diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8ee5186df..bca374acd 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,6 +8,14 @@ updates: labels: - "dependencies" open-pull-requests-limit: 10 + ignore: + # Module path moved upstream to cel.dev/cel-go (v0.26.0+); dependabot + # reports go_module_path_mismatch and fails the whole gomod job. + # Remove once /src/go.mod migrates to the new module path. + - dependency-name: "github.com/google/cel-go" + # teatest lives in a nested module with independent tags; resolving + # github.com/charmbracelet/x@v0.1.0 fails (dependency_file_not_resolvable). + - dependency-name: "github.com/charmbracelet/x/exp/teatest" - package-ecosystem: "github-actions" directory: "/" diff --git a/.github/release-lines.yml b/.github/release-lines.yml index d8a1f8803..c88a470f3 100644 --- a/.github/release-lines.yml +++ b/.github/release-lines.yml @@ -46,6 +46,11 @@ pinned: # release lines and nothing else: a feature branch gets the same gate through # `pull_request`, and running it on every pushed branch would burn runner # minutes re-scanning code that has not reached a release line. + # Added with the docs link checker (#5278), after the v2 line was sunset + # (#5030-era). It gates src/docs/ links on v4 only; v2 is excluded rather + # than back-filled, because the workflow never ran there and adding it + # would gate a line no longer taking doc changes. + docs-link-check.yml: [-v2] go-security-analysis.yml: [] podman-arm64-lane.yml: [] podman-contract.yml: [] diff --git a/.github/workflows/dashboard-lint.yml b/.github/workflows/dashboard-lint.yml index 1adb5ef26..0ab3921cb 100644 --- a/.github/workflows/dashboard-lint.yml +++ b/.github/workflows/dashboard-lint.yml @@ -1,11 +1,23 @@ name: Dashboard Lint + +# The checker itself is in scope, not just the files it checks (#5388). +# The syntax-check job runs .github/scripts/check-inline-js.js, but the filter +# named only 'dashboard/**' — so a change that broke the checker (or silently +# stopped it detecting anything) ran no CI at all, and the next dashboard PR +# would then pass against a checker nobody had exercised. The workflow file is +# listed for the same reason: an edit to the `find` invocation here changes +# what gets checked, and that edit must run the job it changes. on: push: paths: - 'dashboard/**' + - '.github/scripts/check-inline-js.js' + - '.github/workflows/dashboard-lint.yml' pull_request: paths: - 'dashboard/**' + - '.github/scripts/check-inline-js.js' + - '.github/workflows/dashboard-lint.yml' permissions: contents: read diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 397d8509e..0d6d1a428 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -62,6 +62,26 @@ jobs: # merges and is deleted. workflow_dispatch always pushes so a throwaway branch # can be published for a hive on demand before merge. # + # EXCEPTION: `release-gate/*` (#5072). tagged-release.yml pushes its + # release commit to a throwaway `release-gate/v` branch to earn a + # `gate` check on + # that exact SHA before pushing to protected `v4` (see docs/releases.md, + # "Satisfying branch protection"). That push uses the job's default + # GITHUB_TOKEN, and GitHub's documented recursive-workflow guard means a + # GITHUB_TOKEN push never fires another workflow's `push` trigger — so + # tagged-release.yml has to fall back to explicitly dispatching this + # workflow via `gh workflow run docker.yml --ref release-gate/v` + # instead. Without + # this exception, that dispatch would hit the `workflow_dispatch` branch + # below and force push=true on a branch nothing should ever publish images + # for: a full two-platform GHCR push under a one-off scratch branch name, + # solely to obtain a status check that only needs `gate` (a five-second job) + # to run. `release-gate/*` branches are deliberately never in LONG_LIVED and + # are deleted immediately after use (tagged-release.yml's `trap ... EXIT`), + # so this + # exception cannot leave a stray moving tag behind — it only ever prevents + # one from being created in the first place. + # # The long-lived branch set is defined ONCE below (LONG_LIVED). To add or # remove one, edit only that list — the whole workflow derives from this job's # output, so nothing else changes. @@ -71,6 +91,14 @@ jobs: # new release line and never publish its -latest tag (#4462). The # release-line guard asserts this list against .github/release-lines.yml — # see the `env_lists` entry there and src/docs/release-line-guard.md. + # + # NOTE (#5339/#5356): do not special-case `release-gate/*` pull requests in + # this job. GITHUB_TOKEN-opened release PR runs may be recursion-blocked + # before any job starts, and workflow_dispatch check-runs are not associated + # with a PR even when dispatched after it exists. tagged-release.yml handles + # that release-only gap by mirroring its verified check-run as a SHA-scoped + # commit status. Ordinary pull requests still need this job unchanged so + # their head SHA receives the required `gate` check-run (#4965). gate: runs-on: ubuntu-latest outputs: @@ -90,13 +118,24 @@ jobs: EVENT: ${{ github.event_name }} run: | push=false - if [ "$EVENT" = "workflow_dispatch" ]; then - push=true - else - for b in $LONG_LIVED; do - if [ "$b" = "$REF_NAME" ]; then push=true; break; fi - done - fi + case "$REF_NAME" in + release-gate/*) + # See the EXCEPTION comment above this job: this scratch branch + # only ever exists to earn a `gate` check for + # tagged-release.yml, never to publish an image, regardless of + # trigger event. + push=false + ;; + *) + if [ "$EVENT" = "workflow_dispatch" ]; then + push=true + else + for b in $LONG_LIVED; do + if [ "$b" = "$REF_NAME" ]; then push=true; break; fi + done + fi + ;; + esac echo "push=$push" >> "$GITHUB_OUTPUT" echo "Push to GHCR: $push (branch=$REF_NAME event=$EVENT)" @@ -109,7 +148,22 @@ jobs: # real `hive --version` smoke test (`docker`), plus `build-and-test` # (go build + go vet) and `overlayfs-exec-guard`. Building here too would # be a third image build of the same commit. - if: github.event_name != 'pull_request' + # `release-gate/*` is excluded for the same reason `merge*` already skips + # it (gate forces push=false there): these builds publish NOTHING for the + # scratch branch. They are not merely wasted — branch protection evaluates + # the whole check SUITE, so while any job in it runs the `gate` context is + # not treated as satisfied even though its check-run is already `success`. + # The release job's merge waits 120s and a multi-arch build takes ~10min, + # so the merge could never win that race (#5339, 8th recurrence). + # + # No coverage is lost. A release commit modifies CHANGELOG.md and nothing + # else — no source, no Dockerfile — so its tree is byte-identical to the v4 + # tip whose images this workflow already built, published and freshness- + # checked minutes earlier. The `gate` job itself still runs on the scratch + # branch; that is the whole reason the branch exists. + if: >- + github.event_name != 'pull_request' && + !startsWith(github.ref_name, 'release-gate/') strategy: matrix: include: @@ -344,7 +398,16 @@ jobs: build-contributor: needs: gate # Push-only, same as `build` above (#4965). - if: github.event_name != 'pull_request' + # `release-gate/*` is excluded for the same reason `merge*` already skips + # it (gate forces push=false there): these builds publish NOTHING for the + # scratch branch. They are not merely wasted — branch protection evaluates + # the whole check SUITE, so while any job in it runs the `gate` context is + # not treated as satisfied even though its check-run is already `success`. + # The release job's merge waits 120s and a multi-arch build takes ~10min, + # so the merge could never win that race (#5339, 8th recurrence). + if: >- + github.event_name != 'pull_request' && + !startsWith(github.ref_name, 'release-gate/') strategy: matrix: include: @@ -442,7 +505,16 @@ jobs: build-hub: needs: gate # Push-only, same as `build` above (#4965). - if: github.event_name != 'pull_request' + # `release-gate/*` is excluded for the same reason `merge*` already skips + # it (gate forces push=false there): these builds publish NOTHING for the + # scratch branch. They are not merely wasted — branch protection evaluates + # the whole check SUITE, so while any job in it runs the `gate` context is + # not treated as satisfied even though its check-run is already `success`. + # The release job's merge waits 120s and a multi-arch build takes ~10min, + # so the merge could never win that race (#5339, 8th recurrence). + if: >- + github.event_name != 'pull_request' && + !startsWith(github.ref_name, 'release-gate/') strategy: matrix: include: diff --git a/.github/workflows/docs-link-check.yml b/.github/workflows/docs-link-check.yml new file mode 100644 index 000000000..afcd04b75 --- /dev/null +++ b/.github/workflows/docs-link-check.yml @@ -0,0 +1,81 @@ +name: Docs Link Check + +# Why this exists +# +# kubestellar/docs (the org's published docs site, Next.js on Netlify at +# kubestellar.io/docs/hive/*) pulls a growing subset of src/docs/*.md straight +# from this repo's `v4` branch on every site build +# (kubestellar/docs:scripts/sync-hive-docs.ts fetches the raw file over HTTP +# and rewrites relative links to site routes or GitHub blob URLs). That sync +# runs no Markdown linter and no link checker of its own — it trusts this +# source tree to already be internally consistent. +# +# That trust broke concretely once already: #5206 fixed three cross-reference +# anchors that a heading rename in design/tui.md had silently broken. Nothing +# failed red when it happened; a human had to notice. This job is the gate +# that catches the same class of break before merge, for every relative link +# and heading anchor inside src/docs/ — whether or not the file happens to be +# on the sync manifest today, since that manifest only grows over time. +# +# It intentionally does NOT validate http(s)/mailto links (network-dependent, +# and not what broke in #5206) and does not touch the sync manifest itself, +# which lives in the separate kubestellar/docs repository. +# +# The job also guards a second, differently-shaped surface: the wiki vault at +# src/deploy/data/wiki/. src/Dockerfile bakes that tree into the image and +# src/deploy/entrypoint.sh seeds it with `cp -rn /opt/hive/seed-data/* /data/`, +# so it is served FLAT from /data/wiki/ with nothing above it. A parent- +# relative link there resolves for a reviewer browsing this repo and 404s for +# the operator reading the deployed page — the repo view is the one that lies, +# which is what makes the break invisible in review (#5309). The wiki step +# therefore runs --vault-root, which treats the vault directory as the reader's +# whole filesystem and rejects any escape regardless of what exists in this +# checkout. Outbound wiki references belong in absolute blob/v4 URLs, which +# resolve identically in both views (#5308). + +on: + push: + branches: + - v4 + - v5 + paths: + - 'src/docs/**' + - 'src/deploy/data/wiki/**' + - 'src/scripts/check-docs-links.py' + - 'src/scripts/test-check-docs-links.sh' + - '.github/workflows/docs-link-check.yml' + pull_request: + branches: + - v4 + - v5 + paths: + - 'src/docs/**' + - 'src/deploy/data/wiki/**' + - 'src/scripts/check-docs-links.py' + - 'src/scripts/test-check-docs-links.sh' + - '.github/workflows/docs-link-check.yml' + +permissions: + contents: read + +jobs: + link-check: + name: relative links and anchors resolve + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Self-test the checker against fixtures + run: bash src/scripts/test-check-docs-links.sh + + - name: Check src/docs/ relative links and anchors + run: python3 src/scripts/check-docs-links.py src/docs + + # The wiki vault is checked in --vault-root mode, which models the + # DEPLOYED layout rather than this checkout. See the header comment and + # check-docs-links.py's docstring: /data/wiki/ has nothing above it, so a + # parent-relative link is rejected here even when the target exists in + # the repo. Running the plain checker against this tree would pass + # exactly the links that are broken in production (#5309). + - name: Check wiki vault links against the deployed flat layout + run: python3 src/scripts/check-docs-links.py src/deploy/data/wiki --vault-root diff --git a/.github/workflows/go-security-analysis.yml b/.github/workflows/go-security-analysis.yml index bd78c1d6e..57da3e119 100644 --- a/.github/workflows/go-security-analysis.yml +++ b/.github/workflows/go-security-analysis.yml @@ -36,13 +36,22 @@ name: Go Security Analysis # correct and complete attribution. See that job's own comment for the full # reasoning. +# NOTICE is in the path filter because the notice-drift job below compares the +# COMMITTED repo-root NOTICE byte-for-byte against a fresh regeneration +# (`check-notice-drift.sh NOTICE /tmp/NOTICE.generated`). NOTICE does not live +# under src/, so before #5388 a PR editing NOTICE alone — hand-correcting an +# attribution, or reverting the autofix commit — changed the exact file this +# gate polices while running neither that gate nor anything else. The drift +# would surface only on the next unrelated src/ change, attributed to that PR. +# Same shape as dashboard/openapi.json in v2-tests.yml: guarded by a job whose +# filter excluded it. on: push: branches: [v2, v4, v5] - paths: ['src/**', '.github/workflows/**', '.github/release-lines.yml'] + paths: ['src/**', 'NOTICE', '.github/workflows/**', '.github/release-lines.yml'] pull_request: branches: [v2, v4, v5] - paths: ['src/**', '.github/workflows/**', '.github/release-lines.yml'] + paths: ['src/**', 'NOTICE', '.github/workflows/**', '.github/release-lines.yml'] schedule: # Weekly, so a newly-PUBLISHED advisory against unchanged code is still # found. A push-only trigger cannot catch that: the vulnerability appears @@ -145,7 +154,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # A fabricated 40-hex SHA looks exactly like a correct pin and is only - # caught when the workflow RUNS. release.yml shipped one (#4908), so every + # caught when the workflow RUNS. tagged-release.yml shipped one (#4908), so every # tagged release failed at "Prepare all required actions" — discovered # only when someone tried to cut a release. - name: Check every pinned action SHA exists @@ -185,6 +194,16 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Exercise NOTICE drift diagnostics + run: src/scripts/test-check-notice-drift.sh + + # The autofix workflow commits a generated NOTICE unattended, so the + # guard that decides whether a generated file is fit to commit needs its + # own regression coverage here — notice-autofix.yml runs on + # workflow_run and cannot gate a PR itself. + - name: Exercise generated-NOTICE validation + run: src/scripts/test-validate-generated-notice.sh + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: '1.25' @@ -193,11 +212,38 @@ jobs: - name: Regenerate NOTICE from the current module graph run: src/scripts/generate-notice.sh /tmp/NOTICE.generated + # Publish the regenerated file BEFORE the gate runs, so it is uploaded + # on the failing path too — that is the only path where anyone needs it. + # notice-autofix.yml consumes this artifact via workflow_run to commit + # the regeneration back onto a Dependabot branch (#5256). Uploading here + # rather than regenerating in the autofix workflow is deliberate: this + # job already runs the generator with a read-only token and no secrets, + # so the privileged workflow never has to execute PR-head code. + - name: Publish the regenerated NOTICE for the autofix workflow + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: notice-generated + path: /tmp/NOTICE.generated + retention-days: 1 + if-no-files-found: error + + # The PR number travels with the artifact because workflow_run does not + # expose it: the triggering run's event payload is not forwarded, and + # resolving head SHA back to a PR is an extra API round trip that can + # match the wrong PR when several share a head. + - name: Record the PR number for the autofix workflow + if: github.event_name == 'pull_request' + run: echo "${{ github.event.pull_request.number }}" > /tmp/pr-number.txt + + - name: Publish the PR number for the autofix workflow + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: notice-pr-number + path: /tmp/pr-number.txt + retention-days: 1 + if-no-files-found: error + - name: Fail if the committed NOTICE is stale - run: | - set -euo pipefail - if ! diff -u NOTICE /tmp/NOTICE.generated; then - echo "::error::NOTICE is out of date with src/go.mod / src/go.sum. Run 'src/scripts/generate-notice.sh' locally (requires a Go toolchain) and commit the regenerated NOTICE." >&2 - exit 1 - fi - echo "NOTICE matches the current module graph." + run: src/scripts/check-notice-drift.sh NOTICE /tmp/NOTICE.generated diff --git a/.github/workflows/image-attestation-guard.yml b/.github/workflows/image-attestation-guard.yml index d9d8189fe..ade0db94e 100644 --- a/.github/workflows/image-attestation-guard.yml +++ b/.github/workflows/image-attestation-guard.yml @@ -16,7 +16,7 @@ name: Image Attestation Guard # branch-pinned, and the path filter below is what keeps it cheap. # # Release SBOMs (added in the same change that added this guard) are -# deliberately NOT part of this workflow — release.yml's SBOM step runs +# deliberately NOT part of this workflow — tagged-release.yml's SBOM step runs # against an already-published image digest and produces a standalone file # attached to the GitHub Release, never an in-image attestation. See # src/docs/releases.md, "Software bill of materials (SBOM)". diff --git a/.github/workflows/notice-autofix.yml b/.github/workflows/notice-autofix.yml new file mode 100644 index 000000000..88d6079fa --- /dev/null +++ b/.github/workflows/notice-autofix.yml @@ -0,0 +1,137 @@ +# NOTICE Autofix — commit the regenerated NOTICE back onto Dependabot's own +# Go-module bump branches. +# +# WHY THIS WORKFLOW EXISTS (#5256) +# +# The "NOTICE matches the module graph" gate (go-security-analysis.yml, job +# notice-drift) compares the committed NOTICE byte-for-byte against a fresh +# regeneration from src/go.mod + src/go.sum. That comparison is correct and +# worth keeping — it is what surfaced an AGPL-3.0 dependency entering this +# Apache-2.0 project (#5016). But it fails on EVERY Dependabot gomod PR by +# construction: a version bump changes the module graph, so the committed +# NOTICE necessarily no longer matches, and Dependabot cannot regenerate it +# (that needs a Go toolchain and the pinned go-licenses). Result: 100% of +# gomod bumps arrive permanently red and each one needs a human to check out +# the branch, run the generator, and push. +# +# This workflow closes that loop. It does NOT weaken the gate: the gate still +# runs, still compares byte-for-byte, and still fails when NOTICE is stale. +# What changes is that for a Dependabot branch the staleness is repaired +# automatically, so the next run of the gate passes on a correct NOTICE +# rather than a human doing the same mechanical regeneration by hand. +# +# WHY workflow_run AND NOT pull_request_target +# +# The obvious implementation is a pull_request_target job that checks out the +# PR head and runs src/scripts/generate-notice.sh with a contents:write token. +# That is unsafe even when gated to Dependabot: generate-notice.sh resolves +# and downloads the module graph described by the PR's own go.mod/go.sum, so +# it executes tooling against attacker-influenceable inputs while holding a +# token that can write to the repository. `go install`, module resolution and +# go-licenses all run arbitrary upstream code paths. +# +# workflow_run splits the privilege from the untrusted work: +# +# * go-security-analysis.yml's notice-drift job runs on `pull_request` — +# read-only token, no secrets — and uploads its already-generated +# /tmp/NOTICE.generated as an artifact. It was going to generate that +# file anyway; it now publishes it. +# * this workflow runs on `workflow_run`, so it executes the version of +# itself committed on the DEFAULT branch, never the PR's version. It +# downloads the artifact and commits it. It never checks out or runs any +# code from the PR head. +# +# The artifact is data, not code, and it is validated below before use. +# +# WHY THE VALIDATION STEP IS NOT OPTIONAL +# +# Committing a generated file from an artifact means the artifact's content +# becomes the repository's attribution record. Three things are checked +# before anything is committed, and any failure aborts loudly rather than +# committing a degraded NOTICE: +# +# 1. The file must carry the generator's real header — a truncated or +# empty artifact must never overwrite a good NOTICE. +# 2. No trailing-whitespace lines (#5064). The generator normalizes these, +# but committing an unnormalized file would guarantee a permanent diff +# against every future regeneration. +# 3. No unverified/missing license text, and no FORBIDDEN license class. +# A copyleft dependency entering the tree must still stop the pipeline +# and reach a human — auto-committing a NOTICE that records an AGPL +# dependency would launder a license problem into a green check. +# +# KNOWN LIMITATION +# +# A push made with GITHUB_TOKEN does not trigger new workflow runs, so the +# autofix commit does not itself re-run notice-drift. The branch is correct +# from that moment on and the gate passes on the next run (Dependabot's next +# rebase, a maintainer re-run, or any later push). The value delivered is +# that no human has to regenerate the file. If Dependabot force-pushes a +# rebase the autofix commit is dropped and this workflow simply runs again. + +name: NOTICE Autofix + +on: + workflow_run: + workflows: ['Go Security Analysis'] + types: [completed] + +# contents:write is the only privilege needed and the only one granted. +permissions: + contents: write + +concurrency: + group: notice-autofix-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + +jobs: + regenerate-notice: + name: Commit regenerated NOTICE for Dependabot + runs-on: ubuntu-latest + # Gate on the triggering run: a Dependabot-authored PR from a branch in + # this repository. Dependabot never opens PRs from forks, so requiring + # head_repository == repository closes the fork vector completely. + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.actor.login == 'dependabot[bot]' && + github.event.workflow_run.head_repository.full_name == github.repository && + startsWith(github.event.workflow_run.head_branch, 'dependabot/') + steps: + # Check out the DEFAULT branch state of the PR's branch — i.e. the + # Dependabot branch itself, which contains only manifest/lockfile + # changes. Nothing from it is executed; it is the commit target. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.workflow_run.head_branch }} + fetch-depth: 0 + + - name: Download the regenerated NOTICE + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: notice-generated + path: /tmp/notice-artifact + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Validate the regenerated NOTICE before trusting it + run: src/scripts/validate-generated-notice.sh /tmp/notice-artifact/NOTICE.generated + + - name: Commit and push if NOTICE changed + env: + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + run: | + set -euo pipefail + cp -- /tmp/notice-artifact/NOTICE.generated NOTICE + if git diff --quiet -- NOTICE; then + echo "NOTICE already matches the module graph — nothing to do." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -- NOTICE + # -s because this repository requires DCO sign-off on every commit. + git commit -s -m "chore: regenerate NOTICE for dependency bump + + Regenerated by .github/workflows/notice-autofix.yml from the module + graph in this branch's src/go.mod and src/go.sum. See #5256." + git push origin "HEAD:${HEAD_BRANCH}" diff --git a/.github/workflows/podman-arm64-lane.yml b/.github/workflows/podman-arm64-lane.yml index 3b80f0d8f..ba30419ec 100644 --- a/.github/workflows/podman-arm64-lane.yml +++ b/.github/workflows/podman-arm64-lane.yml @@ -26,14 +26,49 @@ name: Podman arm64 Lane # would be tested at all. If the architectures ever DO diverge, that is the # signal to widen this lane — not a reason to widen it now. # -# WHY PULL RATHER THAN BUILD. #4188 asks for "build/pull plus startup", and the -# arm64 BUILD is already a per-push gate: .github/workflows/docker.yml builds -# linux/arm64 on this same runner label on every branch and asserts the -# embedded commit matches the built SHA. What no lane covered is the other -# half — that the published arm64 artifact pulls under Podman and starts. So -# this lane pulls, and per #4336's stop condition it does NOT build an image ad -# hoc: a missing arm64 manifest is reported and fails the job, because the fix -# for that belongs in the publisher, not in a lane papering over it. +# WHY PULL RATHER THAN BUILD, AND WHERE THAT STOPPED HOLDING (#5370). #4188 +# asks for "build/pull plus startup", and the arm64 BUILD is already a per-push +# gate: .github/workflows/docker.yml builds linux/arm64 on this same runner +# label on every branch and asserts the embedded commit matches the built SHA. +# What no lane covered is the other half — that the published arm64 artifact +# pulls under Podman and starts. So on a PUSH this lane pulls, and per #4336's +# stop condition it does NOT build an image ad hoc: a missing arm64 manifest is +# reported and fails the job, because the fix for that belongs in the +# publisher, not in a lane papering over it. +# +# On a PULL REQUEST that reasoning inverts, and #5370 is what it cost. Probing +# the published image on a PR validates code that is already on v4, not the +# change proposed — so the lane cannot gate a fix to the code it tests. A PR +# that REPAIRS a startup bug runs against the still-broken published image and +# stays red; a PR that INTRODUCES one runs against the still-good published +# image and goes green. The signal is inverted exactly when it matters. +# +# Measured, not hypothetical: #5342's 0600 hardening broke container startup +# (#5360). It merged green because the lane probed the pre-#5342 image, then +# stayed red across four merges, and #5368 — the fix — was red too, because +# entrypoint.sh is baked into the image and the lane kept starting the old one. +# +# THE PUBLISHED-IMAGE ROUTE DOES NOT EXIST FOR A PR. #5370 proposed probing a +# per-SHA image built from the PR head. Per-SHA tags are real +# (ghcr.io/kubestellar/hive:<7-char-sha>) but are created ONLY by docker.yml's +# `merge` job under push == 'true', and docker.yml (a) skips its `build` job +# entirely on `pull_request` and (b) allow-lists pushes to LONG_LIVED branches +# ("v2 v4 mk dd") only, so a PR branch's own push run builds as a compile gate +# and publishes nothing. A fork PR never builds in this repo at all. So a PR +# head SHA will NEVER have a published image to pull, and the suggested fix +# cannot work as written. +# +# What this lane does instead: on a pull request it BUILDS the image from the +# PR's checkout and probes that. #4336's "do not build ad hoc" stop condition +# was written to stop this lane papering over a broken PUBLISHER — that reason +# is untouched, and the push path still pulls and still fails on a missing +# manifest. Building here is the only way to put the PR's own entrypoint.sh in +# front of the probe. +# +# COST. The equivalent arm64 build in docker.yml runs ~3.6 min on this runner +# label with the tmux stage served from the gha cache, inside this lane's +# 30-minute timeout. The build is local only (`podman build`, no push, no +# registry credentials), so the lane keeps `contents: read` and no secrets. # # A MISSING arm64 MANIFEST IS A FAILURE, NOT A SKIP. An arm64 lane that steps # aside when the arm64 image is absent reports green while proving nothing — @@ -62,7 +97,13 @@ on: workflow_dispatch: inputs: image: - description: 'Image to probe (default ghcr.io/kubestellar/hive:v4-latest)' + # The old text here said v4-latest and was wrong (#5370). Nothing + # defaults to that: when this input is empty the lane passes no + # --image and probe_arm64_image_startup.sh falls back to + # HIVE_STANDALONE_IMAGE_HIVE from src/deploy/standalone-images.sh, + # which is ghcr.io/kubestellar/hive:stable — the RELEASE CHANNEL tag, + # further behind v4 tip than v4-latest is. Say what actually happens. + description: 'Image to probe. Empty = ghcr.io/kubestellar/hive:stable on a push, or the image built from the PR checkout on a pull request.' type: string default: '' @@ -144,6 +185,92 @@ jobs: printf '| Runner | `%s`, image `%s` |\n' "$RUNNER_OS" "${ImageVersion:-unknown}" } >>"$GITHUB_STEP_SUMMARY" + # ── #5370: on a PR, probe THIS PR's code, not the published image ── + # + # Builds the image from the checkout and leaves it in the runner's local + # Podman store under a PR-specific tag, which the probe step then uses + # instead of the published tag. Without this the lane starts an image + # built from code that is already merged, and its result says nothing + # about the change under review — see the header for the measured cost + # of that (#5342 merged green, #5368 was red for fixing it). + # + # Local only: no `podman push`, no registry login, no secrets. The lane's + # `contents: read` permission is unchanged. + # + # NOT continue-on-error, and no `|| true`. A build failure here is a real + # result — the PR does not produce a runnable image — and must be as loud + # as a probe failure. Silently falling back to the published image on a + # build error would restore the exact inverted signal this step removes. + # + # `--platform linux/arm64` is asserted rather than assumed even though + # the runner is arm64: the environment step above already proved the + # architecture, and naming it here means a runner-label change surfaces + # as a build error instead of a silently-amd64 image. + - name: Build the arm64 image from this PR (#5370) + if: github.event_name == 'pull_request' && inputs.image == '' + env: + PR_IMAGE: localhost/hive-pr:arm64 + run: | + set -euo pipefail + + echo "Building ${PR_IMAGE} from src/Dockerfile at $(git rev-parse --short HEAD)" + echo "This is the PR's own code — the published image would be the pre-PR code (#5370)." + + podman build \ + --platform linux/arm64 \ + --file src/Dockerfile \ + --tag "${PR_IMAGE}" \ + . + + # Prove the thing that was built is arm64 and really exists locally. + # A build that silently produced another architecture would make the + # probe below meaningless in the same way probing the published image + # was meaningless. + built_arch="$(podman image inspect "${PR_IMAGE}" --format '{{.Architecture}}')" + if [ "$built_arch" != "arm64" ]; then + echo "::error::built image is ${built_arch}, expected arm64" + exit 1 + fi + echo "built ${PR_IMAGE} (${built_arch})" + + # Selects what the probe actually runs against, so the choice is made in + # ONE place and reported. On a pull request that is the image built + # above; otherwise it is the caller's --image, or the probe's own default + # (ghcr.io/kubestellar/hive:stable) when that is empty too. + - name: Select the image to probe + id: select + env: + INPUT_IMAGE: ${{ inputs.image }} + IS_PR: ${{ github.event_name == 'pull_request' }} + run: | + set -euo pipefail + + # An explicit dispatch input always wins, and is treated as a + # registry reference: the operator asked for a specific published + # image, so the manifest and pull cases stay meaningful. + if [ -n "${INPUT_IMAGE:-}" ]; then + image="${INPUT_IMAGE}" + local_build="false" + why="explicit workflow_dispatch input" + elif [ "${IS_PR}" = "true" ]; then + image="localhost/hive-pr:arm64" + local_build="true" + why="built from this PR's checkout (#5370) — the published image would be the pre-PR code and could not gate this change" + else + image="" + local_build="false" + why="probe default (ghcr.io/kubestellar/hive:stable) — the published image, which is the right target on a push" + fi + + echo "image=${image}" >>"$GITHUB_OUTPUT" + echo "local=${local_build}" >>"$GITHUB_OUTPUT" + printf 'probing: %s\n' "${image:-}" + printf 'reason: %s\n' "$why" + { + printf '\n**Image under test:** `%s`\n\n' "${image:-ghcr.io/kubestellar/hive:stable}" + printf '%s\n' "$why" + } >>"$GITHUB_STEP_SUMMARY" + # The probe's exit status is the result. 0 means the arm64 image pulled # and the service started; 1 means one of the four checks failed — a # missing arm64 manifest lands here rather than skipping; 78 means a @@ -153,7 +280,8 @@ jobs: # There is deliberately no continue-on-error and no `|| true`. - name: arm64 image pull and service startup (#4336) env: - IMAGE: ${{ inputs.image }} + IMAGE: ${{ steps.select.outputs.image }} + LOCAL_BUILD: ${{ steps.select.outputs.local }} run: | set -uo pipefail @@ -161,6 +289,13 @@ jobs: if [ -n "${IMAGE:-}" ]; then args="--image ${IMAGE}" fi + # #5370: a PR-built image is in the local store and was never + # published, so the manifest and pull cases do not apply to it. The + # probe skips those two under --local and still runs the cases that + # carry this lane's signal — the binary executes, the service starts. + if [ "${LOCAL_BUILD}" = "true" ]; then + args="${args} --local" + fi # The runner invokes this with `bash -e`, so errexit has to come off # deliberately: the probe's exit status IS the result and has to @@ -173,8 +308,16 @@ jobs: case "$status" in 0) - echo "::notice::the arm64 image pulled and the service started" - printf '\n✅ The published arm64 image pulled, `/usr/local/bin/hive` ran, and the service answered `GET /api/health`.\n' >>"$GITHUB_STEP_SUMMARY" + # Say which image actually passed. Reporting "the published + # image" after probing a PR build would misdescribe the result + # in the same direction as the bug (#5370). + if [ "${LOCAL_BUILD}" = "true" ]; then + echo "::notice::the arm64 image built from this PR started" + printf '\n✅ The arm64 image **built from this PR** ran `/usr/local/bin/hive` and answered `GET /api/health`.\n' >>"$GITHUB_STEP_SUMMARY" + else + echo "::notice::the published arm64 image pulled and the service started" + printf '\n✅ The published arm64 image pulled, `/usr/local/bin/hive` ran, and the service answered `GET /api/health`.\n' >>"$GITHUB_STEP_SUMMARY" + fi ;; 78) echo "::error::the probe could not run — a prerequisite is missing (EX_CONFIG). A lane that cannot run is not a lane that passed." @@ -187,3 +330,95 @@ jobs: esac exit "$status" + + # ── #5380: run the entrypoint behavioural suites where root exists ── + # + # THE PROBLEM THIS CLOSES. test_entrypoint_runtime_config.sh (#5368, for + # #5360) and test_entrypoint_data_ownership.sh (#5375, for #5369) each + # carry a behavioural block that is the whole reason the file exists: it + # creates a root-owned file and then really open()s it as the uid the + # hive process drops to. That is the assertion mode-checking could not + # make — #5360 shipped green behind a mode-only check and took four + # merges to diagnose. + # + # Both blocks need root AND a `dev` account. v2-ci.yml runs them on + # ubuntu-latest, which is neither: the runner user is uid 1001 but named + # `runner`, so `id -u dev` fails and BOTH blocks skip on every PR. They + # skip loudly rather than faking a pass — correct, and preserved — but a + # guard that cannot fail is not a guard. The strongest assertions in both + # files have never executed in CI. + # + # WHY HERE. This lane already builds the PR's own image (#5370, above) + # and starts a container from it. That image has root and has `dev` at + # uid 1001 (src/Dockerfile:165, `useradd -m -u 1001 -g node ... dev`), so + # it is the one place in CI where these blocks can actually run — and it + # runs the PR's code, so a regression is caught before merge rather than + # after. + # + # HOW. The suites are NOT baked into the image (they are test-only, and + # should stay out of a shipped artifact). They are bind-mounted in from + # the checkout instead. Both resolve the entrypoint under test as + # `$(dirname "$0")/entrypoint.sh`, so mounting src/deploy gives each + # suite the PR's own entrypoint.sh next to it — the same file the image + # was built from. + # + # AND A SKIP HERE IS FATAL. HIVE_TEST_REQUIRE_BEHAVIOURAL=1 turns every + # skip in those blocks into a FAILURE. This is the load-bearing half of + # #5380: in a container that DOES have root and DOES have `dev`, a skip + # cannot mean "unsuitable environment" — it means a precondition changed + # and the test silently stopped testing. Without this the whole problem + # recurs the moment someone edits the guard. The bare-runner path in + # v2-ci.yml is untouched and still skips loudly, so both suites stay + # runnable on a laptop and on an unprivileged runner. + # + # Only on the PR path, where a local image was built. On a push the image + # is a published registry reference and this lane's contract is the + # pull/startup probe above; the suites still run in v2-ci.yml there. + # + # --user root is explicit: the image's own USER is dev, and the entire + # point is to exercise the root-creates / dev-reads transition. + - name: Entrypoint behavioural suites, in-container (#5380) + if: github.event_name == 'pull_request' && steps.select.outputs.local == 'true' + env: + PR_IMAGE: localhost/hive-pr:arm64 + run: | + set -euo pipefail + + echo "Running the entrypoint behavioural suites inside ${PR_IMAGE}." + echo "Root and the 'dev' account both exist there, so the blocks that" + echo "skip on ubuntu-latest actually execute — and a skip is FATAL." + + status=0 + for suite in test_entrypoint_runtime_config test_entrypoint_data_ownership; do + echo "" + echo "── ${suite}.sh ─────────────────────────────────────────" + # The suite and the entrypoint it extracts functions from are + # mounted together, read-only. :ro,Z keeps SELinux relabelling + # correct and matches how the probe mounts hive.yaml. + if podman run --rm \ + --user root \ + --entrypoint /bin/bash \ + -e HIVE_TEST_REQUIRE_BEHAVIOURAL=1 \ + -v "${GITHUB_WORKSPACE}/src/deploy:/opt/hive-tests:ro,Z" \ + "${PR_IMAGE}" \ + "/opt/hive-tests/${suite}.sh"; then + echo "::notice::${suite}.sh passed IN-CONTAINER with behavioural blocks required" + else + rc=$? + echo "::error::${suite}.sh failed in-container (exit ${rc}). A skip counts as a failure here: this container has root and a 'dev' account, so a skipped behavioural block means the test stopped testing (#5380)." + status=1 + fi + done + + if [ "$status" -eq 0 ]; then + { + printf '\n✅ Entrypoint behavioural suites ran **inside the PR image** with ' + printf '`HIVE_TEST_REQUIRE_BEHAVIOURAL=1` — the root-creates/dev-reads ' + printf 'assertions for #5360 and #5369 actually executed, and a skip ' + printf 'would have failed the lane.\n' + } >>"$GITHUB_STEP_SUMMARY" + else + printf '\n❌ An entrypoint behavioural suite failed in-container (#5380).\n' >>"$GITHUB_STEP_SUMMARY" + fi + + exit "$status" diff --git a/.github/workflows/podman-rootful-lane.yml b/.github/workflows/podman-rootful-lane.yml index e170167ad..7fb07398c 100644 --- a/.github/workflows/podman-rootful-lane.yml +++ b/.github/workflows/podman-rootful-lane.yml @@ -28,6 +28,15 @@ name: Podman Rootful Lane # Probing a PR-built image is the map's lane 3 and is outside #4335's boundary, # which is "wrap the existing probe in a workflow". # +# LANE 3 NOW DOES THAT (#5370). podman-arm64-lane.yml builds the PR's own image +# and probes it on pull_request, so a change to src/deploy/entrypoint.sh IS +# covered pre-merge — on arm64, for startup. This lane still probes the +# published image, so the gap that remains here is narrower than the paragraph +# above implies: the PR's own EGRESS-GATE behaviour under rootful Podman stays +# unverified until publication. Closing that means building the image in this +# lane too; #5370 judged the startup half the one worth paying for first, +# because that is the half that had already shipped a regression (#5360). +# # No path filters, on purpose. #4339 is what a skipped guard looks like: a # workflow scoped so narrowly that it reports green by never running. # diff --git a/.github/workflows/podman-rootless-lane.yml b/.github/workflows/podman-rootless-lane.yml index 902ddc7ed..c9960c5a2 100644 --- a/.github/workflows/podman-rootless-lane.yml +++ b/.github/workflows/podman-rootless-lane.yml @@ -22,6 +22,13 @@ name: Podman Rootless Lane # separate lane (the map's lane 3 covers build/pull) and deliberately out of # #4334's boundary, which is "wrap the existing probe in a workflow". # +# LANE 3 NOW DOES THAT (#5370). podman-arm64-lane.yml builds the PR's own image +# and probes it on pull_request, so a change to src/deploy/entrypoint.sh IS +# covered pre-merge — on arm64, for startup. This lane still probes the +# published image, so what remains uncovered here is narrower than the +# paragraph above implies: the PR's own EGRESS-GATE behaviour under rootless +# Podman stays unverified until publication. +# # No path filters, on purpose. #4339 is what a skipped guard looks like: a # workflow scoped so narrowly that it reports green by never running. This lane # is cheap enough (one image pull, three short container runs) to run on every diff --git a/.github/workflows/quadlet-gate.yml b/.github/workflows/quadlet-gate.yml index 2e9e0565d..7866a4e0e 100644 --- a/.github/workflows/quadlet-gate.yml +++ b/.github/workflows/quadlet-gate.yml @@ -99,10 +99,31 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # BOUNDED RETRY, NOT A SKIP PATH (#5273). The digest pin above fixed + # `manifest unknown` — a resolution failure, which no retry helps. This + # covers the other half: the TRANSFER dropping mid-layer. On 2026-08-31 + # the pull died with `unexpected EOF` reading a blob from cdn01.quay.io + # and failed the whole gate for a PR that touches only Go code in + # pkg/dashboard; re-running the identical job passed. Three attempts + # with a short backoff, matching the transient-transport idiom in + # tagged-release.yml. Everything this gate refuses to do stays refused: a + # registry that is really unreachable still ends the job red on the last + # attempt, and there is still no `continue-on-error`, no `|| true`, and + # no path that reports green without the generator in hand. - name: Install the Quadlet generator run: | set -euo pipefail - podman pull -q "$PODMAN_IMAGE" + for attempt in 1 2 3; do + if podman pull -q "$PODMAN_IMAGE"; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "::error::could not pull ${PODMAN_IMAGE} after 3 attempts. The generator is not optional (#4211): failing rather than skipping the gate." + exit 1 + fi + echo "podman pull failed (attempt ${attempt}/3) — retrying in 5s..." + sleep 5 + done echo "pulled $PODMAN_IMAGE" # AC: the job fails if the generator is missing rather than passing diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 1040e8f64..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,340 +0,0 @@ -name: Tagged Release - -# Cuts a tagged, immutable release with NO human step in the normal path — the -# operator asked for releases to be "part of the CI's job automatically so we -# do not have to do it manually ever" and "a natural part of the hive's -# maturity". See src/docs/releases.md for the full contract; this header is -# the short version. -# -# TRIGGER: `workflow_run` on "Build and Push Docker Image" (docker.yml) -# completing successfully on branch v4. NOT a tag push — there is no tag until -# THIS workflow creates one. Chaining off docker.yml rather than a parallel -# `push: branches: [v4]` trigger means this workflow only ever runs after the -# continuous-delivery images for that exact commit are already published, -# which is also the digest this workflow retags — it can never race ahead of -# or duplicate that build. -# -# DECISION: src/scripts/derive-release-version.sh reads CHANGELOG.md's -# `## Unreleased` section (already the human-curated, PR-time judgment call -# for "is this release-worthy", not an emoji-prefix guess — see the script's -# own header for why that signal was rejected) and infers release=false -# (nothing under Unreleased => most merges) or release=true plus a -# major/minor/patch bump from which subsection headers are present. A -# CHANGELOG.md `` marker is the human -# escape hatch when inference would be wrong. -# -# IDEMPOTENCY: this workflow's own release commit (moving Unreleased into a -# dated section) is what empties Unreleased, which re-triggers docker.yml on -# push, which re-triggers this workflow — and on that second pass Unreleased -# is empty, derive-release-version.sh returns release=false, and the workflow -# is a no-op. It never chases its own tail. `concurrency` below additionally -# serializes any two overlapping runs so two merges landing close together -# cannot race two tags for two different commits — imagetools create (via -# publish-image-tags.sh's monotonic run-number guard, same as docker.yml) -# never regresses a moving tag, and the immutable version tag is refused -# outright if it already exists (a re-run after a partial failure is a safe -# no-op, not a duplicate release). -on: - workflow_run: - workflows: ["Build and Push Docker Image"] - types: [completed] - -concurrency: - group: tagged-release-v4 - cancel-in-progress: false - -permissions: - contents: write - packages: write - -jobs: - decide: - if: >- - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_branch == 'v4' && - github.event.workflow_run.event != 'workflow_dispatch' - runs-on: ubuntu-latest - outputs: - release: ${{ steps.derive.outputs.release }} - version: ${{ steps.derive.outputs.version }} - bump: ${{ steps.derive.outputs.bump }} - sha: ${{ github.event.workflow_run.head_sha }} - steps: - - name: Checkout the commit docker.yml just built - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.workflow_run.head_sha }} - fetch-depth: 0 # full history + tags: derive-release-version.sh reads `git tag` - - - name: Derive whether this merge warrants a release - id: derive - run: src/scripts/derive-release-version.sh - - release: - needs: decide - if: needs.decide.outputs.release == 'true' - runs-on: ubuntu-latest - env: - VERSION: ${{ needs.decide.outputs.version }} - SHA: ${{ needs.decide.outputs.sha }} - steps: - - name: Checkout the commit docker.yml just built - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ env.SHA }} - fetch-depth: 0 - - # Refuse outright rather than silently no-op, so a logic bug that - # recomputes an already-released version is loud. The normal - # idempotency path (Unreleased already emptied => release=false) never - # reaches this step at all; reaching it with an existing tag means - # something upstream disagrees with git's own tag list, which is worth - # failing on rather than quietly walking past. - - name: Refuse to reuse an existing tag - run: | - set -euo pipefail - if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then - echo "::error::tag v${VERSION} already exists — refusing to re-release. If this is an unexpected repeat, investigate before re-running." >&2 - exit 1 - fi - - - name: Sanity-check the derived version is well-formed semver - run: | - set -euo pipefail - if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error::derived version '${VERSION}' is not MAJOR.MINOR.PATCH" >&2 - exit 1 - fi - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - - - name: Log in to GHCR - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # REUSE, NOT REBUILD: docker.yml already built and pushed this exact - # commit's multi-arch images (that success is why this workflow is - # running at all — see the workflow_run trigger). `imagetools create` - # retags the already-published short-SHA digest as the immutable - # version tag without touching a Dockerfile, a builder, or a single byte - # of image content — the published v3.1.0 image is byte-identical to - # the v4-latest / image docker.yml just pushed for this commit. - # This is the same primitive src/scripts/publish-image-tags.sh uses for - # the moving tags, applied here for one additional immutable tag per - # image. A build-time VERSION ldflag is intentionally NOT used to - # rebuild these images — see docker.yml's freshness-guard step, which - # already proved the pushed digest's embedded commit matches this exact - # SHA; rebuilding here would only reintroduce the risk that guard - # exists to eliminate. - - name: Tag immutable version images - run: | - set -euo pipefail - short="${SHA:0:7}" - for image in hive hive-contributor hive-hub; do - src="ghcr.io/kubestellar/${image}:${short}" - dst="ghcr.io/kubestellar/${image}:v${VERSION}" - echo "Tagging ${dst} from ${src}" - docker buildx imagetools create -t "$dst" "$src" - done - - # "A release labelled differently from what it reports is worse than no - # release" (the operator's framing). Because this workflow retags rather - # than rebuilds (see above), there is no fresh --version output to check - # against the tag the way docker.yml's freshness guard checks a commit - # hash — the binary inside ghcr.io/kubestellar/hive:v${VERSION} was - # built by an ordinary docker.yml run that never received a VERSION - # build-arg, so today it always reports the "0.0.0-dev" fallback (see - # cmd/hive/main.go and src/docs/releases.md, "What still blocks cutting - # a real release"). Asserting equality here would therefore fail every - # release until that gap closes, which is the wrong failure mode for a - # tagging step. What this DOES assert: the image actually pulls, runs, - # and answers --version at all (the tag is not silently pointing at - # nothing), and it logs the mismatch plainly rather than the workflow - # implying an equality check happened that it did not. - - name: Confirm the newly tagged image runs, and report its embedded version - run: | - set -euo pipefail - img="ghcr.io/kubestellar/hive:v${VERSION}" - docker pull "$img" - reported="$(docker run --rm --entrypoint /usr/local/bin/hive "$img" --version)" - echo "image reports: ${reported}" - if [[ "$reported" == "hive ${VERSION} "* ]]; then - echo "Embedded version matches the release tag." - else - echo "::warning::v${VERSION} is tagged but the image's --version output does not embed that string (reports: ${reported}). This is the known gap in src/docs/releases.md — the image was built by an ordinary docker.yml run with no VERSION build-arg. The GHCR TAG is still correct and immutable; only the binary's self-report is stale." - fi - - # NOT stable/candidate/edge. Channel promotion is deliberate, separate - # policy (src/docs/release-channels.md) and this workflow must never - # silently couple a version tag to it. - - name: Confirm channel tags were not touched - run: | - echo "This workflow only ever writes vX.Y.Z tags. stable/candidate/edge are owned exclusively by docker.yml's merge jobs on every push to v4, independent of whether a release was cut here." - - # SBOM: a standalone release ARTIFACT, not an in-image attestation. - # - # docker.yml sets `provenance: false` / `sbom: false` on every - # build-push-action step on purpose (#3760, guarded by - # image-attestation-guard.yml): an attestation can only be carried by an - # OCI image *index*, and that index form is what let a `COPY --from` - # layer ship an overlayfs metacopy redirect for /usr/local/bin/hive, - # which containerd/k3s and rootless podman present as non-executable - # until copy-up — every hive pulling that image crash-looped at boot. - # The published per-arch digest MUST stay a plain image manifest, so an - # SBOM here is generated as an ordinary file and uploaded to the GitHub - # Release, never attached to the GHCR image in any form. - # - # SOURCE: the already-published image digest, not the source tree. A - # source-tree scan would report what go.mod/package.json ask for; an - # image scan reports what the shipped filesystem actually contains — - # the base OS packages (`apk add`, the eleven-setuid-binary inventory - # the SUID contract already tracks), the Node/tmux layers built from - # source, and the exact resolved Go module versions — which is the more - # faithful record for a security consumer of a specific release. - # - # TOOL/FORMAT: Syft, SPDX JSON. SPDX is the format the OpenSSF Best - # Practices badge and most downstream SBOM consumers (e.g. dependency- - # track) expect natively; Syft needs no separate scan step from a - # running daemon (unlike Trivy's default vuln-first posture) and reads - # directly off a registry reference by digest. - # - # SCOPE / LIMITATION: `hive` and `hive-hub` are multi-arch - # (linux/amd64, linux/arm64); this scans the linux/amd64 manifest of - # each image only, not linux/arm64 separately. Go's cross-compiled - # binaries and the apk/npm layers in this Dockerfile carry the same - # package *versions* on both architectures — only the compiled machine - # code differs, which an SBOM does not describe — so one architecture's - # package manifest is representative of both. This is recorded here - # AND in the generated release notes rather than silently shipping one - # file and implying full multi-arch coverage. - - name: Generate per-image SBOMs (SPDX JSON, Syft, linux/amd64) - uses: anchore/sbom-action@8e94d75ddd33f69f691467e42275782e4bfefe84 # v0.20.9 - with: - image: ghcr.io/kubestellar/hive:v${{ env.VERSION }} - output-file: hive-v${{ env.VERSION }}-sbom.spdx.json - format: spdx-json - artifact-name: "" # do not also attach to a GH Actions run artifact; the release upload below is the distribution point - - - name: Generate contributor image SBOM - uses: anchore/sbom-action@8e94d75ddd33f69f691467e42275782e4bfefe84 # v0.20.9 - with: - image: ghcr.io/kubestellar/hive-contributor:v${{ env.VERSION }} - output-file: hive-contributor-v${{ env.VERSION }}-sbom.spdx.json - format: spdx-json - artifact-name: "" - - - name: Generate hub image SBOM - uses: anchore/sbom-action@8e94d75ddd33f69f691467e42275782e4bfefe84 # v0.20.9 - with: - image: ghcr.io/kubestellar/hive-hub:v${{ env.VERSION }} - output-file: hive-hub-v${{ env.VERSION }}-sbom.spdx.json - format: spdx-json - artifact-name: "" - - # NOTICE: third-party license attribution for the Go module - # dependencies compiled into these images, generated by - # src/scripts/generate-notice.sh and kept fresh on every PR by - # go-security-analysis.yml's notice-drift job (so the copy checked out - # at this exact commit is already authoritative — nothing to - # regenerate here). Attached the same way the SBOMs above are: a - # standalone file on the GitHub Release, copied under a versioned name - # for consistency with the SBOM naming convention rather than - # generated fresh, since NOTICE (unlike the SBOMs, which scan the - # published image) is a source-tree artifact CI already keeps in sync - # with go.mod/go.sum. - - name: Copy NOTICE under a versioned release-asset name - run: cp NOTICE "hive-v${VERSION}-NOTICE" - - - name: Move CHANGELOG.md Unreleased section into a dated release entry - run: | - set -euo pipefail - today="$(date -u +%Y-%m-%d)" - python3 - "$today" <<'PYEOF' - import re, sys - today = sys.argv[1] - version = __import__("os").environ["VERSION"] - path = "CHANGELOG.md" - with open(path, encoding="utf-8") as f: - text = f.read() - - marker = "## Unreleased" - start = text.index(marker) - # Section body runs from just after the heading line to the next - # "## " heading (or EOF). - body_start = text.index("\n", start) + 1 - next_heading = re.search(r"\n## ", text[body_start:]) - body_end = body_start + next_heading.start() + 1 if next_heading else len(text) - body = text[body_start:body_end] - - # Strip any release markers before filing the section under the - # dated heading — they are release-decision metadata, not part of - # the permanent record. - body = re.sub(r"[ \t]*\n?", "", body) - - new_unreleased = f"{marker}\n\n" - dated_heading = f"## {today} (v{version})\n" - replacement = new_unreleased + dated_heading + body - text = text[:start] + replacement + text[body_end:] - with open(path, "w", encoding="utf-8") as f: - f.write(text) - PYEOF - - - name: Commit the release changelog entry - run: | - set -euo pipefail - git config user.name "hive-release-bot" - git config user.email "actions@github.com" - git add CHANGELOG.md - git commit -s -m "🔖 release: v${VERSION} - - Automated release commit. Moves the CHANGELOG.md Unreleased section - into a dated v${VERSION} entry. See src/docs/releases.md." - - - name: Push release commit and tag to v4 - run: | - set -euo pipefail - git tag "v${VERSION}" HEAD - # Push the branch update and the tag together. v4 must be at HEAD - # of the commit this workflow started from (SHA) plus exactly this - # one release commit — if v4 moved underneath us (another merge - # landed mid-workflow), a plain fast-forward push fails loudly - # rather than force-pushing over new work. - git push origin HEAD:refs/heads/v4 - git push origin "refs/tags/v${VERSION}" - - # --notes-file and --generate-notes have fragile combined behavior - # across gh versions, so notes are built as one file: GitHub's own - # auto-generated commit/PR summary first (fetched via `gh api - # .../generate-notes`, the same content --generate-notes would have - # produced), then the SBOM callout appended after it — rather than - # relying on the two flags to compose correctly together. - - name: Create GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - notes_file="$(mktemp)" - gh api "repos/${{ github.repository }}/releases/generate-notes" \ - -f tag_name="v${VERSION}" \ - --jq .body > "$notes_file" || echo "(auto-generated notes unavailable)" > "$notes_file" - { - echo - echo "---" - echo - echo "**SBOMs:** SPDX JSON, generated by Syft against the linux/amd64 image digest, attached below for \`hive\`, \`hive-contributor\`, and \`hive-hub\`. They cover linux/amd64 only — see [releases.md](https://github.com/${{ github.repository }}/blob/v4/src/docs/releases.md#software-bill-of-materials-sbom) for why linux/arm64 is not scanned separately." - echo - echo "**NOTICE:** third-party license attribution for the Go module dependencies compiled into these images, attached below. Does not cover base-image OS packages or the Node.js/tmux layers built from source — see [releases.md](https://github.com/${{ github.repository }}/blob/v4/src/docs/releases.md#third-party-notices) for the exact boundary." - } >> "$notes_file" - gh release create "v${VERSION}" \ - --repo "${{ github.repository }}" \ - --title "v${VERSION}" \ - --notes-file "$notes_file" \ - --target v4 \ - "hive-v${VERSION}-sbom.spdx.json" \ - "hive-contributor-v${VERSION}-sbom.spdx.json" \ - "hive-hub-v${VERSION}-sbom.spdx.json" \ - "hive-v${VERSION}-NOTICE" diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml new file mode 100644 index 000000000..252213585 --- /dev/null +++ b/.github/workflows/tagged-release.yml @@ -0,0 +1,766 @@ +name: Tagged Release + +# Cuts a tagged, immutable release with NO human step in the normal path — the +# operator asked for releases to be "part of the CI's job automatically so we +# do not have to do it manually ever" and "a natural part of the hive's +# maturity". See src/docs/releases.md for the full contract; this header is +# the short version. +# +# TRIGGER: `workflow_run` on "Build and Push Docker Image" (docker.yml) +# completing successfully on branch v4. NOT a tag push — there is no tag until +# THIS workflow creates one. Chaining off docker.yml rather than a parallel +# `push: branches: [v4]` trigger means this workflow only ever runs after the +# continuous-delivery images for that exact commit are already published, +# which is also the digest this workflow retags — it can never race ahead of +# or duplicate that build. +# +# DECISION: src/scripts/derive-release-version.sh reads CHANGELOG.md's +# `## Unreleased` section (already the human-curated, PR-time judgment call +# for "is this release-worthy", not an emoji-prefix guess — see the script's +# own header for why that signal was rejected) and infers release=false +# (nothing under Unreleased => most merges) or release=true plus a +# major/minor/patch bump from which subsection headers are present. A +# CHANGELOG.md `` marker is the human +# escape hatch when inference would be wrong. +# +# RACES (#5142, #5222): the gate-earning dance below takes minutes, and v4 +# can advance underneath it. Two rules keep that from starving releases or +# painting red runs for normal traffic: +# - SUPERSESSION IS DEFERRAL, NOT FAILURE. If v4 has already advanced past +# this run's commit (precheck job), or advances mid-flight (the final +# step's PR merge becomes non-mergeable because the base moved), this run +# steps aside cleanly: the very merge that won triggered its own +# docker.yml run, whose completion triggers a fresh release run for the +# NEWER tip — and Unreleased is untouched, so that successor derives the +# same release. Retrying here on the old tip could never succeed anyway: +# the images were built from THIS commit, so rebasing the release commit +# onto the new tip would tag content the published images do not +# contain. +# - MERGEABILITY SETTLING IS RETRIED. A direct `git push` to v4 (the +# pre-#5222 approach) was retried on GH006 under the theory that branch +# protection just needed a few seconds to ingest the gate check-run's +# conclusion — but that check-run belongs to the scratch branch's +# workflow_dispatch suite, not to v4's protected-ref evaluation, so no +# amount of retrying could ever have worked (confirmed live: 15 retries +# over 120s, gate green throughout — run 33330740324). The final step now +# opens a PR from the scratch branch into v4 and merges it after publishing +# the SHA-scoped `gate` status described below. That merge call is still +# retried for a bounded window in case GitHub needs a moment to ingest the +# status and recompute mergeability. +# - THE MERGE IS SHA-KEYED, NOT AGGREGATE-KEYED (#5318/#5324). That merge +# is performed by `gh api -X PUT .../pulls/{n}/merge -f sha=`, NOT +# by `gh pr merge`. `gh pr merge` refuses any PR whose AGGREGATE +# `mergeStateStatus` is BLOCKED, and a pending NON-required commit +# status is enough to force BLOCKED even when the only required context +# is green: on release PR #5319, `gate` was success and `dco` was +# success, but `tide` sat PENDING (Tide never reports on a PR it will +# not act on), so `gh pr merge` returned "the base branch policy +# prohibits the merge" on every attempt for the full retry window. The +# API call is evaluated server-side against v4's ACTUAL required +# contexts for that SHA — protection is still fully enforced, an +# unsatisfied required check still yields 405 — it simply stops +# consulting a status the repo does not require. This is the same +# "trust the SHA lookup, not the aggregate or branch-scoped view" +# reasoning as the wait-for-gate step, extended one step further. +# - THE PR ROLLUP NEEDS A SHA-SCOPED `gate` STATUS (#5356). The scratch +# workflow_dispatch run proves the release commit by producing a green +# `gate` check-run, but that check-run has `pull_requests: []`. Opening a +# PR later does not retroactively associate it, and even re-dispatching +# docker.yml after the PR exists leaves the new check-run unassociated. +# Release PR #5402 proved recency was not the discriminator: its second +# green suite was newer than every empty PR-open suite by 35 seconds, yet +# protection still returned `Required status check "gate" is expected` +# for the full merge window. The PR rollup contained only the SHA-scoped +# `dco` and `tide` commit statuses; it never contained the green check-run. +# +# Once the exact-SHA wait below verifies docker.yml's real `gate`, the +# merge step mirrors that verdict as a `gate: success` commit status before +# opening the PR. Commit statuses have no check-suite or PR-association +# requirement, so the release PR rollup can see it. The mirror does not +# manufacture a verdict: a red or missing check-run stops the preceding +# step, and a failed status POST stops the merge step. The SHA-keyed merge +# remains server-side protection-enforced; the change only makes the +# already-earned verdict visible in the representation protection reads. +# +# IDEMPOTENCY: this workflow's own release commit (moving Unreleased into a +# dated section) is what empties Unreleased, which re-triggers docker.yml on +# push, which re-triggers this workflow — and on that second pass Unreleased +# is empty, derive-release-version.sh returns release=false, and the workflow +# is a no-op. It never chases its own tail. `concurrency` below additionally +# serializes any two overlapping runs so two merges landing close together +# cannot race two tags for two different commits — imagetools create (via +# publish-image-tags.sh's monotonic run-number guard, same as docker.yml) +# never regresses a moving tag, and the immutable version tag is refused +# outright if it already exists (a re-run after a partial failure is a safe +# no-op, not a duplicate release). +# BACKSTOP (#5318): the workflow_run trigger alone cannot guarantee that a +# release-worthy commit ever gets a release opportunity. Two ways one is lost +# silently: +# - A docker.yml run that is CANCELLED never fires workflow_run at all. A +# multi-arch build takes ~10 minutes, so when merges arrive faster than +# that, most commits never complete one. +# - A run that DOES fire defers at `precheck` when v4 has already advanced, +# on the reasoning that the successor releases instead. That reasoning is +# sound per-run but has no terminating condition: under sustained merges +# every successor applies the same test and defers again. +# Neither is a bug in the deferral itself — a superseded run genuinely must +# not release, because its images were built from the old tree (see RACES). +# The gap is that nothing ever comes back for the work left behind. The +# schedule below is that "come back": it runs against v4's CURRENT tip, whose +# images docker.yml has long since published, so it releases the accumulated +# Unreleased section without ever tagging content the images do not contain. +# On a healthy repo it is a no-op (Unreleased is empty => release=false). +on: + workflow_run: + workflows: ["Build and Push Docker Image"] + types: [completed] + schedule: + # Hourly, offset off the hour to avoid GitHub's :00 scheduling crush. + - cron: "37 * * * *" + workflow_dispatch: + +concurrency: + group: tagged-release-v4 + cancel-in-progress: false + +permissions: + contents: write + packages: write + # `actions: write` is required for the `release` job's `gh workflow run + # docker.yml` call (#5072) — dispatching a workflow via the API needs this + # scope on top of `contents: write`/`packages: write`, which only cover the + # git push and image-tag operations this workflow already did. + actions: write + # `pull-requests: write` is required for the `release` job's PR-merge path + # to v4 (#5222, 4th GH006 recurrence): a direct `git push` to v4 is + # evaluated against required status checks by the push event, and never + # sees the `gate` check-run earned on the scratch branch even though it is + # on the identical SHA. The release therefore enters through a protected PR + # merge, using the mirrored status described immediately below. + pull-requests: write + +jobs: + decide: + # workflow_run: only a successful docker.yml build of a real v4 push (a + # workflow_dispatch build is the scratch-branch gate dance this workflow + # itself starts — releasing off it would be releasing off our own tail). + # schedule/workflow_dispatch: the #5318 backstop, which has no triggering + # run to qualify and instead resolves v4's current tip below. + if: >- + github.event_name != 'workflow_run' || ( + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'v4' && + github.event.workflow_run.event != 'workflow_dispatch' ) + runs-on: ubuntu-latest + outputs: + release: ${{ steps.derive.outputs.release }} + version: ${{ steps.derive.outputs.version }} + bump: ${{ steps.derive.outputs.bump }} + sha: ${{ steps.target.outputs.sha }} + steps: + # workflow_run carries the exact commit docker.yml built. The backstop + # has no such commit, so it resolves v4's tip — whose images are already + # published (its docker.yml run completed long before an hourly tick), + # which is what keeps the retag honest. + - name: Resolve the commit to consider releasing + id: target + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "workflow_run" ]; then + sha="${{ github.event.workflow_run.head_sha }}" + echo "Triggered by docker.yml on ${sha}." + else + sha="$(gh api "repos/${{ github.repository }}/branches/v4" --jq .commit.sha)" + echo "Backstop (${{ github.event_name }}): considering v4 tip ${sha}." + fi + echo "sha=${sha}" >> "$GITHUB_OUTPUT" + + - name: Checkout the commit docker.yml just built + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.target.outputs.sha }} + fetch-depth: 0 # full history + tags: derive-release-version.sh reads `git tag` + + # The backstop must not tag a commit whose images were never published: + # it retags an existing digest, so a tip whose docker.yml run is still + # running (or was cancelled) has nothing to retag. Stand down and let + # the next tick take it — by then the build has settled either way. + # + # This stands down GREEN (an output the release job gates on), not by + # failing. A tip whose build is still in flight is the ordinary state of + # a busy branch, and an hourly job that paints the repo red for it would + # be training everyone to ignore exactly the signal #5318 added. + - name: Require published images for the backstop's target + id: images + if: github.event_name != 'workflow_run' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + sha="${{ steps.target.outputs.sha }}" + conclusion="$(gh api \ + "repos/${{ github.repository }}/actions/workflows/docker.yml/runs?head_sha=${sha}&status=completed" \ + --jq '[.workflow_runs[] | select(.event == "push")] | last | .conclusion // ""')" + if [ "$conclusion" != "success" ]; then + echo "::warning::Backstop standing down: docker.yml has no successful completed push run for v4 tip ${sha} (found '${conclusion:-none}'), so there is no published digest to retag. The next hourly tick will retry. If this persists, the images for v4's tip are not being built (#5318)." + echo "published=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "docker.yml succeeded on ${sha} — images are published, safe to retag." + echo "published=true" >> "$GITHUB_OUTPUT" + + - name: Derive whether this merge warrants a release + id: derive + if: github.event_name == 'workflow_run' || steps.images.outputs.published == 'true' + run: src/scripts/derive-release-version.sh + + # Cheap supersession check BEFORE the expensive release job (#5142): if v4 + # already moved past the commit this run was triggered for, every minute + # spent here is wasted — the final push can only ever be rejected + # non-fast-forward — and worse, the `concurrency` group above serializes + # release runs, so a doomed run delays the successor that CAN release. One + # API call decides in seconds. + # + # #5318: this job used to claim the successor was GUARANTEED to exist ("the + # merge that advanced v4 triggered docker.yml, whose completion triggers + # tagged-release.yml for that newer tip"). That guarantee does not hold — a + # cancelled docker.yml run fires no workflow_run at all, and a successor + # that does fire applies this same test and defers again. Deferring is still + # the right call for THIS run (its images were built from the old tree), so + # the fix is not to stop deferring; it is that the schedule trigger above + # now provides the terminating condition, and the deferral is logged as a + # ::warning:: rather than a ::notice:: so a lost opportunity is visible in + # the run list instead of silently disappearing. + precheck: + needs: decide + if: needs.decide.outputs.release == 'true' + runs-on: ubuntu-latest + outputs: + proceed: ${{ steps.tip.outputs.proceed }} + steps: + - name: Defer to the successor run if v4 already advanced + id: tip + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + sha="${{ needs.decide.outputs.sha }}" + tip="$(gh api "repos/${{ github.repository }}/branches/v4" --jq .commit.sha)" + if [ "$tip" = "$sha" ]; then + echo "v4 is still at ${sha} — proceeding with the release." + echo "proceed=true" >> "$GITHUB_OUTPUT" + else + echo "::warning::Release opportunity skipped for ${sha}: v4 is already at ${tip}. Deferring — this run's images were built from the older tree, so it must not tag. Unreleased is untouched, so nothing is dropped: either a successor run releases it, or the hourly backstop does. See the RACES note and the BACKSTOP note in this workflow's header (#5142/#5318)." + echo "proceed=false" >> "$GITHUB_OUTPUT" + fi + + release: + needs: [decide, precheck] + if: needs.decide.outputs.release == 'true' && needs.precheck.outputs.proceed == 'true' + runs-on: ubuntu-latest + # Job-level permissions replace the workflow defaults. Repeat the release + # job's existing scopes and add statuses:write only here, so decide and + # precheck cannot manufacture a required context (#5356). + permissions: + contents: write + packages: write + actions: write + pull-requests: write + statuses: write + env: + VERSION: ${{ needs.decide.outputs.version }} + SHA: ${{ needs.decide.outputs.sha }} + steps: + - name: Checkout the commit docker.yml just built + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.SHA }} + fetch-depth: 0 + + # Refuse outright rather than silently no-op, so a logic bug that + # recomputes an already-released version is loud. The normal + # idempotency path (Unreleased already emptied => release=false) never + # reaches this step at all; reaching it with an existing tag means + # something upstream disagrees with git's own tag list, which is worth + # failing on rather than quietly walking past. + - name: Refuse to reuse an existing tag + run: | + set -euo pipefail + if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then + echo "::error::tag v${VERSION} already exists — refusing to re-release. If this is an unexpected repeat, investigate before re-running." >&2 + exit 1 + fi + + - name: Sanity-check the derived version is well-formed semver + run: | + set -euo pipefail + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::derived version '${VERSION}' is not MAJOR.MINOR.PATCH" >&2 + exit 1 + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + - name: Log in to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # REUSE, NOT REBUILD: docker.yml already built and pushed this exact + # commit's multi-arch images (that success is why this workflow is + # running at all — see the workflow_run trigger). `imagetools create` + # retags the already-published short-SHA digest as the immutable + # version tag without touching a Dockerfile, a builder, or a single byte + # of image content — the published v3.1.0 image is byte-identical to + # the v4-latest / image docker.yml just pushed for this commit. + # This is the same primitive src/scripts/publish-image-tags.sh uses for + # the moving tags, applied here for one additional immutable tag per + # image. A build-time VERSION ldflag is intentionally NOT used to + # rebuild these images — see docker.yml's freshness-guard step, which + # already proved the pushed digest's embedded commit matches this exact + # SHA; rebuilding here would only reintroduce the risk that guard + # exists to eliminate. + - name: Tag immutable version images + run: | + set -euo pipefail + short="${SHA:0:7}" + for image in hive hive-contributor hive-hub; do + src="ghcr.io/kubestellar/${image}:${short}" + dst="ghcr.io/kubestellar/${image}:v${VERSION}" + echo "Tagging ${dst} from ${src}" + docker buildx imagetools create -t "$dst" "$src" + done + + # "A release labelled differently from what it reports is worse than no + # release" (the operator's framing). Because this workflow retags rather + # than rebuilds (see above), there is no fresh --version output to check + # against the tag the way docker.yml's freshness guard checks a commit + # hash — the binary inside ghcr.io/kubestellar/hive:v${VERSION} was + # built by an ordinary docker.yml run that never received a VERSION + # build-arg, so today it always reports the "0.0.0-dev" fallback (see + # cmd/hive/main.go and src/docs/releases.md, "What still blocks cutting + # a real release"). Asserting equality here would therefore fail every + # release until that gap closes, which is the wrong failure mode for a + # tagging step. What this DOES assert: the image actually pulls, runs, + # and answers --version at all (the tag is not silently pointing at + # nothing), and it logs the mismatch plainly rather than the workflow + # implying an equality check happened that it did not. + - name: Confirm the newly tagged image runs, and report its embedded version + run: | + set -euo pipefail + img="ghcr.io/kubestellar/hive:v${VERSION}" + docker pull "$img" + reported="$(docker run --rm --entrypoint /usr/local/bin/hive "$img" --version)" + echo "image reports: ${reported}" + if [[ "$reported" == "hive ${VERSION} "* ]]; then + echo "Embedded version matches the release tag." + else + echo "::warning::v${VERSION} is tagged but the image's --version output does not embed that string (reports: ${reported}). This is the known gap in src/docs/releases.md — the image was built by an ordinary docker.yml run with no VERSION build-arg. The GHCR TAG is still correct and immutable; only the binary's self-report is stale." + fi + + # NOT stable/candidate/edge. Channel promotion is deliberate, separate + # policy (src/docs/release-channels.md) and this workflow must never + # silently couple a version tag to it. + - name: Confirm channel tags were not touched + run: | + echo "This workflow only ever writes vX.Y.Z tags. stable/candidate/edge are owned exclusively by docker.yml's merge jobs on every push to v4, independent of whether a release was cut here." + + # SBOM: a standalone release ARTIFACT, not an in-image attestation. + # + # docker.yml sets `provenance: false` / `sbom: false` on every + # build-push-action step on purpose (#3760, guarded by + # image-attestation-guard.yml): an attestation can only be carried by an + # OCI image *index*, and that index form is what let a `COPY --from` + # layer ship an overlayfs metacopy redirect for /usr/local/bin/hive, + # which containerd/k3s and rootless podman present as non-executable + # until copy-up — every hive pulling that image crash-looped at boot. + # The published per-arch digest MUST stay a plain image manifest, so an + # SBOM here is generated as an ordinary file and uploaded to the GitHub + # Release, never attached to the GHCR image in any form. + # + # SOURCE: the already-published image digest, not the source tree. A + # source-tree scan would report what go.mod/package.json ask for; an + # image scan reports what the shipped filesystem actually contains — + # the base OS packages (`apk add`, the eleven-setuid-binary inventory + # the SUID contract already tracks), the Node/tmux layers built from + # source, and the exact resolved Go module versions — which is the more + # faithful record for a security consumer of a specific release. + # + # TOOL/FORMAT: Syft, SPDX JSON. SPDX is the format the OpenSSF Best + # Practices badge and most downstream SBOM consumers (e.g. dependency- + # track) expect natively; Syft needs no separate scan step from a + # running daemon (unlike Trivy's default vuln-first posture) and reads + # directly off a registry reference by digest. + # + # SCOPE / LIMITATION: `hive` and `hive-hub` are multi-arch + # (linux/amd64, linux/arm64); this scans the linux/amd64 manifest of + # each image only, not linux/arm64 separately. Go's cross-compiled + # binaries and the apk/npm layers in this Dockerfile carry the same + # package *versions* on both architectures — only the compiled machine + # code differs, which an SBOM does not describe — so one architecture's + # package manifest is representative of both. This is recorded here + # AND in the generated release notes rather than silently shipping one + # file and implying full multi-arch coverage. + - name: Generate per-image SBOMs (SPDX JSON, Syft, linux/amd64) + uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 + with: + image: ghcr.io/kubestellar/hive:v${{ env.VERSION }} + output-file: hive-v${{ env.VERSION }}-sbom.spdx.json + format: spdx-json + artifact-name: "" # do not also attach to a GH Actions run artifact; the release upload below is the distribution point + + - name: Generate contributor image SBOM + uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 + with: + image: ghcr.io/kubestellar/hive-contributor:v${{ env.VERSION }} + output-file: hive-contributor-v${{ env.VERSION }}-sbom.spdx.json + format: spdx-json + artifact-name: "" + + - name: Generate hub image SBOM + uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 + with: + image: ghcr.io/kubestellar/hive-hub:v${{ env.VERSION }} + output-file: hive-hub-v${{ env.VERSION }}-sbom.spdx.json + format: spdx-json + artifact-name: "" + + # NOTICE: third-party license attribution for the Go module + # dependencies compiled into these images, generated by + # src/scripts/generate-notice.sh and kept fresh on every PR by + # go-security-analysis.yml's notice-drift job (so the copy checked out + # at this exact commit is already authoritative — nothing to + # regenerate here). Attached the same way the SBOMs above are: a + # standalone file on the GitHub Release, copied under a versioned name + # for consistency with the SBOM naming convention rather than + # generated fresh, since NOTICE (unlike the SBOMs, which scan the + # published image) is a source-tree artifact CI already keeps in sync + # with go.mod/go.sum. + - name: Copy NOTICE under a versioned release-asset name + run: cp NOTICE "hive-v${VERSION}-NOTICE" + + - name: Move CHANGELOG.md Unreleased section into a dated release entry + run: | + set -euo pipefail + today="$(date -u +%Y-%m-%d)" + python3 - "$today" <<'PYEOF' + import re, sys + today = sys.argv[1] + version = __import__("os").environ["VERSION"] + path = "CHANGELOG.md" + with open(path, encoding="utf-8") as f: + text = f.read() + + marker = "## Unreleased" + start = text.index(marker) + # Section body runs from just after the heading line to the next + # "## " heading (or EOF). + body_start = text.index("\n", start) + 1 + next_heading = re.search(r"\n## ", text[body_start:]) + body_end = body_start + next_heading.start() + 1 if next_heading else len(text) + body = text[body_start:body_end] + + # Strip any release markers before filing the section under the + # dated heading — they are release-decision metadata, not part of + # the permanent record. + body = re.sub(r"[ \t]*\n?", "", body) + + new_unreleased = f"{marker}\n\n" + dated_heading = f"## {today} (v{version})\n" + replacement = new_unreleased + dated_heading + body + text = text[:start] + replacement + text[body_end:] + with open(path, "w", encoding="utf-8") as f: + f.write(text) + PYEOF + + - name: Commit the release changelog entry + run: | + set -euo pipefail + git config user.name "hive-release-bot" + git config user.email "actions@github.com" + git add CHANGELOG.md + git commit -s -m "🔖 release: v${VERSION} + + Automated release commit. Moves the CHANGELOG.md Unreleased section + into a dated v${VERSION} entry. See src/docs/releases.md." + + # v4 branch protection requires the "gate" status check (the only + # required context — see docker.yml) on the exact SHA being pushed. + # `gate` only ever attaches to a commit via docker.yml's own `push` / + # `pull_request` / `workflow_dispatch` triggers, so a commit created + # here in-workflow and pushed straight to v4 has no check on it yet and + # GitHub rejects the push outright (GH006, #5026) — it can never + # fast-forward, on the first attempt or any retry, because nothing + # about retrying produces the missing check. + # + # So: push this release commit to a scratch branch first, make + # docker.yml run on that exact SHA, and wait for `gate` to succeed + # there. + # + # #5072: the scratch push below uses this job's default GITHUB_TOKEN + # (persisted by actions/checkout). GitHub deliberately does not trigger + # *other* workflow runs from a GITHUB_TOKEN-authenticated push + # (recursive-workflow prevention), so docker.yml's `push` trigger never + # fires on the scratch branch — the original version of this step + # pushed and then waited forever for a `gate` check that could never + # exist. docker.yml does have a `workflow_dispatch` trigger that a + # GITHUB_TOKEN CAN start via the API, so this step explicitly dispatches + # it against the scratch branch right after pushing, and the resulting + # run's check-runs attach to that branch's head SHA — the exact release + # commit. (docker.yml's `gate` job carries a matching `release-gate/*` + # exception so this dispatch cannot push a GHCR image or a moving tag + # under the scratch branch name — see the EXCEPTION comment there.) + - name: Push release commit to a scratch branch and wait for gate + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + scratch="release-gate/v${VERSION}" + commit_sha="$(git rev-parse HEAD)" + + # NOTE: no cleanup trap here — unlike the pre-#5222 version of this + # step, the scratch branch must survive past this step so the next + # step can open a PR from it. It is deleted after the PR merges (or + # on any failure exit) below, not here. + + echo "Pushing release commit ${commit_sha} to scratch branch ${scratch} to earn a 'gate' check..." + git push origin "HEAD:refs/heads/${scratch}" + + echo "Dispatching docker.yml on ${scratch} (GITHUB_TOKEN pushes don't trigger it — #5072)..." + gh workflow run docker.yml --ref "${scratch}" + + echo "Waiting for docker.yml's 'gate' job to report success on ${commit_sha}..." + deadline=$((SECONDS + 600)) # 10 minutes: gate itself is a ~5s shell job; this bounds queueing/runner delay plus the dispatch's own startup lag, not gate's own runtime + conclusion="" + while [ "$SECONDS" -lt "$deadline" ]; do + conclusion="$(gh api "repos/${{ github.repository }}/commits/${commit_sha}/check-runs" \ + --jq '[.check_runs[] | select(.name == "gate")] | sort_by(.started_at) | last | .conclusion // "pending"')" + # GitHub treats success/skipped/neutral as acceptable check-run + # conclusions; only those three may be mirrored as success. + case "$conclusion" in + success|skipped|neutral) + echo "gate concluded '${conclusion}' on ${commit_sha} — eligible for the required-status mirror." + break + ;; + pending) + ;; + *) + echo "::error::gate concluded '${conclusion}' on ${commit_sha} — cannot push to v4." >&2 + exit 1 + ;; + esac + sleep 10 + done + case "$conclusion" in + success|skipped|neutral) ;; + *) + echo "::error::Timed out waiting for gate to succeed on ${commit_sha}. Check whether the workflow_dispatch run against ${scratch} actually started (Actions tab) — if it never queued, docker.yml's workflow_dispatch trigger or this job's actions:write permission may need attention." >&2 + exit 1 + ;; + esac + + # A raw push cannot use the scratch branch's check-run to satisfy v4 + # protection (#5222), so the release enters v4 through a PR. A + # workflow_dispatch check-run is not associated with a PR, however, and + # the release PR's required-context rollup therefore omits it even when + # it is green on the exact head SHA (#5356). Before opening the PR, + # mirror the gate already verified by the preceding step as a commit + # status. The status is SHA-scoped and appears in the PR rollup without + # depending on a check-suite association. + - name: Open and merge a PR from the scratch branch into v4 + id: push_v4 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + scratch="release-gate/v${VERSION}" + commit_sha="$(git rev-parse HEAD)" + merged=false + pr_number="" + + cleanup() { + # Only close the PR if it never merged (closing a merged PR is + # not meaningful and gh errors harmlessly on it). The scratch + # branch is always deleted here — it has no purpose once this + # step exits, merged or not. + if [ "$merged" != true ]; then + gh pr close "$pr_number" >/dev/null 2>&1 || true + fi + git push origin --delete "refs/heads/${scratch}" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + echo "Publishing the verified gate as a SHA-scoped commit status (#5356)..." + gh api --method POST "repos/${GITHUB_REPOSITORY}/statuses/${commit_sha}" \ + -f state=success \ + -f context=gate \ + -f description="docker.yml gate verified for automated release" + + echo "Opening a PR from ${scratch} into v4..." + if ! pr_url="$(gh pr create \ + --base v4 \ + --head "${scratch}" \ + --title "release: v${VERSION}" \ + --body "Automated release PR (#5222/#5356). Merges the release commit for v${VERSION}, carrying a successful gate check and mirrored status on ${commit_sha}." 2>&1)"; then + echo "::error::gh pr create failed: ${pr_url:-}" >&2 + exit 1 + fi + pr_number="$(grep -oE '[0-9]+$' <<<"$pr_url")" + echo "Opened PR #${pr_number}: ${pr_url}" + + # #5318/#5324 (5th recurrence): this loop called `gh pr merge`, which + # refuses to act on a PR whose aggregate `mergeStateStatus` is + # BLOCKED, and reports `the base branch policy prohibits the merge`. + # On release PR #5319 that aggregate was BLOCKED while every input + # protection actually evaluates was green: + # + # gate check-run on bb633caa success <- the ONLY required + # context on v4 + # dco commit status success + # tide commit status PENDING <- never resolves + # mergeable MERGEABLE + # mergeStateStatus BLOCKED <- caused by tide + # + # `tide` is NOT a required status context on v4 (protection lists + # `gate` and nothing else), but a pending commit status still drags + # the aggregate to BLOCKED, and Tide holds that status pending + # indefinitely for a PR it is never going to act on. So the retry + # loop waited out its whole window for a state that could not change + # — the same shape of mistake as #5222, one layer up: waiting on an + # aggregate/branch-scoped view instead of the SHA-keyed evidence. + # + # The fix is the same fix, applied to the merge call: PUT + # /pulls/{n}/merge with the head SHA. The server still enforces + # branch protection on that SHA — an unsatisfied REQUIRED context + # is rejected with 405 — so this does not bypass the gate; it just + # stops consulting a non-required status that never reports. `sha=` + # additionally makes the call fail rather than merge if the PR head + # moved out from under this run. + # + # The three failure modes of a merge attempt, and their remedies + # (carried over from #5142's push-retry logic, now applied to the + # merge call instead of a raw push): + # + # "not mergeable"/mergeable_state transients (405 while GitHub + # finishes recomputing mergeability after the gate check landed, + # or a genuinely-unsatisfied required check that is still + # settling): retried for a bounded window. + # + # non-fast-forward equivalents (base branch moved, merge + # conflict, 409 head-SHA mismatch): v4 advanced mid-flight. + # Retrying could never succeed — this release commit is parented + # on the old tip, and rebasing it forward would tag source the + # already-published images were not built from. Defer instead: + # exit green with a notice, push nothing (tag included), and let + # the successor run — triggered by the very merge that won — cut + # the release from ITS tip. The `pushed` output gates the + # GitHub-Release step below so a deferred run creates no release + # object either. + # + # anything else: hard-fail. + # RELEASE_PUSH_GH006_WINDOW is a test seam (test-release-push-retry.sh + # drives the exhaustion path with 0); production always uses 120s. + deadline=$((SECONDS + ${RELEASE_PUSH_GH006_WINDOW:-120})) + while true; do + # $GITHUB_REPOSITORY (runner-provided) rather than the + # ${{ github.repository }} expression other steps use: this step's + # script is extracted verbatim and run under plain bash by + # test-release-push-retry.sh, where an unexpanded workflow + # expression is a bash bad-substitution. Same value, and it keeps + # the step testable. + # + # Do NOT write an empty workflow-expression delimiter pair in this + # comment to illustrate the point. GitHub's workflow parser scans + # the entire `run:` string for expressions and does not honour + # shell comments, so an empty one is a hard parse error + # ("An expression was expected") that fails the WHOLE workflow at + # registration — not at runtime. That is #5339: the workflow + # silently fell back to being named by its path, its `on:` block + # was never read, and the workflow_run trigger stopped firing for + # four days while every run looked merely "failed". + if out="$(gh api -X PUT "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/merge" \ + -f merge_method=merge -f sha="${commit_sha}" 2>&1)"; then + echo "$out" + merged=true + break + fi + echo "$out" + # 409 Conflict from this endpoint means either "Head branch was + # modified" (the sha= guard fired) or "Base branch was modified" — + # both are the base-moved case, not something a retry fixes. + if grep -qiE 'base branch was modified|head branch was modified|is out of date|HTTP 409' <<<"$out"; then + echo "::warning::Release opportunity skipped for ${SHA}: v4 advanced while this run was in flight — deferring (see the RACES note, #5142/#5222). Nothing was pushed; Unreleased is untouched, so either a successor run or the hourly backstop (#5318) releases it." + echo "pushed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + # 405 Method Not Allowed is GitHub's "not mergeable" for this + # endpoint — including a required check that has not landed yet. + if grep -qiE 'HTTP 405|not mergeable|required status check' <<<"$out"; then + if [ "$SECONDS" -lt "$deadline" ]; then + echo "PR #${pr_number} not mergeable yet (required-status evaluation still settling after gate succeeded) — retrying in 8s..." + sleep 8 + continue + fi + echo "::error::PR #${pr_number} still not mergeable ${RELEASE_PUSH_GH006_WINDOW:-120}s after gate succeeded on ${commit_sha} — this is no longer a settling-time artifact. Note this merge is SHA-keyed (#5318/#5324), so a pending non-required status such as 'tide' is no longer the cause; compare v4's REQUIRED contexts with the combined status for ${commit_sha}, including the mirrored gate status (#5356)." >&2 + exit 1 + fi + echo "::error::merging PR #${pr_number} failed for an unrecognized reason (see the API response above) — not retrying." >&2 + exit 1 + done + + # The merge landed a NEW commit on v4 (a merge commit, or the same + # tree via fast-forward depending on PR mergeability), so re-derive + # the SHA actually on v4 before tagging — tagging the pre-merge + # commit_sha would tag a commit v4 may not contain verbatim. + git fetch origin v4 + v4_sha="$(git rev-parse origin/v4)" + git tag "v${VERSION}" "$v4_sha" + # Tag pushes have no protection race, only transient transport + # failures — a plain bounded retry. + for attempt in 1 2 3; do + if git push origin "refs/tags/v${VERSION}"; then + break + fi + [ "$attempt" -eq 3 ] && { echo "::error::tag push failed after 3 attempts" >&2; exit 1; } + sleep 5 + done + echo "pushed=true" >> "$GITHUB_OUTPUT" + + # --notes-file and --generate-notes have fragile combined behavior + # across gh versions, so notes are built as one file: GitHub's own + # auto-generated commit/PR summary first (fetched via `gh api + # .../generate-notes`, the same content --generate-notes would have + # produced), then the SBOM callout appended after it — rather than + # relying on the two flags to compose correctly together. + - name: Create GitHub Release + if: steps.push_v4.outputs.pushed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + notes_file="$(mktemp)" + gh api "repos/${{ github.repository }}/releases/generate-notes" \ + -f tag_name="v${VERSION}" \ + --jq .body > "$notes_file" || echo "(auto-generated notes unavailable)" > "$notes_file" + { + echo + echo "---" + echo + echo "**SBOMs:** SPDX JSON, generated by Syft against the linux/amd64 image digest, attached below for \`hive\`, \`hive-contributor\`, and \`hive-hub\`. They cover linux/amd64 only — see [releases.md](https://github.com/${{ github.repository }}/blob/v4/src/docs/releases.md#software-bill-of-materials-sbom) for why linux/arm64 is not scanned separately." + echo + echo "**NOTICE:** third-party license attribution for the Go module dependencies compiled into these images, attached below. Does not cover base-image OS packages or the Node.js/tmux layers built from source — see [releases.md](https://github.com/${{ github.repository }}/blob/v4/src/docs/releases.md#third-party-notices) for the exact boundary." + } >> "$notes_file" + gh release create "v${VERSION}" \ + --repo "${{ github.repository }}" \ + --title "v${VERSION}" \ + --notes-file "$notes_file" \ + --target v4 \ + "hive-v${VERSION}-sbom.spdx.json" \ + "hive-contributor-v${VERSION}-sbom.spdx.json" \ + "hive-hub-v${VERSION}-sbom.spdx.json" \ + "hive-v${VERSION}-NOTICE" diff --git a/.github/workflows/v2-ci.yml b/.github/workflows/v2-ci.yml index 7b1bd63f6..88e2e882b 100644 --- a/.github/workflows/v2-ci.yml +++ b/.github/workflows/v2-ci.yml @@ -90,6 +90,33 @@ jobs: - name: Entrypoint dangling key_file tests (#4368) run: bash deploy/test_entrypoint_dangling_keyfile.sh + # #5369: the `chown -R dev:node /data` in the entrypoint's root phase is + # guarded on `[ "$DATA_OWNER" != "1001" ]`, and the Dockerfile already + # does that chown at BUILD time — so the guard is false on every normal + # boot and the chown never runs. Anything the root phase creates under + # /data afterwards stays root:root and is unreadable to the uid the hive + # process drops to. #5360 was one instance; this asserts the class. + # + # The guard must SURVIVE (it prevents a multi-minute recursive chown over + # an NFS PVC), so this pins the targeted sweep that replaces it, checks + # the pre-drop readability assertion, and mechanically enforces the + # maintenance rule: it extracts the root phase and fails if any mkdir + # under /data is neither swept nor chowned at its site, so the path list + # cannot go stale the first time someone adds a write. + - name: Entrypoint /data ownership invariant (#5369) + run: bash deploy/test_entrypoint_data_ownership.sh + + # #5370: the arm64 lane probed the PUBLISHED image, so on a PR it + # validated code already on v4 rather than the change proposed — a PR + # fixing a startup bug stayed red, one introducing a startup bug went + # green. #5342 merged green that way and #5368, its fix, was red. The + # lane now builds the PR's own image and probes that. This pins the + # wiring: the build exists, it does not swallow failures, the probe is + # told the image is local, and the push path's missing-manifest failure + # (#4336) is untouched. + - name: arm64 lane probes PR code, not the published image (#5370) + run: bash deploy/test_arm64_lane_probes_pr_code.sh + # Supply-chain pins regress SILENTLY. The PI_VERSION pin (#3443) landed on # v2, and a sync/v2-into-v4-* merge resolved the conflict in favour of the # older side and restored `ARG PI_VERSION=latest` — the fix commit is an @@ -107,6 +134,17 @@ jobs: - name: Docker release-tag publication policy (#4804) run: bash scripts/test-publish-image-tags.sh + # #5142: v4.0.1 failed to publish twice in a row inside tagged-release.yml's + # final push — once on GH006 propagation lag (branch protection had not + # yet ingested the just-earned gate check), once on a non-fast-forward + # when a second merge landed mid-run. The push step now retries the + # first and defers green on the second (the successor run releases the + # newer merge; retagging here would publish images built from a + # different tree). Extracts the push step from tagged-release.yml and drives + # every branch of that state machine with a stubbed git. + - name: Release push retry/deferral state machine (#5142) + run: bash scripts/test-release-push-retry.sh + # #4206: the standalone stack's image references live in exactly one # place, src/deploy/standalone-images.sh, so the Docker Compose assets # and the Podman assets that land later cannot drift onto different @@ -376,6 +414,10 @@ jobs: working-directory: . run: bash bin/test_hive_review.sh + - name: Shared CI baseline classifier tests + working-directory: . + run: bash bin/test_hive_baseline_check.sh + # The guard that keeps the three steps above from being the last time # anyone notices: it fails when a bin/ suite exists and nothing runs it. # Each omission it catches is individually invisible, because nothing @@ -477,6 +519,18 @@ jobs: - name: Contributor identity relocation (#4408) working-directory: . run: bash src/deploy/test_contribute_move.sh + + # #5145: container mode resolves docker OR podman, but both hints printed + # from INSIDE the container hardcoded docker — a container cannot see its + # own launcher. On a podman run the recipe's host-side hint and the + # entrypoint's status line disagreed about the same container in one + # screen of output, and the one an operator pasted was the wrong one. + # This renders both hints from their shipped sources and requires them to + # be the same command; a grep for the literal would pass on a hint that + # names the variable and still renders wrongly. + - name: In-container attach hints name the real runtime (#5145) + working-directory: . + run: bash src/deploy/test_attach_hint_runtime.sh create-issue-on-failure: if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/v2' runs-on: ubuntu-latest diff --git a/.github/workflows/v2-tests.yml b/.github/workflows/v2-tests.yml index a88903a31..c5e80cd77 100644 --- a/.github/workflows/v2-tests.yml +++ b/.github/workflows/v2-tests.yml @@ -22,7 +22,20 @@ on: # so a red PR fails the check without also filing an issue. pull_request: branches: [v2, v4, v5] - paths: ['src/**', '.github/workflows/v2-tests.yml'] + # Several package tests police shipped files outside src/: contributor + # scripts under bin/, the shared shell backend config, and Justfile recipes. + # Those files must trigger the tests that guard them; otherwise a change to + # the guarded side of a parity assertion skips the assertion entirely. + # dashboard/openapi.json is the same class: its route/schema guards live in + # src/pkg/dashboard, but the published contract does not. The contract test + # in pkg/github/ci_trigger_contract_test.go pins this complete relationship. + paths: + - 'src/**' + - 'bin/**' + - 'config/**' + - 'Justfile' + - 'dashboard/openapi.json' + - '.github/workflows/v2-tests.yml' permissions: contents: read diff --git a/.gitignore b/.gitignore index a85c864ee..e36d2296e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ _inline.js # Test-run scratch: pkg/agent tests create stub CLI binaries under a # randomly-suffixed dir. Generated per run; never part of the tree. .hive-agent-stubs-*/ + +# Test-run scratch: bin/contributor-relay.test.js writes per-task status files +# into randomly-suffixed workspace dirs under here. Generated per run. +.relay-test-tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e24e52f..42066efbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,15 +11,138 @@ Hive did not historically maintain a complete changelog. This file starts a prag ## Unreleased +## 2026-09-01 (v4.0.1) + ### Added +- Google Antigravity (`agy`) is now selectable as an agent method on the dashboard instead of being launchable only through hand-edited configuration. Its model dropdown is backend-specific rather than inheriting the unrelated Copilot catalog: the first paint uses the 11 model IDs reported by `agy models` in Antigravity CLI 1.1.18 (Gemini 3.7/3.6 Flash effort variants, Gemini 3.1 Pro, Claude Sonnet/Opus 4.6, and GPT-OSS 120B), then `/api/config/backends` replaces that floor with the signed-in account's live `agy models` inventory. The probe runs the vendor CLI against the same shared `.gemini` state as hive agents, without reading or logging OAuth material; a missing binary, signed-out account, timeout, or changed/empty output remains a non-fatal, explicitly marked static fallback. The existing launcher contract is unchanged: selecting one of these model IDs still supplies both `--model` and agy's required `--effort low`. + +- `AGENTS.md` repo instructions now actually reach agent kicks ([#5227](https://github.com/kubestellar/hive/issues/5227)). Hive shipped a complete, tested `AGENTS.md` parser (`pkg/agentsmd`) and a kick call site that prepends its output, but the one function supplying that call site a repo checkout path, `Scheduler.agentsRepoRoot()`, returned `""` unconditionally — so the guard could never fire and the parser was never invoked for any repo. A test even pinned the empty return, enforcing the feature staying dead. The root is now resolved per repo from a new optional `project.checkouts_dir`: each repo is looked up at `/`, so a multi-repo hive gets the primary repo's own `AGENTS.md` and never a different repo's. `policies.local_dir` — a real checkout root Hive already reads policy files from — is used as a second source, but only when `policies.repo` names the repo being asked about, so a config repo's instructions never leak into work on an unrelated repo. **Unset is the default and preserves the previous behavior exactly:** Hive agents work over the API and keep no clones, so a hive that configures no checkout has no root, and injection stays the no-op it was. Everything stays fail-open — an absent directory, a missing `AGENTS.md`, or a blank one yields no injection and never fails a kick — and the scheduler now logs at debug which root came up empty, so a wired-but-empty repo is finally distinguishable from an unconfigured hive. Closest-wins nested `AGENTS.md` (`agentsmd.ParseNearest`) remains deferred: it needs file-level targeting, which the kick path does not have. [`src/docs/agents-md.md`](src/docs/agents-md.md) is rewritten from "not wired into kicks" to how to turn it on, and the cross-references in `README.md`, `agent-configuration.md` and `skills.md` are corrected. +- A companion page [`src/docs/forge-app-setup.md`](src/docs/forge-app-setup.md) covering the non-GitHub forges, because `github-app-setup.md` is scoped to GitHub.com and GHE while its own terminology note — echoed by `getting-started.md` and `troubleshooting.md` — told GitLab, Gitea, and Forgejo operators that the Forge App was "the equivalent host app" on their platform and pointed them at a GitHub-only guide. That framing was wrong in a way an operator could only discover after attempting an install. The new page states plainly that a hive **cannot run against GitLab, Gitea, or Forgejo today**: `src/pkg/forge` ships tested GitHub/GitLab/Gitea adapters, but nothing imports the package outside its own tests, so `project.forge: gitlab` changes only the dashboard's Platform tile. It documents why — agents reach their forge through the `gh` CLI wrapper and the `hive-open-pr`/`hive-open-issue` request-file path, not through the abstraction, and the GitHub MCP write tools are explicitly denied — plus the ceiling on the abstraction itself (no `CreateIssue`, no `CreatePR`, and `Merge` left an explicit interface TODO). It also documents the `gitlab:`/`gitea:` config blocks that genuinely parse, as **top-level** siblings of `github:` rather than nested inside it, and disambiguates `project.forge` (forge family) from `github.forge` (which GitHub instance a hive's App lives on) — two unrelated settings sharing a word, where confusing them breaks a working hive. The three misleading cross-references are corrected in place ([#5284](https://github.com/kubestellar/hive/issues/5284)). + +- Agents can now load reusable skills from the hive's skill registry. An agent declares them by name in its config (`skills: [go-error-wrapping, review-checklist]`); at each kick the scheduler loads `/data/skills/`, resolves those names via `pkg/skillreg`, and prepends the rendered block to the agent's `${KNOWLEDGE}` section. When the primary repo has a configured checkout, the same request now falls back to skills defined by that repo's inline `## Skill:` sections or adjacent `skills/` directory, completing the `ResolveRequested` bridge that previously received `nil`; a versioned registry definition still wins when both sources define the same name. The sources fail independently — an absent registry does not suppress a repo-local match, and a missing checkout does not affect registry-only operation. Resolution happens per kick rather than once at startup, so editing either source takes effect on the next kick without a restart. Every failure mode degrades the kick instead of blocking the agent: an absent source, an unknown skill name, or a malformed file is skipped and logged, and the remaining skills still inject. Injected skill bodies are capped at 8 KiB per kick so an unbounded operator-authored file cannot crowd the issue/PR queue out of the prompt; a skill that does not fit is dropped whole rather than truncated, so an agent never receives half an instruction. Only skills an agent explicitly declares are injected — a file no agent names is never sent to anyone. `AgentSpec.DefaultSkills` remains outside this runtime path because no BYO-agent launcher consumes `AgentSpec` yet ([#5228](https://github.com/kubestellar/hive/issues/5228)). + +- A `docs-link-check.yml` CI job gates every PR touching `src/docs/` on `src/scripts/check-docs-links.py`, which resolves every relative link and heading anchor in the tree against the filesystem and against GitHub's actual (non-collapsing) heading-slug rule — catching the `#5206` class of break, where a heading rename silently broke three cross-reference anchors, before merge instead of after a human happens to notice. This is deliberately *not* a new docs-site generator: the org already publishes Hive docs at [kubestellar.io/docs/hive](https://kubestellar.io/docs/hive) from [kubestellar/docs](https://github.com/kubestellar/docs) (Next.js/Netlify), which pulls a subset of `src/docs/*.md` straight from this repo's `v4` branch on every build (`kubestellar/docs:scripts/sync-hive-docs.ts`); `hive.kubestellar.io` serves the product/dashboard page, not docs. Adding a second generator (MkDocs/Docusaurus) here would duplicate that pipeline rather than serve it, so this PR instead makes `src/docs/` a correctly-linking source for the sync that already exists: ten stale cross-reference anchors were found and fixed (`architecture.md`, `security-model.md`, and `security-threat-model.md` section headings render with GitHub's real double-hyphen slug for punctuation like `&`/`—` between spaces, e.g. `#8-hub--spoke`, and several docs linked the collapsed single-hyphen form instead), plus one relative link in `security-self-assessment.md` that pointed at a path inside `src/docs/` instead of the intended repo-root workflow file. `src/docs/roadmap.md`'s "Docs site publication" row is updated to describe the live publication path instead of "still deferred" ([#5258](https://github.com/kubestellar/hive/issues/5258)). + +- Reference pages for two previously-undocumented agent-side relay scripts: [`src/docs/hive-merge.md`](src/docs/hive-merge.md) for `bin/hive-merge.sh` (merges a PR as the App bot instead of the GitHub MCP `merge_pull_request` tool, which GitHub rejects for App installation tokens — covers the `--expect-sha` auto-resolution against the F4 TOCTOU guard, the governor merge-eligible-list target-binding, and the retry/re-engagement behavior when a required check is still red) and [`src/docs/hive-open-issue.md`](src/docs/hive-open-issue.md) for `bin/hive-open-issue.sh` (creates issues, comments, and claims as the App bot instead of `gh issue create`/`gh issue comment` — covers the three request shapes, exact-title dedupe, and the exponential-backoff retry-then-quarantine contract). Both scripts previously had no page even though each is larger than `bin/hive-open-pr.sh`, which already has one; both are indexed in `src/docs/README.md` alongside it ([#5238](https://github.com/kubestellar/hive/issues/5238)). + +### Changed + +- The interactive contributor relay no longer decides a task is finished by reading the agent CLI's terminal chrome. Completion was inferred from per-backend regexes over the last fifteen lines of the tmux pane — a vendor's cosmetic output, free to change in any patch release — and thirteen separate issues ([#1566](https://github.com/kubestellar/hive/issues/1566), [#4026](https://github.com/kubestellar/hive/issues/4026), [#4064](https://github.com/kubestellar/hive/issues/4064), [#4067](https://github.com/kubestellar/hive/issues/4067), [#4078](https://github.com/kubestellar/hive/issues/4078), [#4080](https://github.com/kubestellar/hive/issues/4080), [#4128](https://github.com/kubestellar/hive/issues/4128), [#4182](https://github.com/kubestellar/hive/issues/4182), [#4265](https://github.com/kubestellar/hive/issues/4265), [#5094](https://github.com/kubestellar/hive/issues/5094), [#5121](https://github.com/kubestellar/hive/issues/5121), [#5156](https://github.com/kubestellar/hive/issues/5156), [#5162](https://github.com/kubestellar/hive/issues/5162)) are that one defect repeating, in both directions: finished work reported as still running, and mid-turn frames booked as completions. The agent now **says** it is done, extending the existing `HIVE_VERDICT:` convention with a completion verdict (`HIVE_VERDICT: complete — `) that the task prompt asks for and that the relay parses with the same anchored, prompt-echo-guarded scanner `no_work_needed` has used since [#3987](https://github.com/kubestellar/hive/issues/3987) — one parser, not two. A verdict ends the task from any pane state, so a CLI that restyles its output can no longer strand a completed task; the two API-error states are excluded, so a stale verdict above a 403 cannot launder a failed turn into a success. `classifyTmuxPane` keeps every one of its eleven backend branches and forty-odd patterns, which remain correct for the liveness question the stall backstop and the blocked/error paths ask — it simply no longer answers "is this task done". **For agents that do not emit the sentinel**, idle-looking chrome must now hold across three consecutive progress checks before it may complete a task on its own, and that path is explicitly kept away from the stall backstop, so a non-compliant agent still finishes rather than waiting out a lease and being reported as a failure. Each completion reports which signal ended it (`completion_signal: verdict | chrome_idle`), logged hub-side and normalized to a closed vocabulary, so per-backend non-compliance is measurable rather than guessed at ([#5376](https://github.com/kubestellar/hive/issues/5376), analysis in [#5353](https://github.com/kubestellar/hive/issues/5353)). + +- `pkg/forge` has its first production caller ([#5259](https://github.com/kubestellar/hive/issues/5259)). The forge abstraction shipped with tested GitHub, GitLab and Gitea/Forgejo adapters and a config key to pick one (`project.forge`), but nothing outside its own tests imported the package, so the key drove only the dashboard's Platform card. The governor's escalation sweep — the evidence comment and `needs-human` label posted when an agent-authored PR crosses the fix-attempt threshold — is now typed against a narrow `forge.IssueWriter` seam (`CreateIssueComment` + `AddLabels`) and gets its writer from `project.forge`: `github` or unset hands it the same `*github.Client` it always used, with no adapter interposed, so **a GitHub hive's behavior is unchanged**; `gitlab`/`gitea` hand it the matching adapter, built from the existing `gitlab:`/`gitea:` blocks and the token env var they name. Construction failures (Gitea named with no instance URL, an unknown forge kind) log and fall back to the GitHub client rather than dropping the write. This does **not** make a hive runnable on GitLab or Gitea: those writes are driven by `github.Client.EnumerateActionable`, which still needs a GitHub client, so on a non-GitHub hive the caller is wired but never reached — neutralizing enumeration is the remaining work, and [`src/docs/forge-app-setup.md`](src/docs/forge-app-setup.md) and the roadmap are updated to state that boundary precisely. The retype also gives `runEscalationSweep` its first coverage against a non-GitHub writer. +- Dashboard terminal controls now explain that tmux requires Shift-drag for browser text selection, with a dismissible baseline hint and a prominent, non-dismissible warning when an agent needs login and an OAuth URL must be copied. The warning also calls out wrapped-URL line breaks so operators can correct them before submitting ([#5188](https://github.com/kubestellar/hive/issues/5188)). +- `src/docs/getting-started.md`, the primary operator onboarding doc, now includes a "Where agents actually run" section ahead of L3, covering the host-tmux default, the per-backend confinement matrix from [#5024](https://github.com/kubestellar/hive/pull/5024), the `HIVE__DANGEROUSLY_RUN_UNCONFINED` escape hatches, and the hub-side `agent_sandbox` two-gate requirement — with a link to [sandbox-isolation.md](src/docs/sandbox-isolation.md) for the full picture. Previously the guide had zero mentions of sandboxing, confinement, or the security implications of running agents unconfined on a host ([#5028](https://github.com/kubestellar/hive/issues/5028)). +- `docs/backend-setup.md` now documents `opencode` in the CLI backends table (install, `opencode auth login`/credential path, provider-agnostic model config, headless-only dispatch, and its command-deny-list-only confinement posture) and `src/docs/operator-reference.md`'s configuration-blocks table now includes an `agent_sandbox` row describing the two-gate opt-in and the dashboard Security tab's silent-misconfiguration gap, cross-linked to [sandbox-isolation.md](src/docs/sandbox-isolation.md) ([#5045](https://github.com/kubestellar/hive/issues/5045), [#5047](https://github.com/kubestellar/hive/issues/5047)). +- `docs/backend-setup.md`'s CLI backends table now includes a `kilo` row ([#5040](https://github.com/kubestellar/hive/pull/5040)) covering install (`@kilocode/cli`, pinned via `KILO_CLI_VERSION`), credential env vars (`KILO_AUTH_CONTENT`/`KILO_CONFIG_CONTENT`/`KILO_API_KEY`/optional `KILO_ORG_ID`), headless-only dispatch, and its confinement posture stated accurately: kilo has no OS sandbox and no command deny-list floor in `config/backends.conf` (unlike `opencode`), and it is excluded from `just contribute-k8s`'s headless-pod allowlist pending independent verification ([#5073](https://github.com/kubestellar/hive/issues/5073)). +- Operations activity-feed entries (`picked up`, `reassigned by yank`, `completed`, `failed`) now name external (Linear/Jira) work items by their canonical source-aware identity instead of deriving a label from the GitHub issue number ([#5120](https://github.com/kubestellar/hive/issues/5120)). An external item deliberately carries `Number == 0` with its identity in `Key`/`ExternalID` ([#4245](https://github.com/kubestellar/hive/issues/4245)), so one Linear ticket was announced as `issue acme/team#0: …` on pickup and as a bare internal task id on completion — the two entries for the same item did not match each other, and every zero-numbered item in a repo rendered identically. All four sites now render `kind + canonical key + title` through one helper, falling back to the task id only for a genuinely identity-less synthetic task (a pr-review sweep). GitHub entries are byte-identical to before. +- `src/hive.yaml.example` now includes a commented `planning:` block (`plan_from_label`, off by default) documenting the `plan`/`epic` issue-label trigger, matching the neighboring `retro:`/`knowledge:` blocks' style and cross-linked to `docs/planning-intelligence.md` ([#5074](https://github.com/kubestellar/hive/issues/5074)). +- `cmd/hive` test coverage raised for previously zero-covered governor-eval-loop helpers: `writeIntentVerdicts` (nil-config/nil-actionable guards, non-agent-PR classification skipping evidence fetch, and agent-PR evidence-fetch failure producing an honest denied Tier1 verdict) and `healGitHubAppInstallation` (nil/keyless/orgless no-op guards, plus a swallowed `VerifyInstallation` error against a stubbed GitHub API). `scanForLoginRequired` gained a case proving a pattern list with some invalid regexes still reaches the per-agent scan loop instead of short-circuiting entirely ([#5161](https://github.com/kubestellar/hive/issues/5161)). +- Contributor WebSocket closes are now diagnosable ([#5090](https://github.com/kubestellar/hive/issues/5090)). Every hub-side close was a bare `conn.Close()`, which shuts the socket without sending a WebSocket Close frame, so the client observed code `1006` (abnormal closure) with an empty reason — indistinguishable from a yanked cable. Measured against a live hub, an unauthenticated probe was sent `{"type":"auth_failed","reason":"Authentication timeout"}` and then closed with `1006`/`""`, carrying none of it; the heartbeat-timeout and stale-sweep closes sent no explanation at all. The hub now sends a real Close frame with a code and reason before closing (policy violation for auth/model/role refusals, going-away for heartbeat timeout and the stale-connection sweep), and `bin/contributor-relay.sh` logs the close code and reason it receives instead of discarding both — calling out `1006` explicitly as "no close frame; the socket was cut", since that is the one code never sent on the wire and the distinction a flapping session turns on. This does not change when connections close, only what is knowable about it. + +### Fixed + +- **Claude agents no longer go dead roughly once a day and stay dead until an operator re-logs in** ([#5454](https://github.com/kubestellar/hive/issues/5454)). Hive read the short-lived Claude access token out of `.credentials.json` **once, at container start**, and injected that snapshot into every claude agent as `CLAUDE_CODE_OAUTH_TOKEN`. That variable is a static bearer override: with it set, Claude Code uses the value verbatim, never opens the credentials file, and therefore can never refresh — measured in-container with the variable set to a bad value beside a perfectly good credentials file, the CLI answered `401 OAuth access token is invalid` with no fallback. Claude access tokens live eight hours (measured mint-to-`expiresAt` on a live credential), so every claude agent was pinned to whatever token happened to be on disk when the container started; once it aged out the whole fleet 401'd, and the only thing that re-read the credential was `ReloadClaudeToken()`, called solely from the dashboard's OAuth-login handler. Restarting an agent did not help — it re-injected the same expired snapshot. That is the daily re-authentication treadmill. Measured on a live six-agent hive (2026-09-01): token expired 04:06, the 5-minute-cadence agent went orange at 04:17, the 2-hour agent at 05:12, the 4-hour agents through the morning, each on its next kick; a per-agent restart brought each back for one pane paint and then failed again on the next kick. The variable is now injected **only when the agent has no credential file it can read**, which is the job it was added for; since per-agent homes ([#4619](https://github.com/kubestellar/hive/issues/4619)) every agent's `~/.claude` symlinks to the shared `/data/home/.claude`, so the CLI reads the credential itself and redeems its refresh grant on start — the one thing the override made impossible. + + Alongside it, a new `claude.HasUsableToken` (a live access token, **or** an expired one whose refresh grant is still good) replaces `HasValidToken` at the five sites that were asking "can this credential still put an agent to work?" rather than "is this token live right now?". `HasValidToken` reports an expired token as no token at all, which made a routine daily expiry indistinguishable from a real logout: the dashboard painted the 🔑 badge, the credential watchdog logged the durable credential "unusable" and prescribed an operator device-flow login, the agent watchdog raised `Agent "…" needs re-authentication (PaneShowsLogin)`, and the **token-triggered restart heal stood down** — the heal built for exactly "login prompt on screen while the credential is valid" ([#4596](https://github.com/kubestellar/hive/issues/4596)/[#4606](https://github.com/kubestellar/hive/issues/4606)) was disabled by the most common reason its pane appears. The watchdog now reports `LoginPromptWithUsableCredential` (Authenticated=Unknown, no alert) when a restart can recover the pane. **The genuinely-logged-out path is untouched:** with no refresh grant, or one past its own expiry, every alert, badge and prescription behaves exactly as before — this can only suppress a page it can justify from evidence on disk, never invent one. + + Hive still performs no refresh of its own. A refresh rotates the grant and instantly revokes the access token every *other* live session is holding (observed verbatim as `401 OAuth access token has been revoked` on a sibling agent), which is the race [#5171](https://github.com/kubestellar/hive/pull/5171) declined to introduce; redemption stays in the CLI's own start-up path. `OAuthTokens` also gained the `refreshTokenExpiresAt` field it had been silently dropping on any rewrite — the only field that distinguishes a refreshable credential from a spent one. + +- **The entrypoint behavioural tests now actually run, and a skipped one fails the lane** ([#5380](https://github.com/kubestellar/hive/issues/5380)). `test_entrypoint_runtime_config.sh` and `test_entrypoint_data_ownership.sh` each end in the assertion that is the whole reason the file exists: create a root-owned file, then really `open()` it as the uid the hive process drops to. That is the check mode-inspection cannot make — [#5360](https://github.com/kubestellar/hive/issues/5360) shipped green behind a mode-only assertion and took four merges to diagnose. Both blocks need root **and** a `dev` account, and both suites ran only on `ubuntu-latest`, where the runner user is uid 1001 but named `runner` — so `id -u dev` failed and **both blocks skipped on every PR**. They skipped loudly rather than faking a pass, which was the right design, but nothing acted on the skip: the strongest assertions in either file had never executed in CI. They now also run inside the arm64 podman lane's container, which already builds the PR's own image ([#5370](https://github.com/kubestellar/hive/issues/5370)) and whose image has root and `dev` at uid 1001, so the assertions execute against the code under review rather than code already merged. In that lane `HIVE_TEST_REQUIRE_BEHAVIOURAL=1` makes a skip a **failure**: where root and `dev` are known to exist, a skip cannot mean "unsuitable environment", it means a precondition changed and the test quietly stopped testing — which is what kept this class of gap alive. Nothing the suites assert was weakened, and the bare-runner path is unchanged: both still skip loudly and exit 0 on an unprivileged runner or a laptop, so they stay runnable anywhere. + +- **`/data` ownership is an invariant again, so root-phase writes stay readable after the privilege drop** ([#5369](https://github.com/kubestellar/hive/issues/5369)). The entrypoint's `chown -R dev:node /data` is guarded on `[ "$DATA_OWNER" != "1001" ]`, and `src/Dockerfile` already performs that chown at **build** time — so `/data` is already uid 1001 when the container starts, the guard is false on every normal boot, and the recursive chown is never reached. That made `/data` ownership a boot-time *snapshot* rather than an invariant: anything the entrypoint's root phase created under `/data` afterwards kept `root:root`, and the hive process, which drops to uid 1001 before it reads anything, could not open it. [#5360](https://github.com/kubestellar/hive/issues/5360) was one instance of that shape (`reading config /data/hive.yaml.runtime: permission denied`); this closes the class. The guard itself is deliberate and stays — a recursive chown over an NFS-backed PVC with thousands of files costs minutes of startup — so the fix is targeted rather than a walk: a named, non-recursive list of the paths the root phase creates, swept once after that phase, plus a chown at the point of creation for `/data/home/.bashrc` and `/data/home/.profile` (both previously written by root with a `chmod` and no `chown`, and both sourced by every agent shell). Per-agent trees under `/data/agents` and `/data/beads` are deliberately excluded, since they are owned by their own `hive-` UIDs and sweeping them to `dev` would undo that isolation. Ownership only: no file or directory mode is widened anywhere, so the 0600 hardening from [#5331](https://github.com/kubestellar/hive/issues/5331)/[#5342](https://github.com/kubestellar/hive/issues/5342) is untouched. Both the sweep and the new assertion fail **open** — a chown that cannot happen (no `CAP_CHOWN`, read-only or foreign-owned PVC) warns and continues rather than aborting the boot or locking a foreign-owned file to 0600, which is the combination that *was* the bug. Finally, immediately before the privilege drop the entrypoint now verifies that the paths the process must read are readable by the uid it is about to become, and names the offending path with its owner and mode when they are not — #5360 took four merges to diagnose because the symptom was a bare `permission denied` with no path attached. + +- **The arm64 CI lane probes the pull request's own image instead of the last published one** ([#5370](https://github.com/kubestellar/hive/issues/5370)). The lane pulled `ghcr.io/kubestellar/hive:stable`, so on a pull request it exercised code already merged rather than the change under review — meaning it could not gate a fix to the very code it tests. The signal was inverted precisely when it mattered: a PR that *repaired* a startup bug ran against the still-broken published image and stayed red, while a PR that *introduced* one ran against the still-good published image and went green. That is not hypothetical — [#5342](https://github.com/kubestellar/hive/issues/5342) broke container startup and merged green, the lane then stayed red across four subsequent merges, and [#5368](https://github.com/kubestellar/hive/issues/5368), which fixed it, was red for the same reason. The lane now builds the image from its own checkout on `pull_request` and probes that; on a push it still pulls the published image, and a missing arm64 manifest is still a failure rather than a skip. Note for anyone reading the original issue: its proposed fix — probing a per-SHA image built from the PR head — **cannot** work, because `docker.yml` skips its build job entirely on `pull_request` and publishes only from long-lived branches, so a PR head SHA never has a published image to pull. The build is local to the runner, needs no registry credentials, and leaves the lane's `contents: read` permission unchanged. The `workflow_dispatch` input description is also corrected: it advertised a `v4-latest` default that nothing actually used. + +- **Reverted an incorrect release-gate skip, and identified why the tagged release still cannot merge** ([#5339](https://github.com/kubestellar/hive/issues/5339)). An earlier fix in this chain made `docker.yml`'s `gate` job skip `pull_request` events on `release-gate/*`, on the theory that the release PR's own docker run produced a competing `gate` check-run that superseded the green one earned by the dispatched run. **That theory was wrong.** The release PR is opened with the job's `GITHUB_TOKEN`, so GitHub's recursive-workflow guard creates its `pull_request` runs but never starts their jobs — those check suites contain **zero** check-runs, so they never produced a `gate` to supersede anything. Confirmed by comparing the pre-fix release PR #5355 with the post-fix #5364: both have byte-identical status rollups (`tide`, `dco`, and no `gate` at all) and both are `BLOCKED`, so the skip changed nothing about the outcome. It did remove the only code path that could ever attach a PR-associated `gate`, making it a latent trap rather than a fix, so it is reverted and `test-release-push-retry.sh` now asserts the skip stays absent. The genuine fix from the same chain — skipping the heavyweight `build`/`build-contributor`/`build-hub` jobs on `release-gate/*`, which kept the check suite `in_progress` for ~10 minutes past the merge's 120s window — is unaffected and retained. The remaining blocker is now precisely characterised: `PUT /pulls/{n}/merge` evaluates required contexts against the **pull request's** status rollup, and a `GITHUB_TOKEN`-opened PR never receives one, so `gate` is absent from that rollup even though it is present and green on the head commit itself (`GET /commits/{sha}/check-runs`). This is why the merge fails with `Required status check "gate" is expected` while every direct inspection of the commit shows the required context satisfied — the two views genuinely disagree, and only the PR-scoped one governs the merge. + +- **The tagged release's own gate-earning build no longer blocks its merge** ([#5339](https://github.com/kubestellar/hive/issues/5339), 8th recurrence). With the 7th fix in place the release run stopped manufacturing a competing `gate` check-run, and still failed at the merge step with `Required status check "gate" is expected` (HTTP 405) on every attempt for the full 120s window — with exactly one `gate` check-run on the commit, `conclusion: success`. The remaining cause is that branch protection evaluates the whole check **suite**, not just the named check-run: `docker.yml`'s `gate` job finished in ~2 seconds, but the same dispatched run's `build`/`build-contributor`/`build-hub` jobs kept that suite `in_progress` for the ~10 minutes a multi-arch image build takes, and while the suite is unfinished the required context is not treated as satisfied. The merge's bounded 120s retry window could therefore never win that race — and unlike the earlier recurrences this one is a pure timing loss, so it would have looked like an intermittent failure had the window ever been widened. Those builds publish nothing on the scratch branch anyway: `gate` already forces `push=false` for `release-gate/*` (#5072), which is why the `merge*` jobs were already skipping. They now skip too, so the suite completes as soon as `gate` does. No coverage is lost — a release commit modifies `CHANGELOG.md` and nothing else, so its tree is byte-identical to the `v4` tip whose images were built, published and freshness-checked minutes earlier, and the `gate` job that the scratch branch exists to run is untouched. + +- **The tagged release no longer supersedes its own `gate` check and become unmergeable** ([#5339](https://github.com/kubestellar/hive/issues/5339), 7th recurrence in this chain). `tagged-release.yml` earns the `gate` context branch protection requires by pushing the release commit to a `release-gate/v` scratch branch and explicitly dispatching `docker.yml` on it, then opens a PR from that branch into `v4` and merges it. Opening that PR fires `docker.yml` a second time on the **same SHA** under its `pull_request` trigger — the trigger added by [#4965](https://github.com/kubestellar/hive/issues/4965) so fork PRs can earn `gate` at all — and because the PR is opened with the job's `GITHUB_TOKEN`, GitHub's recursive-workflow guard creates the run but never starts its jobs, so it completes as `failure` with zero jobs about two seconds later. Branch protection resolves a required context to the **most recent** check-run of that name on the commit, so that instant failure superseded the green `gate` earned seconds earlier, and every merge attempt was refused with `Required status check "gate" is expected` (HTTP 405) until the 120s window expired. It could never recover, because nothing re-runs the superseding suite. Confirmed on release run `33432927303` (v4.0.1) and, previously masked behind the [#5324](https://github.com/kubestellar/hive/issues/5324) `tide` failure, on the earlier attempt for release PR #5319 — this is why the run failed at the merge step with a green `gate` visibly present on the commit, which is exactly the state the step's own error message told operators to go and check. `docker.yml`'s `gate` job now skips `pull_request` events whose head branch is `release-gate/*`, so the release PR no longer manufactures a competing check-run and the dispatched run's green `gate` stays authoritative. The other jobs already skip on `pull_request`, and the `merge*` jobs gate on `needs.gate.outputs.push`, so skipping the job cascades to skips rather than failures and no `gate` check-run is produced at all. This is a job-level condition rather than a trigger filter on purpose: `.github/release-lines.yml` pins this workflow as `unpinned: '**'`, so adding a `branches:` filter would fail the release-line guard, which inspects only `branches:`/`branches-ignore:`. The fork-PR contract is unaffected — a fork PR's head is never `release-gate/*`. + +- **Tagged Release now registers with GitHub again — the release pipeline had been fully disabled** ([#5339](https://github.com/kubestellar/hive/issues/5339)). GitHub's workflow record for `.github/workflows/release.yml` reported its *path* (`.github/workflows/release.yml`) where every other workflow reports its declared `name:`, which is the fallback GitHub uses when it holds no parsed definition for a workflow. The consequence was not cosmetic: with no parsed definition, the `on:` block was not read, so the `workflow_run` trigger stopped firing. Three docker builds completed successfully on `v4` on 2026-08-31 (`b31779e8`, `31640cc4`, `7a0faa5c`) and produced **zero** release runs, and the last `workflow_run` release run predates all of them. Every release fix in the chain — [#5286](https://github.com/kubestellar/hive/issues/5286), [#5334](https://github.com/kubestellar/hive/issues/5334) and three earlier — had therefore never executed, and `v4.0.1` had not cut since 2026-08-27. The file itself was not at fault: it parses as valid YAML, declares `name: Tagged Release` on line 1, carries no BOM, no tabs and no duplicate keys, and no revision on any branch has ever declared a `push:` trigger. The corruption was in the registration record rather than the source, so the remedy is to retire that record: the workflow is renamed to `.github/workflows/tagged-release.yml`, which GitHub registers as a fresh workflow with a correctly parsed definition. The secondary symptom the same record produced — instant, job-less, log-less `push`-event failures on every branch that touched the file, which had been misattributed in [#5324](https://github.com/kubestellar/hive/issues/5324) to a scratch branch being deleted mid-run — disappears with it. All references were updated, including the one functional dependency: `src/scripts/test-release-push-retry.sh` extracts the merge step from the workflow by path. No trigger, permission, gate-wait, tag-refusal or merge-path logic was changed. + +- **A skipped release opportunity is no longer lost silently, and the deferral chain now terminates** ([#5318](https://github.com/kubestellar/hive/issues/5318)). `precheck` defers when `v4` has advanced past the commit a run was triggered for, on the stated guarantee that "the successor is guaranteed to exist". That guarantee does not hold in two ways: a `docker.yml` run that is **cancelled** fires no `workflow_run` at all, and a multi-arch build takes ~10 minutes, so under merges arriving faster than that most commits never complete one; and a successor that *does* fire applies the same supersession test and defers again, with no terminating condition. Deferring remains correct for the individual run — its images were built from the older tree, so re-deriving from the new tip would tag content the published images do not contain, which is why the workflow's own RACES note forbids it — so the fix leaves the deferral intact and supplies what was missing: something that comes back for the abandoned work. An hourly `schedule` trigger (plus `workflow_dispatch`) now evaluates `v4`'s **current** tip, whose images `docker.yml` published long before the tick, so the backstop retags an existing digest and never tags an unbuilt commit; it is a no-op whenever `Unreleased` is empty. Because the backstop targets a tip rather than a commit handed to it by a completed build, it first requires a *successful, completed* `docker.yml` push run for that tip and stands down with a warning otherwise, so a cancelled or still-running build cannot cause a tag with no digest behind it. Both deferral paths — `precheck` and the mid-flight base/head-moved branch in the merge step — now emit `::warning::` instead of `::notice::`, so a skipped release opportunity is visible in the run list rather than scrolling past. `test-release-push-retry.sh` pins all three: the warning annotation, the presence of the `schedule` trigger, and the backstop's published-images guard. +- Agents can push again: the git credential helper is now installed system-wide in `/etc/gitconfig` instead of only in the container user's `~/.gitconfig` ([#5343](https://github.com/kubestellar/hive/issues/5343)). The entrypoint wired the helper with `git config --global`, which is per-`$HOME`, and the entrypoint runs as `dev` with `HOME=/home/dev` — while every per-agent UID runs with its own `$HOME` under `/data/home/agents/` that has no `.gitconfig` at all. There was no `/etc/gitconfig` either, so there was no system-level fallback: measured on a hosted GHE spoke, `su -s /bin/sh hive-quality -c 'git config --global --get-regexp credential; git config --system --get-regexp credential'` returned **nothing** for both scopes. The helper script itself was present, executable, and correct — it was simply never invoked. The failure was quiet in the worst way: agents did the work, committed a branch, hit the auth failure, correctly refused to manipulate git credentials, and wrote an honest summary, so the fleet view showed a healthy agent whose sessions completed while no PR was ever opened and the branch was lost. The helper is a single system-wide binary at `/usr/local/bin/git-credential-hive.sh`, so a system-wide config is where its wiring belongs; `/etc/gitconfig` is written 0644 in the entrypoint's **root** phase (`/etc` is root-owned, so the post-drop `dev` phase could not write it) and contains no secret — it names the helper path, and the helper mints the per-agent scoped token. The GHE host derivation used by both layers is factored into one function so the system and per-user layers cannot drift apart, which is what this bug was. The dev user's `~/.gitconfig` is deliberately retained: git precedence is system < global < local and both layers name the same helper for the same hosts, so it shadows nothing for agents (who have no global layer), while the contributor-relay/local-mode paths and any boot that could not become root still depend on it. The entrypoint now also asserts the property that actually matters at startup — that a process with no per-user `.gitconfig` resolves the helper — and warns loudly naming this issue if it does not, instead of reporting success for a `git config` command that ran fine and helped nobody. + +- `hive-open-pr` no longer reports an authentication failure as a missing branch ([#5343](https://github.com/kubestellar/hive/issues/5343)). Every gate on the PR-open path begins by comparing `base...head`; when the agent's branch was never pushed GitHub answers 404, and the request failed with a raw compare error that reads as "the branch doesn't exist on the remote repository" — sending an operator to investigate branch creation when the branch is missing because the **push** failed to authenticate. A 404 on compare is now diagnosed before it is reported: the repository is probed first (so a repo the App installation cannot see is named as an installation problem, not blamed on a branch), then the head ref, and a genuinely absent branch is reported as an unpushed branch together with the two credential causes that actually produce it — the helper being unreachable from the agent's UID, and an absent or unreadable per-agent scoped token — each with the command to check it. The watcher logs that case at ERROR on its own line, because it is completed work that cannot be published and is otherwise invisible in the fleet view. Diagnoses are only asserted when they can be proven: a 404 whose head ref does exist points at the base branch instead, and any non-404 failure (403, rate limit, 5xx) keeps its own identity so the existing retry and rate-limit handling still recognises it. The request stays retryable throughout — pushing the branch makes the same request valid. +- The dashboard's terminal copy control now names its case, appears only when that case is live, and hands over a URL on every browser ([#5327](https://github.com/kubestellar/hive/issues/5327)). The underlying capability shipped in [#5188](https://github.com/kubestellar/hive/issues/5188) is sound; its presentation made it unreadable. The `🔗 copy URL` button sat welded to a hint line about a different subject — `Terminal copy: ⇧-drag to select · Ctrl+Shift+C / Ctrl+Shift+V` is a sentence about selecting text with the keyboard, while the button ran a server-side pane capture — so an operator seeing it asked, reasonably, what it was for. Its tooltip carried the real answer, which is no help: nobody hovers a control they cannot interpret. It was also always visible, so on a pane with no login in flight it returned a repository URL scraped from the agent's own output, something the operator never asked for; and when they clicked it, it frequently reported `Could not copy automatically. This browser blocked the clipboard write.` That message was honest and correct, but end to end the operator spent a click and received instructions. Each piece behaved as designed and the whole read as broken. Three changes fix that. The control is now labelled **`🔑 Copy login URL`** — the case, not the mechanism — and is rendered **only when the agent's pane poller has actually seen a login prompt** (`needsLogin`, the same signal behind the 🔑 badge); with no login in flight it is absent rather than disabled, because a disabled control still asks to be understood. The endpoint gained an `authUrls` list, the subset of pane URLs that look like sign-in links, and the dashboard consumes only that — never the unfiltered list — so a control promising a login URL can only ever return one or nothing. Matching is deliberately narrow and is done on path and query alone, so a host that merely reads like an auth endpoint cannot dress an ordinary link up as a sign-in link. Finally, the click now **leads with the pre-selected URL field** instead of a clipboard write: a self-hosted hive reached over plain `http://` on a LAN address is not a secure context, so that write is blocked more often than it lands here, and showing the URL selected and ready for one Cmd/Ctrl+C works in every context. The automatic write is still attempted underneath and the panel reports its outcome (`✓ Also copied to your clipboard.`) as a bonus rather than the headline, so no click ends without the operator holding the URL. The `⇧-drag` / `Ctrl+Shift+C` advice was useful the first time and noise on every agent card thereafter; it now lives in the tooltip of a small `⌨ keyboard copy` affordance, still dismissible for good, and still reachable by click for touch and keyboard users who never get a hover. Nothing about the terminal's keyboard handling changed. The [#5188](https://github.com/kubestellar/hive/issues/5188) guarantees are all preserved: the capture is still `capture-pane -J` so a wrapped OAuth URL is rejoined whole, redaction still runs first on the entire capture with marker-bearing URLs dropped rather than offered, GitHub device-flow URLs are still excluded wholesale because their line carries the one-time code, and the fallback field still stops click propagation so the sticky toast cannot close under the operator's first click. + +- The contributor relay's 30-minute task deadline is now a **progress lease** rather than a wall-clock budget, so a long-but-live agent is no longer killed mid-work ([#5321](https://github.com/kubestellar/hive/issues/5321)). `MAX_TASK_DURATION_MS` was armed once at task start and never re-armed, so it fired against any task whose honest duration exceeded 30 minutes regardless of whether the agent was making progress — such a task was not slow, it was impossible. Observed live on 2026-08-31 it killed an agent that had already committed and pushed and was blocked on a full `go test` run, the single most predictable long pole in this repo; the hub booked the task `failed` 57 seconds before that task's own PR was opened, so a shipped, open PR was recorded as a failure and the issue was returned to the failure cooldown. Because every such timeout books a cooldown on work that was never actually attempted to completion, the ready queue starved while the cooldown list filled. Every forward-progress tick now re-arms the deadline from the same pane-output signal the stall detector already computes, so an agent producing output keeps its lease; the three sibling per-task clocks initialised alongside it were already progress-aware, and this was the odd one out. A new `ABSOLUTE_TASK_DEADLINE_MS` (4 hours, `HIVE_ABSOLUTE_TASK_DEADLINE_MS`) is the backstop nothing re-arms, for a process that prints forever without finishing, and the headless one-shot path — which has no pane to scrape and therefore no progress signal — is bounded by it directly instead of aliasing the lease. Crossing either ceiling is now reported with `failure_kind: environment`: it is a statement about this runtime, not a judgement that the agent failed its work, and the old path passed no options at all so an infrastructure ceiling was recorded as a plain task failure. The genuinely-hung case is unaffected and still caught sooner by the existing pane-stall detector (20 minutes of byte-identical output, confirmed over multiple ticks). This also aligns the relay with the hub, which has been progress-driven since [#4260](https://github.com/kubestellar/hive/issues/4260) — `leaseTTL` is re-stamped on every accepted `task_progress` and `reclaimExpiredLeases` never reclaims a task that keeps reporting — leaving the relay's blind timer as the only remaining wall-clock kill. +- A contributor reconnect no longer leaves its in-flight issue recorded as released ([#5322](https://github.com/kubestellar/hive/issues/5322)). A tunnel cut without a close frame — the 1006 flap [#5090](https://github.com/kubestellar/hive/issues/5090)/[#5310](https://github.com/kubestellar/hive/pull/5310) measured — leaves the hub's read loop parked in `ReadMessage`, so the socket's disconnect cleanup does not run when the socket dies; it runs whenever the next read finally errors. The relay meanwhile redials in about a second and re-asserts its task, which the lease-bound resume in `task_progress` legitimately adopts onto a *new* connection. `h.connections` is keyed by a random per-socket id and the hub has no notion of "this contributor's current socket", so the late cleanup then released, **by issue**, a task a live connection was demonstrably still working: it booked the [#2356](https://github.com/kubestellar/hive/issues/2356) release cooldown on an in-flight issue and wrote a `released: connection lost` row for work nobody released. The mirror-image ordering did the same damage from the other side — when the cleanup finished *first* (the common case, and the full `left`/`released`/`joined` triplet every earlier flap produced) the cooldown it booked was correct at that instant but was never withdrawn once the original relay came back and resumed, so the issue carried a "recently released" record for the rest of the window while it was being actively implemented. Both are now closed: the disconnect path skips the release when another live connection for the same identity is already holding that exact task, and a successful lease-bound resume withdraws the speculative cooldown its own disconnect booked. Both fences are narrow — the skip requires an exact {identity, task id, canonical repo, number} match, and the withdrawal refuses to touch any issue carrying a consecutive-failure count, so a real `task_failed`, a watchdog give-up, or the wedged-task backstop cannot be laundered by flapping. A genuine departure with no reconnect releases exactly as before, with the same cooldown and the same activity rows. + +- Copying out of the dashboard terminal now actually completes, and says so when it cannot ([#5188](https://github.com/kubestellar/hive/issues/5188)). The previous change explained the Shift-drag workaround; it did not give operators a copy that works. The terminal is ttyd/xterm.js attached to the agent's tmux session, and every copy affordance in it is broken for a structural reason: tmux mouse mode owns drag selection (deliberately, so the browser wheel drives tmux scrollback), and the pinned ttyd 1.7.7 — still the latest release — has no browser-side OSC52 handling, so Claude Code's own `press c to copy` and tmux copy-mode both drop their clipboard escape before the browser can see it. Shift-drag does bypass tmux, but a pane-wrapped URL copies with the wrap's newlines embedded, which silently invalidates an OAuth exchange — the exact failure that left an operator on 2026-08-30 unable to resolve a genuine `needs login` state without host access. Rather than fight the terminal, the copy is now done server-side: a new read-only `GET /api/agents/{name}/terminal-urls` captures the pane through the existing `capture-pane -J` path (`-J` rejoins wrapped lines, so a URL comes back whole — the one thing no client-side fix can reproduce) and returns the distinct URLs it contains, newest first. A `🔗 copy URL` button beside the terminal controls puts the most recent one on the clipboard in one click. Redaction runs first, on the whole capture, exactly as the full-log endpoint does, and any URL still carrying a redaction marker is dropped rather than offered — a URL is the most likely place in a pane for a credential to appear, so extracting before redacting would have made this a bypass for the one shape most worth redacting. Consequently a GitHub *device-flow* URL never appears in the list (its line carries the one-time code beside it and is blanked wholesale), which costs nothing because that flow already has its own dashboard copy control. **The "silently" half of the bug is fixed independently of the copy itself:** `navigator.clipboard` exists only in a secure context, and a self-hosted hive reached over plain `http://` on a LAN address is not one — the common case, not the edge case — so the write is gated on `isSecureContext`, falls back to a `document.execCommand` copy, and when *both* fail surfaces an error toast naming the real reason (`Clipboard access needs HTTPS or localhost…`) with the joined URL in a pre-selected input for one manual Cmd/Ctrl+C. An unreadable pane and a pane with no URL on it each get their own distinct message. No path returns without telling the operator what happened. Dismissing the baseline hint now strips only the advice text, keeping the copy button: dismissal means "I know the workaround", not "take the fix away". +- Tagged Release no longer fails with GH006 when pushing the release commit to `v4` ([#5222](https://github.com/kubestellar/hive/issues/5222), 4th recurrence). [#5128](https://github.com/kubestellar/hive/pull/5128)'s scratch-branch gate-earning dance worked exactly as designed — `gate` genuinely succeeded on the release commit's SHA before the very first push attempt — but every one of 15 retries over a 120s window was still rejected. Root cause, confirmed via the Checks API on the failing run's commit: the `gate` check-run's check suite carried `head_branch: "release-gate/v4.0.1"`, never `v4`, because that is the ref docker.yml actually ran against. A direct `git push` to a protected branch evaluates required status checks against reports tied to that push/ref event, and never counts a check earned on a different branch's push for the identical commit SHA — there was no propagation window to retry through, because that check-run was never going to satisfy a raw push to `v4` no matter how long the wait. The release job now opens a PR from the scratch branch into `v4` and merges it via the API instead of pushing the ref directly: a PR merge evaluates required status checks by SHA lookup against all check-runs for that commit (`GET /commits/{sha}/check-runs`), independent of which ref produced them — the same lookup this workflow's own wait-for-gate step already used, and the same path every ordinary contributor PR into `v4` already merges through. The merge call is still retried on a bounded window in case GitHub's `mergeable_state` computation needs a moment to settle after the gate check lands, and still defers cleanly (no failure, no release) if `v4` advances underneath it mid-flight, exactly as the prior push-retry logic did. +- Tagged Release no longer stalls on a PR that branch protection would in fact allow it to merge ([#5324](https://github.com/kubestellar/hive/issues/5324), [#5318](https://github.com/kubestellar/hive/issues/5318) — 5th recurrence). [#5222](https://github.com/kubestellar/hive/issues/5222)'s PR-merge path was the right mechanism but reached it through `gh pr merge`, which refuses to act on any PR whose **aggregate** `mergeStateStatus` is `BLOCKED` and reports `the base branch policy prohibits the merge`. On release PR #5319 every input protection actually evaluates was green — `gate` (the only required context on `v4`) concluded `success` on the release commit, `dco` was `success`, and the PR was `MERGEABLE` — but the `tide` commit status sat `PENDING`, which is enough on its own to drag the aggregate to `BLOCKED`. Tide holds that status pending indefinitely for a PR it is never going to act on, and `tide` is **not** a required status context on `v4`, so the retry loop spent its whole 120s window waiting out a state that could not change, then hard-failed. This is the same shape of mistake as #5222 one layer up: trusting an aggregate or branch-scoped view instead of the SHA-keyed evidence the workflow already gathers. The merge is now performed by `PUT /repos/{owner}/{repo}/pulls/{n}/merge` with the release commit's SHA, which GitHub evaluates server-side against `v4`'s actual required contexts for that commit — branch protection is still fully enforced (an unsatisfied required check still returns 405 and is still retried on the bounded window), it simply stops consulting a status the repository does not require. Passing `sha=` additionally makes the call fail rather than merge the wrong tree if the PR head moves mid-flight; that 409, like the pre-existing base-branch-moved case, defers green with `pushed=false` rather than failing, so no tag and no GitHub Release are produced for a superseded run. An unrecognized merge failure now hard-fails immediately instead of being retried as if it were a settling artifact. Note that #5324 diagnosed this as `tide` being a *required* context needing admin removal from the branch policy; it is not one — protection on `v4` lists `gate` and nothing else — so the fix is in the workflow, not in repository settings. +- The snapshot pipeline's success paths are now covered by unit tests ([#5235](https://github.com/kubestellar/hive/issues/5235)). `handleSnapshotPage` (11.8% coverage) and `buildSnapshot` (15.8%) hardcoded `/data/snapshots/...`, `/opt/hive/proxy/public/index.html`, and `/opt/hive/dashboard/build-snapshot.mjs`, and shelled out to `node`, so the stale-threshold rebuild decision, dark/light mode selection, the `/live/hive` → `/snapshot` URL-rewrite pipeline, and CSP hash stamping on the served bytes could only ever run live. `Server` gained a `snapshotDir` field (empty = production `/data/snapshots`, same convention as the existing `acmmLinearBaseURL` test override) and a `buildSnapshotFn` hook (nil in production, same nil-in-production-`var` seam convention as `pkg/hub`'s `afterGenerationsReadAttempt` from [#5080](https://github.com/kubestellar/hive/issues/5080)) that tests set to a fake builder writing a fixture file instead of spawning `node`. No production behavior changed — same paths, same rebuild decision, same rewrite pipeline; `buildSnapshot` and `snapshotDirOrDefault` fall through to the exact prior logic (now named `buildSnapshotProd`) whenever the seams are unset. `handleSnapshotPage` coverage went from 17.8% to 100%; `buildSnapshot`'s dispatcher wrapper went from 25.0% to 100% (the real Node-invocation body it wraps, `buildSnapshotProd`, is deliberately left uncovered by design — same as the production body behind `afterGenerationsReadAttempt` — since exercising it would mean actually spawning `node`). + +- Sandboxed agent PRs no longer fail a target repo's formatter gate (`gofmt -l .`, `cargo fmt --check`, `just check` → `fmt-check`/rustfmt, `prettier --check`) on a trailing blank line left at end-of-file ([#5116](https://github.com/kubestellar/hive/issues/5116), reported by @hanthor). Five agent-authored PRs across four languages and repos (`tuna-os/corral#238`, `tuna-os/remora#40`, `tuna-os/remora#38`, `tuna-os/finupdate#80`, `tuna-os/bootc-migrate#209`) failed CI for exactly this reason, and the failure is nearly invisible in CI output — one instance surfaced only as a bare `exit code 1`. Hive has no file writer of its own on this path: the coding CLI running inside the sandbox (Claude Code, Codex, Copilot, etc.) writes the target repo's files with its own tools, and hive only sees the result via `git diff`/`git push` afterward, so the defect could not be fixed at a hive-owned write call — there isn't one. `pushbroker.Broker.Run`, the one point hive already controls before a diff leaves the sandbox, now normalises each changed, still-present, non-binary file to end in exactly one trailing newline, `git add`s and `--amend`s the commit if anything changed, and only then continues into the existing secret/protected-path checks. The scope is deliberately narrow: a file with no trailing newline at all is left untouched (a different, less universally-enforced style question), a file already ending in exactly one newline is untouched and does not trigger a needless amend, and binary files (detected via the same NUL-byte-in-a-leading-sample heuristic git itself uses) are never opened as text. `pkg/pushbroker` carries a 99% coverage floor as the last hive-controlled checkpoint before a diff leaves the sandbox; tests cover the strip behavior plus every no-op and error path deliberately (an untouched no-trailing-newline file, an untouched binary file, an untouched already-correct file with no needless amend, the amend path's commit-actually-moved assertion, an all-newline file's single-newline fallback, and the read/write/git-add/git-amend/HEAD-reread failure paths) — the package is at 100% statement coverage. +- Advisory findings that record no provenance can no longer keep themselves alive forever by cached replay ([#5236](https://github.com/kubestellar/hive/issues/5236)). [#5148](https://github.com/kubestellar/hive/issues/5148) stopped identical explicit-`provenance_sha` re-reports from refreshing a bead's staleness clock, but deliberately left the no-provenance fallback unchanged: every re-report still counted as confirmation, so a disproved finding whose producer never reported a commit survived every prune window — observed live as an `atomic-image-builder` shell-coverage finding still marked `reported 3×` after the implementation refuting it landed, spawning duplicate downstream scanner issues while the same digest carried the issue disproving it. `PersistAsBeads` now stores a hash of each finding's text (title, detail, file reference) in bead metadata and treats a byte-identical no-provenance re-report the way it treats an identical-provenance one: the `last_seen_at` refresh is skipped and the staleness clock keeps running, so `staleness_days` retires the finding on the normal schedule. The boundary is deliberately narrow — a report whose producer changed *anything* in the text still refreshes as before, re-verification under a newer explicit `provenance_sha` refreshes even byte-identical text, a genuinely new no-provenance finding still creates its bead normally, and beads written before the hash existed take one refresh (which stamps the hash) before replays are recognised. Skipped replays are counted and the digest captions such findings `⚠️ re-reported N× from cached evidence, not re-verified`, so repetition no longer reads as verification. Honest trade-off: a still-live condition whose finding text is fully deterministic and whose producer reports no provenance will now age out and re-open as a fresh bead after pruning — noisier than before, but the replay loop it replaces kept disproved findings live indefinitely. + +- The bobshell (IBM `bob`) install layers in `src/Dockerfile` and `src/Dockerfile.contributor` no longer hard-fail the whole image build when IBM Cloud Object Storage (`s3.us-south.cloud-object-storage.appdomain.cloud`) is unreachable ([#5203](https://github.com/kubestellar/hive/issues/5203)). The retry hardening added for [#4941](https://github.com/kubestellar/hive/issues/4941) (per-attempt timeouts, 8 retries) recurred anyway: on 2026-08-30 all 9 attempts hit `Connection timed out after 15002 ms` across ~175s, proving the endpoint was down for the whole window, not merely flaking — no retry ladder can ride out a multi-minute outage. Both layers now tolerate a failed *download* the same way the Goose/pi/copilot/codex layers already do, printing `WARN: Bob CLI download failed — skipping (backend: bob will be unavailable)` and continuing the build; a failed *checksum verification* against the pinned SHA-256 still hard-fails exactly as before — the `||`-style tolerance only ever covers the `curl` step. This is safe because bob's absence was never silent to begin with once you get past image-build time: `pkg/agent/manager.go`'s `launchInTmux` resolves every backend's binary via `exec.LookPath` before any backend-specific logic runs, and on a miss marks the agent `StateFailed`, records `LastError`, logs a warning, writes a banner into the agent's own tmux pane, and emits an `AuditAgentStartFailed` event; the contributor container's `detect_cli` in `bin/contributor-agent.sh` similarly gates on `command -v` first and returns `NOT_INSTALLED`, which `bin/contributor-agent.sh` turns into `ERROR: bob CLI not found. Install it and try again.` and a non-zero exit rather than a container that claims readiness it doesn't have — the "backend validates but silently never launches" trap [#5048](https://github.com/kubestellar/hive/issues/5048) describes for a different backend (`agy`) is not reproduced here. +- **errcheck ratchet, step 3 of 5 ([#4903](https://github.com/kubestellar/hive/issues/4903)): all 108 production findings in `pkg/proxy` fixed.** `pkg/proxy` is the GitHub/Linear/inference MITM policy proxy, so every finding was judged individually rather than blanket-silenced. All 108 turned out to be genuinely ignorable best-effort I/O on connections whose outcome was already decided before the unchecked call: paired `SetReadDeadline`/`SetWriteDeadline`/`SetDeadline` calls that bound relay phases (a failure there just means the connection is already dead, which the next read/write surfaces), deferred `Close()` on read-only response/request bodies and TCP/TLS connections, `io.Copy` in the bidirectional tunnel relay (`relayTunnel`/`transfer`), and `resp.Write`/`fmt.Fprintf`/`w.Write` writes of already-decided responses (a 403 block page, a synthesized local inference reply, a relayed upstream response) back to a client that may have disconnected. None of these sit on the (method, path) → ACMM-mode decision, the repo allowlist, the canary egress scan, or credential minting/forwarding path itself — those results are always `if`-checked and were untouched. `errcheck` remains out of `src/.golangci.yml` until the `pkg/hub` step lands — this PR fixes findings only. +- **errcheck ratchet, step 4a of 5 ([#4903](https://github.com/kubestellar/hive/issues/4903)): all 108 findings in `pkg/hub/saas.go` fixed.** This is the largest single-file concentration in the ratchet and the file behind hive creation, access grants/revocation, and hub auto-upgrade. Several ignored errors were real bugs, not lint: `handleGrantAccess`, `handleRevokeAccess`, `handleApproveRequest`, and `handleApproveAccess` all called `saveSaaSUser` without checking the result, so a failed persist left the in-memory role change reported to the caller as success while the on-disk record silently kept the old role — the four handlers now return 500 and log instead of claiming an access change that never landed. `handleCreateHive`'s owner-grant save and `handleMyHives`' backfill save had the same gap, now logged. `handleHubAutoUpgrade`'s toggle write is no longer dropped either; a failed persist now reports 500 instead of telling the operator their preference was saved when it wasn't (a companion test, `TestHandleHubAutoUpgradeValid`/`TestHandleHubAutoUpgradeEnable`, was passing only because the write failure was previously invisible — both now use the existing `helperSetupTempDirs` fixture so the write actually succeeds in CI). `handleOAuthCallback`'s post-login save (login count + encrypted token) is likewise now logged on failure. Every other finding was genuinely ignorable — a best-effort response write, an HTTP response body close, a mkdir whose failure surfaces through the write it guards — and is now an explicit `_ = ` with the reasoning inline. +- **errcheck ratchet, step 4b of 5 ([#4903](https://github.com/kubestellar/hive/issues/4903)): the remaining 116 `pkg/hub` findings fixed, closing the package out.** `provisionHive`'s spoke manifest — which briefly holds a plaintext GitHub App token before `kubectl apply` and immediate deletion — previously closed its file handle without checking the error; a failed `Close()` can mean buffered bytes never reached disk, so `kubectl` could have applied a truncated manifest carrying a partial secret. It now checks the close, and on any write/close failure removes the manifest and refuses to apply rather than risk a broken or partial credential landing on a cluster. `startProvisionWatcher`'s hosted-hive provisioning goroutine had the same gap on its status saves (`timed out`, `running`) — a failed persist now logs instead of silently leaving a hive's on-disk status stuck behind its actual state. Everything else — atomic key-store cleanup paths in `wrapkey_store.go` and `cluster_app_key.go` (an already-failed chmod/write/rename's error is what's returned; the paired `Close()`/`Remove()` is best-effort cleanup, not the primary error), read-only HTTP response body closes across `heartbeat.go`/`oauth.go`/`slack.go`/`oci_fss.go` and others, and best-effort response writes — is genuinely ignorable and is now explicit. + +- The advisory digest no longer republishes a finding under a freshness stamp it never earned ([#5130](https://github.com/kubestellar/hive/issues/5130)). Findings persist as open beads and are re-rendered verbatim every cycle, while the footer stamps the whole digest `Analyzed at owner/repo@` — so a finding whose evidence was computed several commits ago was published as though it had been checked at current HEAD. The only freshness check that existed, `VerifyFindingPaths`, tests **path existence** and nothing else, which both of the reported findings passed. On a live digest this let a `contrib/aib` coverage finding outlive its own fix by 18 hours across five regeneration cycles, and a re-verifying agent that ran the finding's cited `grep` against the stamped commit concluded the evidence had been *fabricated* — filing a false accusation against evidence that was merely stale, and costing a maintainer the time to adjudicate it. The digest now tracks the commit each finding's evidence came from and captions any finding not computed at the analyzed commit (`⚠️ evidence computed at \`c9546a8\`, not re-verified at the analyzed commit`); the footer says so too, instead of implying every finding was checked at HEAD. Provenance is read from an explicit `provenance_sha` (advisory JSONL or bead metadata), falling back to the commit a finding already names in its own prose (`revision `, `commit `, `computed at `) — which is what makes the fix apply to findings already in flight, with no agent change. A bare hex run with no such keyword is never read as a commit, and a finding naming no provenance is left unmarked, since silence is not a freshness claim in either direction. Re-running each finding's own evidence is deliberately **not** attempted: that evidence is arbitrary (a `grep`, a workflow-file read, a coverage run), so the digest stops asserting a freshness it never checked rather than claiming one it cannot establish. Provenance also closes the keep-alive loop that defeated staleness pruning: agents re-report from **cached prior findings**, not from re-verification, and `PersistAsBeads` read every re-report as "condition still holds" and refreshed `last_seen_at`, so a fixed finding survived every prune window. A re-report carrying the same `provenance_sha` the bead already records no longer refreshes the stamp, so `staleness_days` retires it on the normal schedule. Two limits keep that from retiring live findings: only an **explicit** `provenance_sha` gates the refresh (never the prose-inferred one — misreading "regressed in commit ``" would age out a real finding), and a finding recording no provenance behaves exactly as before, so no hive starts ageing findings out merely because its agents do not report a commit. + +- Finished Claude contributor tasks no longer remain `working` until the pane-stall backstop records a false environment failure when a background shell is still running ([#5162](https://github.com/kubestellar/hive/issues/5162)). Claude replaces the optional `(shift+tab to cycle)` footer hint with `1 shell · ← for agents · ↓ to manage` in that state, so the relay's old hint-dependent idle check could not reach `IDLE_COMPLETE`; it consequently skipped PR detection, renewed the task lease indefinitely, then relaunched the CLI and failed already-shipped work after 20 minutes. The classifier now treats Claude's `esc to interrupt` footer marker as explicitly busy and its persistent `⏵⏵` / `← for agents` chrome as idle when that marker is absent, while retaining the conservative activity-verb fallback for unrecognised footer variants. A background shell is therefore correctly treated as orthogonal to whether the agent's turn has ended. +- The "attach to the agent" commands printed from inside a contributor container named the wrong container runtime ([#5145](https://github.com/kubestellar/hive/issues/5145)). `just contribute-hive` resolves docker **or** podman, but a container cannot see its own launcher, so both in-container hints hardcoded `docker`. Observed live on a podman launch, in one screen of output: the recipe's own host-side hint said `podman exec -it hive-contributor-agy-… tmux attach -t contributor` and four lines later the entrypoint's status block said `docker exec -it hive-contributor-agy-… tmux attach -t contributor` — two contradictory instructions for the same container, and pasting the second one gets `permission denied … /var/run/docker.sock` (or `no such container`, if docker happens to be running too). The worse of the two sites is the relay's **needs-authentication banner**, which fires exactly when a human *must* attach to complete a login: a paste-able command that fails there reads as the whole thing being broken. The recipe now passes the runtime it resolved as `HIVE_CONTAINER_RUNTIME` alongside the `HIVE_CONTAINER_NAME` it already passed, and both hints render it, defaulting to `docker` so a bare-docker launch — or an image older than this change — prints exactly what it printed before. A third site is fixed in passing: the relay's banner also runs in **host mode**, where there is no container at all, and it was telling local contributors to `docker exec` into `hive-contributor`; `HIVE_CONTAINER_NAME` is set only by the container arm of the recipe, so its absence now selects a plain `tmux attach -t ` — the same command the recipe itself prints four lines earlier. Kubernetes pods are unaffected: they run `CONTRIBUTOR_MODE=headless`, and both hints are interactive-only. Display strings only; nothing functional changes. +- The watchdog no longer raises fleet-wide `needs re-authentication` alerts when Claude's short-lived stored access token expires while its refresh token remains valid ([#5165](https://github.com/kubestellar/hive/issues/5165)). The rotation usage probe previously ignored `expiresAt`/`refreshToken`, sent the stale token raw to Anthropic, and returned `claude usage HTTP 401`; the watchdog then reinterpreted that deliberately fail-open measurement error as a definitive auth failure, putting a healthy idle hive into Degraded state until the next agent turn refreshed the token. The probe now recognizes an expired-but-refreshable credential before making the stale request and reports the usage check as inconclusive, allowing Claude Code to refresh normally on its next call. An expired access token without a refresh token still produces the high-confidence login-expired verdict and re-authentication alert. +- Agents no longer treat a repository-wide red CI baseline as an independent failure on every affected pull request ([#5110](https://github.com/kubestellar/hive/issues/5110)). Fix-oriented scanner, CI-maintainer, and quality policies now require baseline triage before retry, repair, or escalation: the new `hive-baseline-check.sh` compares the exact check against the repository's actual default branch and open sibling PRs, returns distinct shared/isolated/unknown statuses, and fails closed when GitHub evidence is unavailable. A check red on the default branch or at least three sibling PRs becomes one stable `[shared-ci]` incident that agents reuse and link once, deferring affected PRs instead of posting the same escalation every kick. The helper is installed in both container and native deployments and has mocked regression coverage for base failures, reruns, exact-name matching, pending checks, thresholds, and API failure. +- Guide agents now verify copy-pasteable shell commands before publishing them ([#5113](https://github.com/kubestellar/hive/issues/5113)). Every guide policy mode now requires packages, app IDs, images, versions, and remote artifacts to be resolved against an authoritative registry or vendor source; commands to be exercised end to end when practical (or checked against current authoritative documentation with limitations disclosed); and required repositories, toolkits, authentication, hardware, services, or generated configuration to appear before dependent commands. Agents must record their verification evidence and omit commands they cannot verify instead of presenting plausible names or incomplete first steps as working instructions. +- `TestGridGolden` (`pkg/tui/panes/grid_golden_test.go`) was flaky under CI scheduling jitter ([#5102](https://github.com/kubestellar/hive/issues/5102)), and the flake was PRODUCT-adjacent, not the test's own timing: bubbletea renders a model's pristine `View()` once, synchronously, before its event loop starts draining messages (`(*tea.Program).Run`), and `pkg/tui`'s root model falls back to a bare splash line (`"Hive TUI (q to quit)"`) until it has been sized — so that first render is always the splash, regardless of when the test sends `q`. The renderer flushes on its own independent ~16ms ticker; if that ticker fires before `Update` has processed the `WindowSizeMsg` `teatest.NewTestModel` already queued, the splash gets flushed to the captured output stream as its own frame before the sized grid frame overwrites it in place, and the byte count no longer matches `testdata/grid.golden` even though the model's *final* state is always sized correctly (confirmed live: the issue's own evidence showed the identical `v4` SHA passing and failing across different CI runs). Reproduced deterministically by injecting a delay into `Update`'s `WindowSizeMsg` case (10/10, then 30/30, then 100/100 failures with the delay in place, 0/150 without). Because the race is in scheduling between bubbletea's internal renderer and event-loop goroutines — outside the test's control — waiting longer before sending `q` cannot fix it: an already-flushed transient frame's bytes are permanently in the stream by the time any wait condition resolves. The fix normalizes the captured output instead of trying to dodge the race, stripping the transient splash-frame bytes (identified by their fixed, known content) before comparing to the golden — an extra splash flush is a scheduling artifact of the very first render, never a layout change, since a real layout change would alter the *sized* frame the golden actually pins. Verified passing 100/100 with the same injected delay in place (previously 0/30), and clean at `-count=200` / `-race -count=30` with no injection. +- `TestF20_ReadIsRetriedBeforeFailingClosed` (`pkg/hub/hub_generations_store_test.go`) raced two independent wall-clock timers ([#5080](https://github.com/kubestellar/hive/issues/5080)): the test's own goroutine slept `generationsReadRetryDelay*1.5` (150ms) before restoring the generations file's permissions, while `readGenerationsFile`'s three read attempts land at ~0/100/200ms — a ~50ms margin that a loaded scheduler can consume, so the restore goroutine's `time.Sleep` overshoots, the final read still sees `EACCES`, and the loader correctly (but flakily) fails closed. Test-side, not product-side: `readGenerationsFile`'s retry-and-fail-closed behavior is exactly what F20 requires. Reproduced deterministically by widening the restore delay by 60ms past its margin (10/10 failures on the original code); fixed by adding a test-only seam, `afterGenerationsReadAttempt` (nil in production), that `readGenerationsFile` calls after each attempt completes, and having the test schedule the permission restore the instant attempt 0 is observed to have failed rather than guessing when that will be. Verified passing 15/15 with the same injected overshoot (plus an additional 80ms delay inside the now-triggered restore goroutine, since the fix removes the *scheduling* race rather than merely re-tuning the margin), and clean at `-count=100` with no injection. +- **Tagged Release's scratch-branch `gate` check now actually triggers** ([#5072](https://github.com/kubestellar/hive/issues/5072)). [#5051](https://github.com/kubestellar/hive/pull/5051) made `release.yml` push its release commit to a throwaway `release-gate/v` branch first to earn the `gate` status check `v4` branch protection requires, before pushing the same commit to `v4`. That push used the job's default `GITHUB_TOKEN`, and GitHub deliberately does not trigger *other* workflows' `push` events from a `GITHUB_TOKEN`-authenticated push (recursive-workflow prevention) — so `docker.yml` never ran on the scratch branch, no `gate` check ever attached to the release commit, and every release run timed out after 10 minutes waiting for a check that could never arrive, permanently blocking tagged releases exactly like #5026 did. `release.yml` now explicitly dispatches `docker.yml` (`gh workflow run docker.yml --ref release-gate/v`) right after the scratch push, using `docker.yml`'s existing `workflow_dispatch` trigger, which a `GITHUB_TOKEN` *can* start via the API; the release job's `permissions` gained `actions: write` for this call. `workflow_dispatch` on `docker.yml` normally forces a GHCR push regardless of branch (so a throwaway branch can be published for a hive on demand) — `docker.yml`'s `gate` job now carries a `release-gate/*` exception that forces `push=false` for that branch pattern on every trigger, so this dispatch cannot push a real image or moving tag under the scratch branch name; it only ever runs the few-second `gate` job the wait loop needs. Branch protection is unchanged: no bypass, no weakened check, no `enforce_admins` change. +- **Tagged Release no longer dies on the two push races it can lose** ([#5142](https://github.com/kubestellar/hive/issues/5142)). v4.0.1 failed to publish twice in a row at the final `git push` to v4: once on `GH006` because branch protection had not yet ingested the gate check the run had just earned (propagation lag — the check *was* successful), and once on a non-fast-forward because a second PR merged while the run was in flight. `release.yml` now retries `GH006` for up to two minutes, and treats a lost non-fast-forward race as a green *deferral* — the successor run triggered by the winning merge cuts the release, which is the only correct outcome, because the run's images were built from the pre-merge tree and force-completing would tag content the images don't contain. A new `precheck` job also defers immediately when v4 has already moved past the run's commit, skipping the pointless gate dance. The push state machine is exercised in CI by `src/scripts/test-release-push-retry.sh`, which extracts the step from the workflow and drives every branch with a stubbed `git`. +- `just contribute-hive`'s startup banner and `just contribute-status` both reported and queried the wrong hub for every contributor whose hub comes from `~/.config/hive/contributor.env` — i.e. everyone on a hosted spoke ([#5092](https://github.com/kubestellar/hive/issues/5092)). `hive_hub := env("HIVE_HUB", ...)` is resolved by `just` at parse time from the environment `just` itself was started with, but `HIVE_HUB` only arrives when a recipe sources `contributor.env` in its body, so every `{{hive_hub}}` interpolation was already frozen to the built-in default. The banner therefore printed `wss://hive.kubestellar.io/contribute` while the relay connected somewhere else entirely, and `contribute-status` derived `HUB_HTTP` from the same frozen value before sourcing the config file — then asked that default hub for a `CONTRIBUTOR_ID` that only exists on the configured one, which is a 404, so "Could not fetch profile" was the guaranteed result. Both now resolve `${HIVE_HUB:-{{hive_hub}}}` after sourcing, keeping the built-in value as a fallback for an operator with no config file, matching the `${_HUB:-{{hive_hub}}}` shape `contribute-setup` already used. `contribute-status` additionally walks the full comma-separated, position-aligned `HIVE_HUB`/`CONTRIBUTOR_ID` lists instead of reporting only the first hub. `contribute-browse` (the public registry) and `hive-api`/`hive-api-docs` (whose documented interface is an explicitly exported `HIVE_HUB`) are deliberately unchanged. +- Closed an ioscan canary exfiltration bypass ([#4960](https://github.com/kubestellar/hive/issues/4960)): `CreateIssue` and `CreatePR` refused a write whose body carried a registered canary token when `canaryFailClosed` was set, but `CreateIssueComment` — which the issue-request watcher dispatches the same agent-supplied body to for `kind: "comment"` — only ran `logscrub.ScrubString`, which does not match canary tokens by design. A prompt-injected agent blocked from exfiltrating via a new issue could post the identical body as a comment instead. `CreateIssueComment` now calls the same `scanCanaryText` check and honors `canaryFailClosed` exactly like `CreateIssue`/`CreatePR`. Two further agent-writable text paths with the same gap are also closed: `PostAdvisoryDigest` (the advisory digest aggregates agent-sourced finding text) and the review-request watcher's `PullRequests.CreateReview` body (an agent-supplied PR review, previously unscanned and unscrubbed). +- The Operations tab's ready-work queue listed tracker/umbrella issues that `selectTask` will never hand out ([#5091](https://github.com/kubestellar/hive/issues/5091)). A tracker is coordination-only — its children carry the work and are queued independently — so the assignment path has refused to offer one since [#4188](https://github.com/kubestellar/hive/issues/4188). `ReadyQueue` is the read-only projection of exactly that offerable set and applied every other exclusion (hold, cooldown, no-work verdict, failure cooldown, in-flight, convergence admission, the title/author/label filters, skip-assigned) but never read the enumerator's `is_tracker` flag, so an umbrella sat in the queue looking like offerable work that nobody could be assigned; on a live hub `#4907` occupied a slot permanently. The queue now applies the same gate, placed after the operator HOLD check so a held tracker still renders as held. +- The agent sandbox's two-gate opt-in (`agent_sandbox.enabled` globally AND `sandbox.enabled: true` per agent) could be misconfigured silently through the dashboard itself: the Security tab's toggle writes only the global flag, so an operator could turn "agent sandbox" on, be told the setting was updated, and have every agent keep running unconfined with no error anywhere in the UI. `config.AgentSandboxGateWarnings` already computed this exact diagnosis and logged it at WARN on boot/config-reload, but nothing surfaced it where the operator was actually looking. `GET /api/config/governor`'s `security` section now carries a `sandboxWarnings` array (empty when the config needs no comment — the documented default and a fully opted-in hive both stay silent), and the Security tab renders it both in the existing non-blocking "Coherence warnings" box and as an inline warning directly under the sandbox toggle itself, naming exactly which agents are still unconfined and the config key that fixes it ([#4918](https://github.com/kubestellar/hive/issues/4918)). +- A transient Claude API error is no longer reported to the hub as a **completed** task ([#5094](https://github.com/kubestellar/hive/issues/5094)). Claude Code prints a turn-duration summary (`✻ Cogitated for 9m 24s`) whenever a turn *ends* — including when it ends in an error — and the contributor relay's claude pane classifier matched exactly that line as its completion marker, so an errored turn was indistinguishable from a finished one. Observed live: issue `#5061` was picked up at 11:46:38 and booked "completed" at 11:57:40 with no PR, its half-written work still uncommitted, and the contributor was immediately reassigned. `bin/contributor-relay.sh` now classifies a turn that ended in a *retryable* API failure (dropped connection, timeout, 5xx, overloaded — mirroring `transientAPIErrorPatterns` from the hub-side #4697 nudge) as a new `TRANSIENT_API_ERROR` state and retries it in place with a bounded `try again` (3 per task, 90s apart), preserving the session context that makes recovery cheap. Authorization (403) and quota failures are classified separately and handed back at once as an `environment` failure — never retried, since repeating those loops the agent against a wall ([#4400](https://github.com/kubestellar/hive/issues/4400), [#4583](https://github.com/kubestellar/hive/issues/4583)), and never reported complete either, which is the same defect one branch over. A mid-session credential expiry (`API Error: 401` / "Please run /login") is reported as `blocked_on_human` with an attention flag — a human logging in is the only recovery, so the task is neither completed, failed, nor retried; a login hint alongside a 403 stays fatal, since `/login` fixes authentication and fixes nothing about authorization ([#4400](https://github.com/kubestellar/hive/issues/4400)). Both error detectors require the CLI's `API Error:` chrome on the matching line, so an agent whose own completed-turn summary quotes a quota phrase or error string is not misclassified. When a human is attached to the tmux pane the relay types nothing and reports `blocked_on_human` with `attention` instead, matching the hub-side rule that a watchdog never types over a person. An exhausted retry budget hands the task back as an `environment` failure — never as a completion. +- An API error matching neither of the contributor relay's curated lists no longer reads as a completed task ([#5121](https://github.com/kubestellar/hive/issues/5121)). [#5094](https://github.com/kubestellar/hive/issues/5094) closed the retryable and known-unretryable buckets; anything outside both — a 400, a 404, a novel gateway phrasing — still fell through to the completion test and booked a completion for a turn that shipped nothing. Unrecognised errors are now detected by the CLI's own rendering (a line-leading `● API Error:` — anchored strictly, so an agent whose completed-turn prose merely mentions an API error is still credited), logged verbatim so the curated lists can be grown from real occurrences instead of guesses, and routed down the same bounded retry path as known-transient failures: if the error was retryable the retry wins, and if not the budget runs out and the task is handed back as an honest `environment` failure. Never a fabricated completion in either case. +- Interactive contributor relays now revoke task-scoped GitHub credentials, double-interrupt only their configured tmux pane, and relaunch a clean backend before advertising `ready` after a task revoke ([#5041](https://github.com/kubestellar/hive/issues/5041)). A failed relaunch remains unavailable instead of overlapping another assignment. +- A contributor task that is *released* rather than finished now appears in the activity feed ([#5097](https://github.com/kubestellar/hive/issues/5097)). When a relay's socket dropped mid-task, or the relay asked for new work while still holding one, the hub released the issue and booked its cooldown but recorded nothing an operator could see — the feed showed a `picked up` with no terminal event ever following it, indistinguishable from an issue nobody touched. Measured on a live hub: a contributor restarting four times in ten minutes touched four different issues and completed none, with no record of the three it abandoned mid-implementation. Both release paths now add a `released: connection lost` / `released: gave the task back` entry naming the task, formatted the same way its own `picked up` entry was. Deliberately not the `failed` verb: [#4260](https://github.com/kubestellar/hive/issues/4260) established that a dropped socket is not a failure of the work, and counting it as one is what quarantined issues nobody had failed. +- `agy` (Google's Antigravity CLI) had no working contributor path at all: `src/Dockerfile.contributor` never installed it, so container mode failed with `agy CLI not found`, and [#5024](https://github.com/kubestellar/hive/pull/5024) correctly made local mode refuse to launch it unconfined — leaving an operator either accepting an unconfined agent on their host or not using agy ([#5048](https://github.com/kubestellar/hive/issues/5048)). `src/Dockerfile.contributor` now installs `agy` from Google's published, checksummed release tarball (the same one the `antigravity-cli` Homebrew cask resolves to), so `just contribute-hive agy` (container mode, the default) has a real host boundary. The `Justfile`'s `agy)` credential-staging case, previously mounting only `~/.gemini/antigravity-cli`, now stages the whole `~/.gemini` directory so `oauth_creds.json`/`google_accounts.json` — siblings of that path, not descendants — travel with it; a stale comment claiming agy "keeps no credential file under HOME that a container can inherit" is corrected (it does; the file was simply outside what was staged). Local mode's refusal behavior is unchanged: agy still requires `HIVE_AGY_DANGEROUSLY_RUN_UNCONFINED=1` to launch unconfined, and this does not claim confinement agy does not have — whether a staged credential re-authenticates an unattended, headless container run is unverified, and agy 1.1.22's own `--sandbox` flag is a remote/cloud sandbox concept per the binary's own strings, not a local OS-level boundary hive can rely on. `docs/backend-setup.md`, `src/docs/contributor-relay.md`, `src/docs/sandbox-isolation.md`, `config/backends.conf`, and the `/contribute` page's agy copy are all corrected to match. +- `just contribute-hive` now says when the Claude credential it staged into the container cannot authenticate ([#5088](https://github.com/kubestellar/hive/issues/5088)). Container mode gives the CLI a copy of `~/.claude` in an ephemeral staging directory that is deleted on exit — deliberate containment (H6/CWE-668) that keeps a permissions-bypassed agent away from the real credential — but it also silently discards a login performed *inside* the container. A contributor whose host credential had expired reached the login menu, completed the full browser flow, worked a session, and was back at the login menu next run with nothing to show for it. Interactive runs now warn before the CLI starts, stating that an in-container login lasts for this run only and pointing at running `claude` on the host once; headless runs (`CONTRIBUTOR_MODE=headless`) fail fast instead, since no one can answer a login prompt in a pod and it would otherwise sit at one indefinitely. An expired access token that still carries a refresh token is treated as usable, because Claude Code refreshes it silently and no prompt appears, and a set `ANTHROPIC_API_KEY` satisfies the gate outright — the recipe forwards it into the container, so an API-key contributor needs no OAuth file and must not be warned or refused. **The containment boundary is unchanged** — nothing is copied back out of the container. +- Kicking an agent from the dashboard no longer reports `504 Gateway Time-out` for a kick that actually succeeded ([#5325](https://github.com/kubestellar/hive/issues/5325)). `POST /api/kick/{agent}` was synchronous, and its slow leg waits for the agent's CLI to present its input prompt — bounded by `inputPromptTimeout` at 120s, which exceeds a typical ingress idle timeout of 60s. So whenever a CLI was merely slow to show its prompt (finishing a previous turn, still initializing, mid-restart) the proxy cut the connection and answered 504 while the wait was still running; the wait then completed server-side, the prompt was typed, and the agent ran the session to completion. The operator had been told it failed. This was not the JSON-parse bug fixed in [#5306](https://github.com/kubestellar/hive/pull/5306) — that fix worked correctly and is what surfaced the honest 504 instead of `Unexpected token '<'`. The endpoint is now asynchronous: fast, deterministic preconditions (unknown agent, paused, stopped, missing tmux session, over-long prompt) are still evaluated inline and still answer `400`, while the prompt wait and the typing move to a background goroutine and the handler answers `202` as soon as the kick is queued — so it can no longer outlive any proxy. Delivery outcome is read from the new `GET /api/kick/{agent}/status`, which reports `in-flight`, `delivered`, or `failed`; a CLI that never reaches its prompt within the timeout still settles as a genuine failure with its reason. Because the natural response to a false failure was to click Kick again — delivering the prompt twice, which on a hold-gated lane means duplicate advisory comments and beads — delivery is deduplicated per agent: a second click while a delivery is in flight is folded into the first and answers `status: "in-flight"` rather than typing the message a second time. The dashboard now polls for the real outcome and, critically, renders an unsettled kick as still-waiting rather than failed: a timed-out kick is indeterminate, not failed. + +### Added + +- TUI client support for reading and applying the ACMM level (`GET`/`PUT /api/acmm`), part of the `hive tui` epic ([#5136](https://github.com/kubestellar/hive/issues/5136)). +- `hive tui`'s GOVERNOR pane now renders live mode, total actionable queue depth, relative next evaluation, configured evaluation interval, and explicit ACMM level from the real governor API fields, with unavailable or unconfigured values shown honestly as `—` (T7 of [#4907](https://github.com/kubestellar/hive/issues/4907), [#5056](https://github.com/kubestellar/hive/issues/5056)). +- `hive tui` can pause or resume the selected agent from the AGENTS pane with `p` (T15 of [#4907](https://github.com/kubestellar/hive/issues/4907), [#5216](https://github.com/kubestellar/hive/issues/5216)). The key opens a modal naming the selected agent and proposed operation; `y` confirms, while `n` or `esc` cancels without a request. A successful call applies the server's authoritative returned state — including a no-op response with `changed=false` — and immediately refreshes the fleet. Failures remain in the modal instead of exiting the TUI, with owner-gated 403 responses explained as requiring owner access. While the modal is open it consumes every other key, so `q`, focus movement, and pane bindings cannot fire underneath it. The footer and help overlay now advertise `p` as available. +- `hive tui` can attach to the selected agent's local tmux session from the AGENTS pane with `a` (T22 of [#4907](https://github.com/kubestellar/hive/issues/4907), [#5218](https://github.com/kubestellar/hive/issues/5218)). The TUI asynchronously checks the canonical `hive-` session before suspending, runs `tmux attach -t ` through Bubble Tea's terminal-release flow, and refreshes the fleet after the session exits. A missing tmux binary or session stays inside the TUI as a footer error instead of quitting or flashing a raw tmux failure across the alternate screen. The control is local-only: it does not create sessions or use the dashboard's ttyd/WebSocket path. +- `hive tui`'s API client gains `Client.PauseAgent(ctx, agent)` and `Client.ResumeAgent(ctx, agent)` — the package's first WRITE calls (T14 of [#4907](https://github.com/kubestellar/hive/issues/4907), [#5134](https://github.com/kubestellar/hive/issues/5134)). Both `POST` to `/api/pause/{agent}` / `/api/resume/{agent}` and decode the shared 200 schema into a new `client.AgentActionResult` (`ok`, `status`, `agent`, `changed`, `state`), which `dashboard/openapi.json` and the `pauseToggleResponse` handler agree on field-for-field — nothing invented. `Changed` is the load-bearing field: pausing an already-paused agent is a deliberate **no-op returning 200 with `changed=false`** (re-pausing would clobber the original pause reason/trigger), so a caller that reads any 200 as "it happened" reports a transition that never occurred. `State` is authoritative on that path too, and `AgentActionResult.Paused()` exists because `status` and `state` disagree by design — a no-op resume returns `status:"resumed"` with `state:"running"`. The issue's proposed `PauseAgent(ctx)` signature was corrected to take the agent: `{agent}` is a required path parameter, and the name is `url.PathEscape`d so a separator in it cannot retarget the request at another route. An empty name fails locally rather than as a 404 describing the routing table. Supporting changes: `postJSON` joins `getJSON` on a shared `doJSON` core (body marshalled when non-nil, omitted when nil — these two operations declare no `requestBody`, so they send none and set no `Content-Type`); `APIError` gains `Method`, since it previously hardcoded `GET` and would have reported a failed write as a request that was never made; and `client.IsForbidden(err)` types the owner gate, because a 403 from `requireOwnerRole` is the one failure a pane must not offer to retry. On a self-hosted hive the TUI's `HIVE_DASHBOARD_TOKEN` clears that gate (the local proxy injects `X-Hive-Internal`, which `authenticate()` maps to a verified owner); on a hub-proxied hive a read-only user gets a real 403. +- `hive tui`'s API client gains `Client.Models(ctx, backend)`, reading `GET /api/inference/models/{backend}` into a new `client.ModelList` (`backend`, `models`, `fallback`, `partial`, `entitled`, `entitledSource`) — the TUI's model-discovery read path (T16 of [#4907](https://github.com/kubestellar/hive/issues/4907), [#5135](https://github.com/kubestellar/hive/issues/5135)). Like T6, the published spec could **not** be mirrored: `dashboard/openapi.json` declares `models` as `items: {"type":"object"}`, but `handleInferenceModels` sends a `[]string` (every source feeding it — `fetchInferenceModelsForBackendDetailed`, `inferenceStaticModelAliases`, `intersectEntitled` — is `[]string`, and the web dashboard reads it as one via `m.split('/')`), so a `ModelOption` struct with tags "matching the spec" would decode nothing at all. `ModelOption` is therefore a string type, and the spec also omits `entitled`/`entitledSource` entirely though the handler returns both; the drift is cited in-code and added to [#5077](https://github.com/kubestellar/hive/issues/5077). The task's proposed `Models(ctx) ([]ModelOption, error)` is corrected in both halves: `{backend}` is a required path parameter, and returning the bare slice would discard the flags that say whether the list can be trusted. Those flags are load-bearing — `fallback` means discovery found nothing and the server substituted unverified static aliases, and `partial` means only some endpoints answered, so a model's ABSENCE proves nothing. `ModelList.Authoritative()` encodes exactly that ([#4438](https://github.com/kubestellar/hive/issues/4438): auto-heal reading a partial sample as a census switches an agent off a model only the unreachable endpoint served). A 404 — the answer for a backend with no configured gateway — is documented as an ordinary "nothing to offer here" state rather than a fault. +- `src/docs/cel-triggers.md` documents the previously-undocumented `triggers:` config key (`pkg/config.TriggerRule`, evaluated by `pkg/celtrigger`) — the additive CEL-based agent-triggering feature that had no operator-facing doc: `src/hive.yaml.example` carried no `triggers:` block at all, and `src/docs/hooks.md`'s one mention was a passing comparison, not a guide ([#5184](https://github.com/kubestellar/hive/issues/5184)). The new page documents the full `TriggerRule` schema (`name`/`expr`/`agent`/`priority`) straight from the struct tags, the sole CEL activation (`event`, a `celtrigger.NormalizedEvent`) with every reachable field (`kind`, `repo`, `labels`, `title`, `author`, `body`, `is_draft`, `number`, `state`, `base_branch`, `head_branch`, `assignees`, `comment`) and the `hasLabel(list, needle)` helper, the fail-closed contract (malformed rule ⇒ config load fails; runtime evaluation error or cost-budget overrun ⇒ silently treated as no-match, never a crash), and three worked examples verified to compile and match against `pkg/celtrigger`'s own test suite. Cross-linked from `src/docs/hooks.md` (which shares the CEL engine for its `when:` predicate but binds an unrelated `t` transition-payload variable), `src/docs/README.md`'s index, and `src/docs/agent-configuration.md`'s cadences section. `src/hive.yaml.example` gains a commented, opt-in `triggers:` block matching the neighboring `retro:`/`planning:` blocks' style. +- `hive tui` gains a help overlay on `?` (T23 of [#4907](https://github.com/kubestellar/hive/issues/4907), [#5137](https://github.com/kubestellar/hive/issues/5137)). A centred, bordered table of the design doc's §4 keybindings; any key dismisses it, and the footer strip now advertises `? help` so it is discoverable. The overlay is **modal by construction**: the dismiss branch runs before the global bindings, so `q` closes the dialog instead of quitting the program — the one misfire a help screen must not have — and keys never reach the focused pane underneath. The table shows the whole §4 roadmap but says plainly which half is live, extending the rule `footerText` already follows ("showing them now would advertise actions that silently do nothing"), which matters more in help than in the footer because help is where an operator goes to learn what they CAN do. Only `tab`/`shift+tab`, `?` and `q`/`ctrl+c` are marked available; `j`/`k`, `p`, `m`, `K`, `A` and `a` are listed under "not wired up yet", and each later task flips its own flag — a test pins the current set so the flag cannot rot silently. `panes.Help()` returns the box and `app.go` centres it via `lipgloss.Place`, the same content/chrome split `pane.go` already draws. Golden at 100x30 in `panes/testdata/help.golden`; `grid.golden` is regenerated for the one-line footer change. +- `src/docs/telemetry.md` and `src/docs/operations.md` document the two L5/L6-only opt-in agents that previously had no dedicated page ([#5098](https://github.com/kubestellar/hive/issues/5098)), following the structure of `src/docs/supervisor.md`: what each agent does per ACMM mode, the L5/L6-only gating verified against `src/pkg/config/packs/level-5.yaml`/`level-6.yaml` (absent below L5, `paused` in every governor mode at L5/L6 until an operator opts in), the `governor.project_observability` opt-in flow and its automatic paused→`24h` cadence swap, how the two agents' disjoint lane keywords divide the work, and their `applyKnownAgentDefaults` registration. `examples/agents/telemetry.md` and `examples/agents/operations.md` add the two missing role-specific policy-prompt examples to the ten-file `examples/agents/` set, mirroring `supervisor.md`/`strategist.md`'s prose structure ([#5099](https://github.com/kubestellar/hive/issues/5099)). `src/docs/getting-started.md`'s L5 section now names telemetry/operations as the two agents that first appear at that level, states plainly that they stay paused by default even there, and links the opt-in path instead of the previous "Leave paused: nothing," which was inaccurate; the L6 section gets the equivalent correction ([#5100](https://github.com/kubestellar/hive/issues/5100)). + +- `hive tui`'s API client gains `Client.Agents(ctx)`, decoding `GET /api/agents` into a new `client.Agent` type (`name`, `id`, `displayName`, `enabled`, `managed`, `backend`, `model`) — the TUI's agent-list read path (T4 of [#4907](https://github.com/kubestellar/hive/issues/4907), [#5053](https://github.com/kubestellar/hive/issues/5053)). The design doc's contract gap this task was filed against ([#4912](https://github.com/kubestellar/hive/issues/4912)) is closed: [#5023](https://github.com/kubestellar/hive/pull/5023) added `/api/agents` to `dashboard/openapi.json`, and its shape matches the `handleAgentsList` handler field-for-field, so no fields were invented or guessed from the live response. +- `hive tui`'s API client gains `Client.Governor(ctx)` and `Client.GovernorEvalInterval(ctx)` — the TUI's governor read path (T6 of [#4907](https://github.com/kubestellar/hive/issues/4907), [#5055](https://github.com/kubestellar/hive/issues/5055)). `Governor` decodes the governor slice of `GET /api/status` into a new `client.GovernorStatus` (`active`, `mode`, `issues`, `prs`, `thresholds{quiet,busy,surge}`, `nextKick`, plus the top-level `acmmLevel`/`acmmLevelConfigured`); `GovernorEvalInterval` reads the evaluation cadence from `GET /api/config/governor`, the only endpoint that publishes it. Unlike T4, the published spec could **not** be mirrored here: `dashboard/openapi.json` documents `governor` as `{mode, queue, budgetPct}` and the server sends none of `queue` or `budgetPct` (queue depth arrives split as `issues`/`prs`, budget is a separate top-level object), so the types are transcribed from `dashboard.FrontendGovernor` and `buildGovernor` with the divergence cited in-code and filed as [#5077](https://github.com/kubestellar/hive/issues/5077). Nothing is invented: every field exists on the wire today. +- `hive tui` polls: the frame is live rather than a static sketch (T12 of [#4907](https://github.com/kubestellar/hive/issues/4907), [#5061](https://github.com/kubestellar/hive/issues/5061)). A tick every 5s — the cadence `dashboard/server.js` already refreshes at, so the TUI never asks for a snapshot the server has not rebuilt — issues the client reads that exist and delivers each result to the panes as that pane's own message type (`panes.AgentsMsg` today; one per pane as T6/T8/T10 land). Startup fetches immediately instead of waiting out the first interval. A failed fetch is swallowed by the app and never reaches a pane, so the previous data survives a transient error by construction, and the next tick is armed before the fetches are issued, so a dashboard that is down cannot stop the clock. The header's `hive:`/`governor:`/`ws:` placeholders are unchanged and still honest: nothing polled here carries any of them. +- `hive tui`'s TOKENS pane renders real usage — one row per agent (agent, in, out, cost), a separator and a totals row, with human-readable magnitudes (`1.2M`, `88.1k`) — driven by a new `panes.TokensMsg` that the poll loop fills in T12/T13b (T9 of [#4907](https://github.com/kubestellar/hive/issues/4907), [#5058](https://github.com/kubestellar/hive/issues/5058)). Rows are sorted by spend descending with a name tie-break, because the dashboard keys usage by map and an unsorted pane would reshuffle on every refresh; the totals row is the dashboard's own total rather than a re-sum of the rows, which would silently disagree with the web UI on any hive whose collector saw sessions it could not attribute to a configured agent. The sketch's COST column is present but degrades honestly: `GET /api/tokens` carries no dollar figure at all (`tokens.AggregateSummary` is counts only — cost lives on `GET /api/cost`), so a row whose cost was not fetched renders `—` and never `$0.00`. + +- `hive tui` handles resize and refuses to draw a broken grid in a terminal that cannot hold one (T24 of [#4907](https://github.com/kubestellar/hive/issues/4907), [#5138](https://github.com/kubestellar/hive/issues/5138)). The grid already re-derived itself from the last `tea.WindowSizeMsg` on every render, so panes share the space at any size; what is new is the floor. Below 60x20 the grid is not shrunk to a stack of two-column-wide empty boxes — it is replaced by a single centred `terminal too small (need at least 60x20)`, wrapped and clipped so the message fits even a terminal narrower than the message itself. The threshold is named by the message from the same constants the guard compares against, so what an operator is told to resize to cannot drift from what is enforced, and an unsized model (width 0, before the first `WindowSizeMsg`) still shows the splash rather than claiming a measurement nobody has taken. Pinned by teatest cases at a large, an exactly-minimum and a below-minimum size, a shrink-below-and-grow-back case that requires the restored frame to be byte-identical to a never-shrunk one, and a new `pkg/tui/panes/testdata/too_small.golden`. +- `hive tui`'s focus highlight is visible on a light terminal (T25 of [#4907](https://github.com/kubestellar/hive/issues/4907), [#5139](https://github.com/kubestellar/hive/issues/5139)). Both of the frame's colors were ANSI-256 literals written at their call sites and chosen against a dark background: the focused pane's border, ANSI 205 (`#ff5fd7`), is a ~7.9:1 contrast highlight on black but ~2.4:1 on white — barely a tint — so an operator on a light terminal could not see which pane had focus, the one thing that border exists to say. Both literals move into a new `pkg/tui/theme` package as `lipgloss.AdaptiveColor` tokens named by role (`Border`, `BorderFocus`), each with a light-background counterpart matched by contrast against its own background rather than by resembling the other: the border's 240 (~2.9:1 on black) pairs with 245 (~3.5:1 on white) so unfocused chrome stays chrome instead of becoming the loudest thing on a light screen, and the focus border's 205 pairs with 127 (`#af00af`, ~6.3:1 on white) — the same magenta, darkened until it carries the same emphasis. The dark halves are exactly the colors that shipped, so nothing changes on a dark terminal. Header and status-glyph tokens are deliberately not defined: nothing renders a status glyph yet and the header carries bold rather than color, so a token for either would mean inventing a color for a role nothing draws. A ratchet test parses (rather than greps) `pkg/tui` and `pkg/tui/panes` and fails if either names a color, in the `lipgloss.Color("…")` or the raw-hex form — `pkg/tui/theme` is the only place allowed to, and the ratchet enforces that by not scanning it rather than by exempting a filename. The palette sits in its own leaf package because `pkg/tui` imports `pkg/tui/panes`: the help overlay (T23) borders its box in the frame's emphasis color, so a palette in package `tui` would be an import cycle away from the one pane that needs it. The golden files needed no regeneration and a new test pins why: `go test` renders through termenv's Ascii profile, where color is stripped before an adaptive token's two halves can differ, so background detection — which varies with whatever terminal CI inherited — cannot reach the captured bytes ([#5131](https://github.com/kubestellar/hive/issues/5131)'s flake shape, one vector over). + +- `kilo` (`Kilo-Org/kilocode`) as a headless contributor-relay backend ([#5038](https://github.com/kubestellar/hive/issues/5038)). It uses Kilo's distinct `kilo run` surface with `--auto` and optional `--model provider/model`; credentials/config flow only through `KILO_AUTH_CONTENT`, `KILO_CONFIG_CONTENT`, `KILO_API_KEY`, and optional `KILO_ORG_ID`, never a whole Kilo config mount. Kilo is headless-only and remains outside the Kubernetes allowlist pending independent credential and confinement verification. + + +- Pi contributor transport and readiness ([#5039](https://github.com/kubestellar/hive/issues/5039)): `AGENT_MODEL=provider/model` is now the single contributor-owned Pi selection (no competing provider variable), preserved across initial launch, restart, reconnect evidence, and headless `pi --print --mode json` tasks. Provider credentials stay in Pi's official provider-specific variables or a narrowed staged auth file, only the selected provider's environment is handed into the container, and bounded output redacts the selected credential. Machine-readable readiness distinguishes binary, configuration, configured-but-unverified authentication, verified authentication, and real invocation success. Completion receipts carry effective backend/model, assignment generation, and result; headless revoke kills Pi and fences late success while interactive Pi remains explicitly outside cancellation conformance. +- `just contribute-k8s` now ships the agent CLI's own credential — or refuses to generate ([#5103](https://github.com/kubestellar/hive/issues/5103)). The generated workload authenticated to the hub (`HIVE_REGISTRATION_TOKEN`) and to GitHub (`GH_TOKEN`) and then launched a backend with nothing to authenticate *with*: the pod deployed cleanly, went Ready, accepted a task, and could do no work, for all five allow-listed headless backends. The Secret now carries the selected backend's credential material — `claude`: an explicit `ANTHROPIC_API_KEY` (preferred, with the same onboarding pre-seed the litellm path uses so the custom-API-key approval prompt cannot block a headless pod) or the operator's logged-in `~/.claude/.credentials.json`, base64-wrapped and materialized at startup by `bin/contributor-agent.sh` (0600, decode-validated, never written back to the laptop); `litellm`: `HIVE_LITELLM_ENDPOINT`+`HIVE_LITELLM_API_KEY`, which the entrypoint already maps to the claude CLI; `goose`: `GOOSE_PROVIDER`/`GOOSE_API_KEY`(/`GOOSE_MODEL`), which the entrypoint already writes into goose's config. With no credential available — and for `copilot`/`codex`, whose OAuth state directories are unverified in an unattended pod — generation now **refuses with a message naming exactly what is missing** instead of emitting a manifest that cannot work; `HIVE_K8S_ALLOW_MISSING_BACKEND_CREDENTIALS=1` emits anyway for operators supplying credentials out of band. The Deployment's interim credential note now covers the backend credential alongside `GH_TOKEN`. + +- `opencode` (`anomalyco/opencode`) as a contributor-relay CLI backend ([#4970](https://github.com/kubestellar/hive/issues/4970)). It joins `KNOWN_BACKENDS`/`config.CLIBackends` and dispatches through headless one-shot mode (`opencode run "" --model provider/model --auto`, `CONTRIBUTOR_MODE=headless`) rather than the interactive tmux keystroke path, since `opencode run` is the CLI's natural non-interactive entry point. opencode is provider-agnostic (75+ providers) and unconfined (no OS sandbox of its own, like goose/pi/bob); `--auto` is its unattended auto-approve flag. It is not yet in `just contribute-k8s`'s headless-pod allowlist pending verification that `opencode auth login`'s credential (`~/.local/share/opencode/auth.json`) supports unattended use in a fresh pod. Relay-only: this does not add hub-side pod-launcher support beyond what the shell/Go backend-list parity guard ([#4987](https://github.com/kubestellar/hive/pull/4987)) already requires. + - `dashboard.public_url`: the externally reachable origin of this dashboard, used to build the OAuth `redirect_uri` for the Linear agent install and the OpenRouter funding flow when it differs from the host the request arrived on. Until now the only knob was `hub.dashboard_url` — a hub-namespaced field that is semantically wrong on a hive with no hub, and one an operator had to discover by reading source — and without it the origin was derived per request from `X-Forwarded-Host`/`Host`. That breaks a standalone hive whose dashboard is private but whose `/linear/callback` is published on another hostname behind an ingress that rewrites `Host` (Traefik with a fixed upstream `Host`, a Cloudflare Tunnel "HTTP Host Header"): the install leg and the callback leg derive different origins and Linear rejects the code exchange with `redirect_uri is invalid`. Precedence is now `dashboard.public_url` → `hub.dashboard_url` (unchanged for hub-hosted spokes) → forwarded/request host, shared by both flows. The value must be an absolute `http(s)://` origin with no path, query, fragment or credentials — anything else fails config load with a clear error, and a trailing slash is trimmed. `POST /api/linear/agent/install` additionally returns the `redirect_uri` it used beside `authorize_url`, so the value the Linear app's Callback URL must match is visible without decoding the authorize URL. Setup notes in [docs/linear-agent.md](src/docs/linear-agent.md#setup). -- Third-party license attribution: a repo-root `NOTICE` file lists every Go module dependency compiled into `hive`, `hive-hub`, and `hive-contributor`, with module path, version, and license identifier. It is generated by `src/scripts/generate-notice.sh` (pinned `google/go-licenses`, following the same install-by-version pattern as `govulncheck`/`gosec`) and kept fresh by a new `notice-drift` job in `go-security-analysis.yml`, which regenerates `NOTICE` on every change to `src/go.mod`/`src/go.sum`/the script and fails the build on drift. Tagged releases now attach `NOTICE` to the GitHub Release alongside the existing per-image SBOMs. The committed `NOTICE` is currently a statically-derived placeholder (assembled from `src/go.mod` without running `go` tooling, every license field marked `UNVERIFIED`) — see [docs/releases.md](src/docs/releases.md#third-party-notices-notice) for what a maintainer needs to do once CI produces the authoritative, license-text-included version. +- Third-party license attribution: a repo-root `NOTICE` file lists every Go module dependency compiled into `hive`, `hive-hub`, and `hive-contributor`, with module path, resolved version, detected license identifier, source URL, and license text. It is generated authoritatively by `src/scripts/generate-notice.sh` (pinned `google/go-licenses`, following the same install-by-version pattern as `govulncheck`/`gosec`) and kept fresh by a new `notice-drift` job in `go-security-analysis.yml`, which regenerates `NOTICE` on every change to `src/go.mod`/`src/go.sum`/the script and fails the build on drift. Tagged releases now attach `NOTICE` to the GitHub Release alongside the existing per-image SBOMs. See [docs/releases.md](src/docs/releases.md#third-party-notices-notice) for generation and scope. - A Linear-sourced hive can now be configured end-to-end from the dashboard. `PUT /api/config/governor/work-source` accepted only `api_key` and `hold_labels` for Linear, so `session_agent`, `assigned_only`, and the team→repo map (`teams[].key/repo/states/cycles/projects`) — the parts that make enumeration work and keep a large backlog from pushing the governor into SURGE — had to come from the ConfigMap seed. The endpoint now accepts all of them (`teams` replaces the stored list when present, like `hold_labels`) and validates before persisting: team `key`/`repo` are required, `session_agent` must name a configured agent, and `assigned_only: true` is refused (400) until the Linear agent is connected — the same fail-closed rule the work-source factory enforces, so an unusable config is never saved. The Work Source tab gains the matching controls, including a team editor. `GET` no longer returns the Linear API key value, only `api_key_set`; a `PUT` without `api_key` keeps the stored key. See [docs/linear-agent.md](src/docs/linear-agent.md#setup). - GitHub App credential verdicts now report the granted **Actions** and **Commit statuses** permissions, including when the verdict is `ok`. App auth classification reads only the Issues permission, which is correct — the Hive App deliberately holds Actions at read and no Commit-statuses grant at all, and the optional [Visual Hive App](src/docs/github-app-setup.md#the-optional-visual-hive-app-4030) exists so those two write grants never have to be added fleet-wide ([#4030](https://github.com/kubestellar/hive/issues/4030)). But reading only Issues meant the other two grants were not merely unenforced, they were unobservable: an installation that had approved them and one that had not both reported `state=ok`, with nothing anywhere recording the difference. Since GitHub keeps an App on its *old* permissions until an org owner accepts an update, a fleet can sit half-approved indefinitely and look entirely healthy. Each verdict now also logs `grants="actions= statuses="` (`none` where there is no grant) and `visual_hive_execution_grants=`, true only when both are at write — emitting it for a *healthy* verdict is the point, since the un-approved installation is the one that still looks fine. Verdicts are computed where a GitHub call has already failed and on the dashboard's **Re-check** button, so this adds no API calls to a healthy hive, and Re-check is the on-demand way to read a given installation's grants. Nothing is enforced and no classification changed: an ordinary installation missing both grants is still `ok`, still raises no banner, and is still never asked to fix anything. - ACMM gap issues can now be filed where the backlog lives instead of always on GitHub. The dashboard's "Open Issue" / "Open All" buttons on a failed ACMM criterion used to create a GitHub issue even on a hive whose work source is Linear. A new `governor.acmm.issue_tracker: github | work_source` key (default `github`, so unset hives see no change) routes them: `work_source` on a Linear-sourced hive creates the issue via Linear `issueCreate` on the team mapped to the criterion's repo (`work_source.linear.teams[].repo`, else the first team) with the same title and body; on a GitHub-sourced hive it stays on GitHub. `POST /api/acmm/issue` accepts an optional per-request `tracker` override (`github` | `work_source`; unknown values are a 400) and its response now carries `tracker` (`github` | `linear`) plus, for Linear, `identifier` and `team`. `GET /api/acmm/evaluation` reports the effective default as `issue_tracker`, and the level dialog shows a "File in: GitHub / Linear" selector when the work source is Linear. See [docs/acmm-policy-matrix.md](src/docs/acmm-policy-matrix.md#where-acmm-gap-issues-are-filed). - Advisory digest target: `governor.advisory.target: github | linear` chooses where the once-per-cycle advisory digest comment lives. `github` is the default and leaves the pinned-issue path exactly as it was, key absent or present. `linear` maintains the digest as one comment on a designated Linear issue (`governor.advisory.linear_issue: ONB-123`), rewritten in place each cycle with the same body the GitHub comment gets and authenticated with the work source's existing `governor.work_source.linear.api_key` — so a Linear-sourced hive whose owners live in Linear finally has a way to see the digest there. Both keys are editable from **Governor Config → Advisory**. The Linear route fails closed: a missing `linear_issue`, missing key, or unknown issue is logged as an error naming the key and recorded as a failed post (tripping the hub's stale-advisory pill), never silently redirected to GitHub. See [docs/advisory.md](src/docs/advisory.md#where-the-digest-is-posted). +- Linear agent sessions and the governor no longer hand the same issue out twice. A delegated issue arrives both through the session webhook (kicked immediately) and, with `assigned_only`, through the governor's next sweep; because kicks wait for an idle prompt rather than interrupting, the effect was a *re-hand* the moment the session's run ended. The session tracker is now the in-flight ledger: while a session is working, the scheduler withholds its issue from every governor kick and says so in an **In Flight** note (`${IN_FLIGHT}` places it explicitly). Alongside: the Linear session agent now defaults to the sole enabled agent whose ACMM mode allows tracker writes — so the L3 pack (six agents, quality the only writer) takes sessions without setting `work_source.linear.session_agent` — a PR opened through `hive-open-pr` for an agent with an active session is narrated into the session and attached to its external links, and the Linear Agent card in Settings → Governor → Work Source reports which credential agents hold for Linear writes ([docs/linear-agent.md](src/docs/linear-agent.md)). - Linear work sources now reach GitHub-Issues parity for agent *writes*. Until now an agent on a Linear-sourced hive could be handed `owner/repo!TEAM-123` in its work list but had no credential for `api.linear.app` and only GitHub-shaped instructions (`gh issue create`, `Fixes #N`) — the proxy's Linear mutation allowlist was gated but unreachable. The hive now hands ISSUES_ONLY+ agents the connected Linear app's OAuth token as `LINEAR_ACCESS_TOKEN` (falling back to `work_source.linear.api_key` as `LINEAR_API_KEY`), re-pushed on the same hourly tick as the GitHub App token, and strips both from advisory sessions — so Linear writes are authored by the same Hive app identity that acknowledges sessions, the analogue of App-bot authorship on GitHub. Every kick on a Linear-sourced hive also carries a **Work Tracker: Linear** section rendered from the existing `work_source.linear` config (team → repo map, states, hold labels, `assigned_only`) that maps the policy's issue recipes onto Linear: how to authenticate, `issueCreate`/`commentCreate` in place of `gh issue create`, and PR linking through Linear's own GitHub integration (`Fixes TEAM-123` closes on merge, `Part of` / `Refs` do not) rather than a hive-side state machine. It is injected at the same post-resolution seam as held-PR coordination, so customized templates cannot omit it; `${WORK_TRACKER}` places it explicitly. GitHub-sourced hives see no change. Setup and the manual verification list are in [docs/linear-agent.md](src/docs/linear-agent.md#github-issue-parity-agents-writing-to-linear). @@ -35,8 +158,7 @@ Hive did not historically maintain a complete changelog. This file starts a prag - `converse`: a per-agent capability that separates *talking* from *filing*. Posting a comment sat behind `ISSUES_ONLY`, bundled with creating issues, rewriting issue bodies and relabelling; leaving a PR review sat behind `ISSUES_AND_PRS`, bundled with pushing branches. Both bundles were wrong in both directions — an ADVISORY agent that noticed something on a thread could not reply, only emit a bead nobody outside the hive ever reads, and buying it a reply meant also handing it issue-mutation rights nobody asked for. `converse` is deliberately not a fifth rung on the mode ladder: conversation is not a trust tier between "observe" and "file issues", it is a different axis. It is an orthogonal `converse: true` on the agent, checked *beside* the mode tier rather than instead of it, so it can only ever widen — an agent already at a permitting tier is unaffected, and no capability reaches the hard-denied routes (direct PR creation, direct merge) or anything the tier ladder gates. Enforced over both REST and GraphQL, which matters because `gh issue comment` and `gh pr review` send GraphQL; on the GraphQL side the grant is evaluated over the whole document, so a mutation that comments *and* edits an issue, or comments and merges, is still refused at the tier its non-conversational half requires. Off at every ACMM level by default — a hive that does not mention it behaves exactly as before, and nothing is written into `hive.yaml` until an operator opts in ([#4492](https://github.com/kubestellar/hive/issues/4492)). - `bin/hive-podman-setup.sh`: a one-command standalone install for the Podman path, closing the asymmetry with Docker's `bin/hive-setup.sh`. The Podman install was a correct but manual twenty-step sequence in the README, and two of those steps are the traps that cost the most to hit by hand: `dashboard.port` disagreeing with the unit's healthcheck port buys a silent 300-second hang with no container left to inspect, and a secrets directory at the intuitive `mkdir -m 700` presents as an SELinux denial it is not. The script reads the port out of the unit that will enforce it and refuses to continue if the config does not read back agreeing, and applies the right secrets command for the root mode (`podman unshare chown` rootless, `chgrp` rootful). It installs no packages — Podman's supported hosts include image-based systems where the package manager cannot — clones nothing, and generates no deployment description; the four Quadlet units are installed verbatim. It is idempotent, never overwriting an existing config without `--force` and never touching `secrets/` at all, and it ends by confirming the gateway answers rather than that `systemctl start` returned. A failing step says which step and leaves the host as it is ([#4470](https://github.com/kubestellar/hive/issues/4470)). -### Changed - +- Static-analysis ratchet (#4903): fixed 121 of the 122 `staticcheck` findings on `v4` — real bugs (a nil-guard false positive, a duplicate-rune cutset, dead self-assignments, unasserted test branches), API-deprecation migrations kept behavior-identical, and mechanical De Morgan/tagged-switch/embedded-field simplifications — without enabling the linter yet, since one finding sits inside a prompt-injection fail-closed security test this rung deliberately does not touch. `errcheck` remains the last rung before `staticcheck` can flip on. - Linear backlog admission now honors native blocker relations instead of admitting every non-GitHub item unconditionally. Linear issue identifiers remain source-aware (`repo!TEAM-123`) through convergence, while GitHub and GitHub Enterprise identities retain their existing `owner/repo#number` shape. Open blockers withhold work and completed or canceled blockers release it on the next sweep, without creating shadow GitHub beads ([#4730](https://github.com/kubestellar/hive/issues/4730)). - Hold-gated writers now receive the open held-PR set as a mandatory occupied-ground preflight before every scheduled or manual kick. Held PRs had been moved out of the actionable PR list by design, while agent policy simultaneously forbade rebuilding that list with `gh`; a second coverage pass therefore could not see work that had spent hours awaiting human review. The injected snapshot names every held PR, requires a title/body/files/diff comparison before choosing a cluster, and directs overlapping work to a disjoint target or to stand down. It is applied after policy resolution, so customized and repo-sourced prompts cannot accidentally omit it, and held titles pass through the same prompt-injection scanner as ordinary PR titles ([#4744](https://github.com/kubestellar/hive/issues/4744)). - `/contribute` light theme: the accent palette is retuned for light surfaces. The dark-tuned accents (`#58a6ff` blue, `#d29922` amber, `#3fb950` green, and friends) sat below 3.0:1 on light backgrounds once the theme selector made light user-reachable; accent text, tier stat numbers, status dots and focus rings now route through per-theme tokens whose dark values are the exact original hexes — dark rendering is byte-identical — while light gets Primer-light equivalents (`#0969da`, `#9a6700`, `#1a7f37`, `#bf3989`, `#8250df`, …) that clear 4.5:1 for body-size text. A headless-Chromium contrast sweep of the rendered page went from nine sub-3.0:1 pairs to zero in light with zero computed-color changes in dark; per-theme token pins and a literal-accent regression guard hold the line ([#4560](https://github.com/kubestellar/hive/issues/4560)). @@ -46,6 +168,29 @@ Hive did not historically maintain a complete changelog. This file starts a prag ### Security +- Contributor-local confinement now extends to every remaining backend, closing + the rest of the gap #5011 opened for claude/litellm only. `copilot` local + launches now use Copilot CLI's own OS-enforced `--sandbox` (Seatbelt on + macOS, bubblewrap on Linux — same class of boundary as Claude's), gated on + the installed CLI actually supporting the flag and falling back with a loud + warning (or `HIVE_COPILOT_DANGEROUSLY_BYPASS_SANDBOX=1`) if it does not. + `opencode` local launches gain a host-state command deny-list via its own + `permission.bash` config — the same command family the claude deny-list + covers, honestly documented as a floor and not a filesystem boundary, since + opencode has no OS sandbox of its own. `goose`, `agy`, `bob`, `pi`, and + `aider` have **no sandbox, filesystem allowlist, or command deny-list this + repo can wire at all** (verified against each CLI's own current + documentation) — `just contribute-hive local` for these five now + **refuses to launch** with a plain explanation of what's missing, unless the + operator sets that backend's own `HIVE__DANGEROUSLY_RUN_UNCONFINED=1` + escape hatch. The local-mode launch banner now distinguishes three postures + (sandboxed / denylisted-only / unconfined) instead of two, so it never calls + a command-deny floor a "confinement" it is not. Container mode remains the + backend-independent default for all of them. See + [docs/sandbox-isolation.md](src/docs/sandbox-isolation.md#per-backend-confinement-on-the-contributor-local-path) + for the full per-backend matrix + ([#4918](https://github.com/kubestellar/hive/issues/4918)). + - Claude-family contributor agents are now write-confined even when explicitly launched in host-local mode. `just contribute-hive local` enables Claude Code's native OS sandbox, fails startup if that sandbox is @@ -62,7 +207,12 @@ Hive did not historically maintain a complete changelog. This file starts a prag - Token mint: the `/mint` HTTP endpoint gains a caller-identity seam, per-caller entitlements, and per-caller audit records ([#4436](https://github.com/kubestellar/hive/pull/4436), [#3915](https://github.com/kubestellar/hive/issues/3915)). Default behaviour is unchanged (shared-secret gate, no entitlement bound — a warning is now logged for this posture); once `Entitlements` are configured the mint is deny-by-default per verified identity, refusing any subject, audience or scope outside the caller's grant, and every mint and refusal is logged with the caller's identity. - Token mint: `/mint` can now verify a caller's Kubernetes ServiceAccount instead of only its possession of a shared secret, closing the last of [#3915](https://github.com/kubestellar/hive/issues/3915). The seam landed earlier without this backend on the assumption it needed `k8s.io/client-go` — a dependency of a weight worth a maintainer's decision. It does not: TokenReview is one POST of a small, stable JSON object to `authentication.k8s.io/v1`, so the standard library covers it and the decision is no longer in the way. A caller presents a ServiceAccount token projected for the mint's own audience in a dedicated header, and the identity the API server returns — `system:serviceaccount::` — is what per-caller entitlements are keyed on and what the audit line records. The audience is verified on the API server's *response*, not merely requested: a cluster whose authenticators do not validate audiences answers "authenticated" with the field absent, and accepting that would let the API-server token every pod already has mounted be replayed at the mint. The token is read from its own header rather than `Authorization`, so a dual-accept deployment cannot forward the mint's shared secret to the API server as something to review. Every infrastructure failure — unreachable API server, missing RBAC, unparseable response — refuses the caller, because a TokenReview that failed open would be worse than the secret it replaces. A dual-accept authenticator runs both mechanisms during migration so the cutover is not a flag day. Default posture is unchanged: a mint that configures nothing still uses the shared secret. -### Fixed +- **Tagged Release can now push its release commit to the protected `v4` branch** ([#5026](https://github.com/kubestellar/hive/issues/5026)). `v4` branch protection requires the `gate` status check, which only ever attaches to a commit through `docker.yml`'s own triggers — so the release commit `release.yml` creates in-job had no `gate` check on it and every direct push to `v4` was rejected (`GH006: Required status check "gate" is expected`), identically on every retry, permanently blocking the first tagged release. `release.yml` now pushes the release commit to a throwaway `release-gate/v` branch first (which earns a `gate` check the same way any other branch push does), waits for it to succeed, then pushes the same commit to `v4` — which protection now accepts, since a required check is evaluated by commit SHA, not by which ref it ran on. The scratch branch is outside `docker.yml`'s `LONG_LIVED` set so this never pushes a stray GHCR image or moving tag, and it is deleted immediately after regardless of outcome. Branch protection itself is unchanged: no bypass, no weakened check, no `enforce_admins` change. See [docs/releases.md](src/docs/releases.md#satisfying-branch-protection). +- **`TestIntegration_SelectTask_PromotionRequiresPR` flake fixed (test-only; no product change)** ([#5037](https://github.com/kubestellar/hive/issues/5037)). The test drove the WS `task_complete` handler in a loop, then re-read the contributor's profile from disk after a fixed `time.Sleep(30 * time.Millisecond)` rather than waiting for the handler's own `saveContributorProfile` write to actually land. Under load that sleep can lose the race against the connection's read-loop goroutine, so the test observed a stale `TasksWithPR` and reused an issue number whose completion had *already* booked it into cooldown — `selectTask` then correctly (and confusingly) returned `task_unavailable/no_matching_work` for it. `src/pkg/dashboard/contribute_ws_integration_test.go` now polls the persisted profile for the expected `TasksCompleted`/`TasksWithPR` count (bounded, 2s timeout) instead of guessing a fixed delay. No production code changed; `selectTask`'s behavior was correct throughout. +- **errcheck ratchet, step 2 of 5 ([#4903](https://github.com/kubestellar/hive/issues/4903)): all 94 production findings in `pkg/dashboard` fixed.** Persistence and credential failures are no longer silent: audit and prompt-history append failures are logged; contributor activity/task ledgers stop and report when their parent directory cannot be created; snapshot builds stop when their output directory is unavailable; and GitHub, Claude, and Copilot logout handlers report a failed credential deletion instead of claiming success. Scaffold ZIP writes and closes, inception-wiki imports and vault reconnection, workspace-cleanup chmod walks, and SSE setup writes now surface or act on their errors. HTTP body closes, read-only descriptor cleanup, post-response writes, and cleanup that is already preserving a primary error are explicitly best-effort. `errcheck` remains out of `src/.golangci.yml` until the `pkg/proxy` and `pkg/hub` steps land, so the gate stays green throughout the ratchet. +- **errcheck ratchet, step 1 of 5 ([#4903](https://github.com/kubestellar/hive/issues/4903)): every ignored-error finding outside `pkg/hub`, `pkg/proxy`, and `pkg/dashboard` fixed.** `errcheck` reported 585 findings on `v4`; this step clears the ~114-finding long tail across ~24 smaller packages (`pkg/knowledge`, `pkg/hivectl/commands`, `cmd/hive`, `pkg/agent`, `pkg/beads`, `pkg/config`, and others), leaving the three largest packages for steps 2-4. Each finding was judged individually rather than blanket-silenced. Most were genuinely ignorable and are now explicit `_ = ...` with the reasoning in a comment — closing an already-fully-read HTTP response body, a read-only file descriptor, best-effort cleanup in a branch that is already returning the real error, or a CLI stdout write with no one left to report a broken pipe to. A handful were real bugs, fixed rather than silenced: `beads.Store.Archive` and `appendArchiveEntry` deferred `Close()` on a **write path** with the error discarded, so a failed flush could report a successful archive while the in-memory bead was deleted — the record now round-trips through a checked `Close`, and the archive-then-delete contract can no longer lie. `GraphStore`'s four bbolt `View` calls and `DocumentSource.Delete`'s `RemoveTriple` calls now log rather than silently drop a query/cleanup failure. `hivectl`'s table printer now returns the `tabwriter.Flush` error instead of dropping it, so a broken output pipe is reported instead of swallowed. `cmd/hive/main.go`'s self-upgrade marker cleanup and `parseColorInt`'s hex parse now handle their failure paths explicitly instead of leaving stale state or silently returning black. `errcheck` itself stays out of `src/.golangci.yml` until steps 2-4 (`pkg/hub`, `pkg/proxy`, `pkg/dashboard`) land — this PR fixes findings only. +- **Inference reroute no longer forwards Claude CLI telemetry to the gateway.** When an agent's backend is an OpenAI-compatible inference gateway, every request the Claude CLI made to its Anthropic host — telemetry batches under `/api/event_logging/`, error reports, `POST /v1/messages/count_tokens`, profile lookups — was blindly translated into a `POST /v1/chat/completions` with `"messages": null`, and the gateway rejected each one with `400 Missing required parameter: 'messages'`, counted against the provider's request rate limit (measured at ~2 failures per real completion on a production hive). Both the MITM reroute and the `ANTHROPIC_BASE_URL` translator now forward only `POST /v1/messages`: `count_tokens` is answered locally with a chars-based `input_tokens` estimate, anything under `/api/` gets `200 {}`, and unknown paths get a 404 `not_found_error` plus a `WARN` log line naming the method and path. Inference-routed `claude` sessions also launch with `DISABLE_TELEMETRY=1`, `DISABLE_ERROR_REPORTING=1`, and `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`; subscription sessions are unchanged. See [docs/agent-configuration.md](src/docs/agent-configuration.md#methods-subscription-clis-vs-self-hosted-inference). +- **`dashboard/openapi.json` now documents the dashboard's actual write/action surface, not just a fraction of its reads** ([#4912](https://github.com/kubestellar/hive/issues/4912)). The spec was hand-maintained and had drifted badly: it covered 32 GET-only operations while the live Go dashboard server (`src/pkg/dashboard/`) registers 300 distinct `/api/*` operations across every HTTP method — every write/action endpoint (pause/resume/kick/restart, all governor config, per-agent config, knowledge base, nous, contribute/hive registration, inception, plan review, and more) was undocumented. The filed issue's "38 of 69" count came from diffing the spec against `dashboard/server.js`, a legacy Node prototype `dashboard/README.md` already states v2 production never starts — the real gap, measured against the actual registered routes, was 269 missing operations plus one stale entry (`GET /api/issue-costs`, no longer a real route) removed. All 261 in-scope operations (269 minus 8 documented exceptions — health/liveness probes, the legacy GitHub-PAT `/api/v1/` catch-all, the `/api/contribute/ws` WebSocket upgrade, the internal terminal-assertion cookie renewal, and the `/api/docs` HTML page) were added with parameters, request bodies, and response schemas derived by reading each handler directly; four new tags (`Contribute`, `Inception`, `Knowledge`, `Plan`) were added alongside the existing ones. A few deeply-nested or genuinely dynamic response fields are documented as untyped objects rather than guessed shapes — see the PR body for the specific handlers. A new `TestOpenAPISpecCoversEveryRegisteredRoute` test (`src/pkg/dashboard/openapi_route_parity_test.go`) parses every `s.mux.HandleFunc`/`s.mux.Handle` registration in the package with `go/ast` and fails if a registered `/api/*` route and the spec ever diverge again in either direction, with the exception set above expressed as a closed, documented list rather than a silent skip — the same shape as `TestShellAndGoCLIBackendListsAgree` (`src/pkg/config/backend_list_parity_test.go`). - **Go Security Analysis's `notice-drift` job no longer fails on every `v5` push.** `github.com/fumiama/go-docx`, used for read-only `.docx` text extraction in `src/pkg/knowledge/docparser.go`, is licensed AGPL-3.0 — classified FORBIDDEN by `go-licenses`, so the NOTICE generator aborted before writing anything and the job failed deterministically on every push to this branch. It is replaced with a stdlib-only implementation: `archive/zip` opens the `.docx` container and locates `word/document.xml`, and `encoding/xml` decodes the ``/``/``/`` shape, matched by local element name so the `w:` namespace prefix needs no special handling. Chunking, title derivation, and error handling are unchanged — malformed input (corrupt zip, missing `word/document.xml`, unparseable XML) still returns `nil, ""` exactly as before. `go mod tidy` removed `github.com/fumiama/go-docx` and its transitive `github.com/fumiama/imgsz` from `go.mod`/`go.sum`; no replacement dependency was added ([#5046](https://github.com/kubestellar/hive/issues/5046)). Ported from the equivalent `v4` fix ([#5016](https://github.com/kubestellar/hive/pull/5016)). @@ -243,6 +393,8 @@ Covers user-facing changes merged between 2026-08-11 and 2026-08-21 (the previou ### Added +- TUI client support for reading and applying the ACMM level (`GET`/`PUT /api/acmm`), part of the `hive tui` epic ([#5136](https://github.com/kubestellar/hive/issues/5136)). + - Convergence engine (default **off**): a new `convergence.mode` rollout knob (`off`/`shadow`/`enforce`) with admission diagnostics, canonical outcome identity and desired-generation status, exact-subject GitHub proof, selective proof invalidation, and a fenced, idempotent mutation journal ([#4356](https://github.com/kubestellar/hive/pull/4356), [#4362](https://github.com/kubestellar/hive/pull/4362), [#4366](https://github.com/kubestellar/hive/pull/4366), [#4372](https://github.com/kubestellar/hive/pull/4372), [#4382](https://github.com/kubestellar/hive/pull/4382), [#4388](https://github.com/kubestellar/hive/pull/4388), [#4389](https://github.com/kubestellar/hive/pull/4389)). Behavior is unchanged unless the mode is turned on. - Podman deployment path: host preflight checks (engine, root mode, cgroups, SELinux, mounts, secrets, ports, subordinate IDs), Quadlet units for the Hive service, gateway, network boundary, and data volume, a rootful/rootless × enforcing/advisory support matrix, digest-pinned manual update and rollback, and backup/restore plus Docker-to-Podman migration guidance ([#4284](https://github.com/kubestellar/hive/pull/4284), [#4286](https://github.com/kubestellar/hive/pull/4286), [#4307](https://github.com/kubestellar/hive/pull/4307), [#4340](https://github.com/kubestellar/hive/pull/4340), [#4342](https://github.com/kubestellar/hive/pull/4342), [#4355](https://github.com/kubestellar/hive/pull/4355), [#4380](https://github.com/kubestellar/hive/pull/4380), [#4383](https://github.com/kubestellar/hive/pull/4383), [#4385](https://github.com/kubestellar/hive/pull/4385), [#4407](https://github.com/kubestellar/hive/pull/4407), [#4409](https://github.com/kubestellar/hive/pull/4409)). - `just contribute-move`: a supported path to move a contributor relay to another machine by reissuing its credential, instead of hand-copying `contributor.env` ([#4418](https://github.com/kubestellar/hive/pull/4418)). @@ -274,6 +426,8 @@ Covers user-facing changes merged between 2026-08-11 and 2026-08-21 (the previou ### Added +- TUI client support for reading and applying the ACMM level (`GET`/`PUT /api/acmm`), part of the `hive tui` epic ([#5136](https://github.com/kubestellar/hive/issues/5136)). + - Contributor onboarding, governance, templates, and local development documentation for the `v2` Go codebase. - Timezone-aware time-of-day governor cadences so hives can vary agent activity by local operating windows. - Merger contributor tier and configurable automerge queue/label behavior for safer merge delegation. diff --git a/Justfile b/Justfile index 1bd3084e6..ac4a98f43 100644 --- a/Justfile +++ b/Justfile @@ -127,8 +127,17 @@ contribute-check-backend backend="claude": pi) if command -v pi &>/dev/null; then echo "Pi CLI detected ($(pi --version 2>&1 | head -1))" - echo " Supports: Anthropic, OpenAI, Google, Ollama, and more" - echo " Set provider: --provider anthropic --model claude-sonnet-4-6" + if [[ -z "${AGENT_MODEL:-}" ]]; then + echo "ERROR: Pi requires one canonical provider-qualified model." + echo " export AGENT_MODEL=anthropic/claude-sonnet-4-6" + exit 1 + fi + if ! PI_READINESS=$(node bin/pi-backend.js "${AGENT_MODEL}" 2>&1); then + echo "ERROR: ${PI_READINESS}" + exit 1 + fi + echo "HIVE_BACKEND_READINESS=${PI_READINESS}" + echo " Credential presence is reported as configured_unverified, never as proof of authentication." else echo "ERROR: Pi CLI not found. Install: curl -fsSL https://pi.dev/install.sh | sh" exit 1 @@ -162,21 +171,49 @@ contribute-check-backend backend="claude": echo " Models: gemini-3.6-flash, claude-sonnet-4-6, gpt-oss-120b, and more" echo " Set model: export AGENT_MODEL=gemini-3.6-flash-high" echo " Effort: export AGENT_REASONING_EFFORT=low|medium|high (agy needs --effort with --model)" - # agy signs in through an interactive Google OAuth flow (browser URL - # plus a pasted code) and offers no API-key mode, so run it on the - # HOST: a container cannot inherit the sign-in. Sign in once with a - # bare `agy` before starting the relay. - echo " Sign in once interactively (run: agy) — agy's Google OAuth cannot be" - echo " completed by an unattended container, so run this backend on the host:" - echo " just contribute-hive agy local" + # agy has NO OS-level sandbox of its own (config/backends.conf's "no + # confinement mechanism at all" list) — Container is the only mode + # with any host boundary, and is now possible: src/Dockerfile.contributor + # installs the agy binary (#5048; it did not before). agy signs in + # through an interactive Google OAuth flow with no API-key mode, so + # sign in once — either on the host first (this recipe stages a + # signed-in ~/.gemini into the container) or interactively inside the + # container itself. + echo " Recommended: sign in once (run: agy), then run this backend CONTAINERIZED:" + echo " just contribute-hive agy" + echo " Local mode has no sandbox for agy and REFUSES to launch unless you set" + echo " HIVE_AGY_DANGEROUSLY_RUN_UNCONFINED=1, which runs agy directly against your" + echo " host filesystem with no boundary at all — not recommended." else echo "ERROR: agy CLI not found. Install: https://antigravity.google/product/antigravity-cli" echo " Homebrew: brew install --cask antigravity-cli" exit 1 fi ;; + opencode) + if command -v opencode &>/dev/null; then + echo "opencode CLI detected ($(opencode --version 2>&1 | head -1))" + echo " Provider-agnostic (75+ providers); set model: export AGENT_MODEL=provider/model" + echo " Auth: run 'opencode auth login' — credential stored at ~/.local/share/opencode/auth.json" + echo " opencode only runs in headless mode (CONTRIBUTOR_MODE=headless): 'opencode run' is its" + echo " one-shot entry point and there is no interactive-tmux wiring for it." + else + echo "ERROR: opencode CLI not found. Install: https://opencode.ai/docs/" + exit 1 + fi + ;; + kilo) + if command -v kilo &>/dev/null; then + echo "Kilo CLI detected ($(kilo --version 2>&1 | head -1))" + echo " Headless only: kilo run --model provider/model --format json --auto" + echo " Set KILO_AUTH_CONTENT or KILO_API_KEY (optionally KILO_ORG_ID); do not mount Kilo config." + else + echo "ERROR: kilo CLI not found. Install @kilocode/cli: https://kilo.ai/docs/code-with-ai/platforms/cli" + exit 1 + fi + ;; *) - echo "ERROR: Unknown backend '{{backend}}'. Supported: claude, copilot, goose, codex, pi, bob, agy, litellm" + echo "ERROR: Unknown backend '{{backend}}'. Supported: claude, copilot, goose, codex, pi, bob, agy, litellm, opencode, kilo" exit 1 ;; esac @@ -763,9 +800,32 @@ contribute-hive backend="" mode="docker": check-version echo " then re-run: just contribute-hive bob" exit 1 fi + if [[ "$BACKEND" == "pi" ]]; then + if ! PI_READINESS=$(node bin/pi-backend.js "${AGENT_MODEL:-}" 2>&1); then + echo "ERROR: ${PI_READINESS}" + echo " export AGENT_MODEL=provider/model" + exit 1 + fi + echo "HIVE_BACKEND_READINESS=${PI_READINESS}" + # This recipe runs in its own shell, so narrowing the environment does not + # alter the contributor's login shell. It does ensure both local relay/CLI + # launches and the container path can see only the selected provider's + # official credential variables. + while IFS= read -r name; do + if [[ -n "$name" ]]; then unset "$name"; fi + done < <(node bin/pi-backend.js --unselected-env-names "${AGENT_MODEL}") + fi echo "=== Hive Contributor Agent (ClankeR) ===" echo "Backend: ${BACKEND}" - echo "Hub: {{hive_hub}}" + # The SOURCED value, not {{hive_hub}}. `hive_hub := env("HIVE_HUB", …)` is + # resolved by just at PARSE time, from the environment just itself was + # started with — but HIVE_HUB arrives only when this recipe sources + # contributor.env above. Interpolating {{hive_hub}} here therefore printed + # the built-in default on every machine whose hub comes from the config + # file, i.e. every hosted spoke, while the relay connected somewhere else + # entirely. Same shape as the ${_HUB:-{{hive_hub}}} fallback contribute-setup + # already uses. + echo "Hub: ${HIVE_HUB:-{{hive_hub}}}" echo "GitHub: $(gh api user --jq '.login' 2>/dev/null || echo 'authenticated')" echo "" @@ -796,49 +856,97 @@ contribute-hive backend="" mode="docker": check-version _local_truthy() { case "${1:-}" in 1|true|TRUE|yes|YES|on|ON) return 0 ;; *) return 1 ;; esac } - _LOCAL_WRITE_CONFINED=false + # Three postures, not two — an operator reading this banner needs to + # know which one they're actually getting: + # sandboxed — an OS-enforced filesystem boundary (claude/litellm's + # native sandbox, codex/copilot's own sandbox modes) + # denylisted — a command-name floor with NO filesystem boundary + # (opencode's permission.bash denials); real, but not a + # sandbox, and saying "confined" here would be exactly + # the overclaim #4918 is about + # unconfined — nothing at all unless the operator opted in + _LOCAL_POSTURE="unconfined" case "$BACKEND" in claude|litellm) if ! _local_truthy "${HIVE_CLAUDE_DANGEROUSLY_BYPASS_APPROVALS_AND_SANDBOX:-}"; then - _LOCAL_WRITE_CONFINED=true + _LOCAL_POSTURE="sandboxed" fi ;; codex) if ! _local_truthy "${HIVE_CODEX_DANGEROUSLY_BYPASS_APPROVALS_AND_SANDBOX:-}"; then - _LOCAL_WRITE_CONFINED=true + _LOCAL_POSTURE="sandboxed" + fi + ;; + copilot) + if ! _local_truthy "${HIVE_COPILOT_DANGEROUSLY_BYPASS_SANDBOX:-}" \ + && copilot --help 2>&1 | grep -qe '--sandbox'; then + _LOCAL_POSTURE="sandboxed" + fi + ;; + opencode) + if ! _local_truthy "${HIVE_OPENCODE_DANGEROUSLY_ALLOW_HOST_STATE:-}" \ + && command -v jq >/dev/null 2>&1; then + _LOCAL_POSTURE="denylisted" fi ;; esac - if [[ "$_LOCAL_WRITE_CONFINED" == "true" ]]; then - echo "🔒 LOCAL MODE — workspace write confinement is enabled for ${BACKEND}." - echo "" - echo " The CLI still runs as $(id -un) on this machine, but commands and" - echo " file edits may write only under the agent state directory and" - echo " ${HIVE_WORKSPACE_DIR:-$HOME/workspace}." - if [[ "$BACKEND" == "claude" || "$BACKEND" == "litellm" ]]; then - echo " Claude's native sandbox is mandatory: startup fails rather than" - echo " falling back unconfined when its OS sandbox is unavailable." - fi - echo "" - echo " Container mode remains the stronger backend-independent boundary:" - echo " just contribute-hive ${BACKEND}" - echo "" - else - echo "⚠️ LOCAL MODE — the agent is NOT confined to a workspace." - echo "" - echo " The backend CLI runs as $(id -un) on this machine, with permission" - echo " prompts bypassed. It can read and write anything your user can," - echo " including files outside ${HIVE_WORKSPACE_DIR:-$HOME/workspace}." - echo " Assigned repos are third-party code and their test suites run for real." - echo "" - echo " Still constrained: supported host-state commands are denied, and no" - echo " agent receives a GitHub token or pushes directly." - echo " NOT constrained: everything else your user can reach." - echo "" - echo " For a confined agent, drop 'local' and use container mode:" - echo " just contribute-hive ${BACKEND}" - echo "" - fi + case "$_LOCAL_POSTURE" in + sandboxed) + echo "🔒 LOCAL MODE — workspace write confinement is enabled for ${BACKEND}." + echo "" + echo " The CLI still runs as $(id -un) on this machine, but commands and" + echo " file edits may write only under the agent state directory and" + echo " ${HIVE_WORKSPACE_DIR:-$HOME/workspace}." + if [[ "$BACKEND" == "claude" || "$BACKEND" == "litellm" ]]; then + echo " Claude's native sandbox is mandatory: startup fails rather than" + echo " falling back unconfined when its OS sandbox is unavailable." + elif [[ "$BACKEND" == "copilot" ]]; then + echo " Copilot's own --sandbox flag (OS-enforced: Seatbelt on macOS," + echo " bubblewrap on Linux) provides the boundary." + fi + echo "" + echo " Container mode remains the stronger backend-independent boundary:" + echo " just contribute-hive ${BACKEND}" + echo "" + ;; + denylisted) + echo "🟡 LOCAL MODE — ${BACKEND} is NOT filesystem-confined, but named" + echo " host-state commands are denied." + echo "" + echo " opencode has no OS sandbox and no filesystem write-allowlist. The" + echo " same command family the claude deny-list covers (sudo, pkexec," + echo " rpm-ostree, bootc, ...) is denied via opencode's own permission" + echo " config, but this is a command-name floor, not a boundary: anything" + echo " not on that list, and anything reached another way, is unconstrained." + echo "" + echo " Container mode remains the stronger backend-independent boundary:" + echo " just contribute-hive ${BACKEND}" + echo "" + ;; + *) + echo "⚠️ LOCAL MODE — the agent is NOT confined to a workspace." + echo "" + echo " The backend CLI runs as $(id -un) on this machine, with permission" + echo " prompts bypassed. It can read and write anything your user can," + echo " including files outside ${HIVE_WORKSPACE_DIR:-$HOME/workspace}." + echo " Assigned repos are third-party code and their test suites run for real." + echo "" + if [[ "$BACKEND" == "claude" || "$BACKEND" == "litellm" || "$BACKEND" == "codex" || "$BACKEND" == "copilot" ]]; then + echo " Still constrained: supported host-state commands are denied, and no" + echo " agent receives a GitHub token or pushes directly." + echo " NOT constrained: everything else your user can reach." + else + echo " ${BACKEND} has no sandbox, filesystem allowlist, or command deny-list" + echo " hive can wire on this path — nothing stands between the agent and" + echo " anything your user can reach. No agent receives a GitHub token or" + echo " pushes directly, but that is the only guardrail left." + fi + echo "" + echo " For a confined agent, drop 'local' and use container mode:" + echo " just contribute-hive ${BACKEND}" + echo "" + ;; + esac TMUX_SESSION="hive-${BACKEND}-$(head -c 2 /dev/urandom | od -An -tx1 | tr -d ' ')" SCRIPT_DIR="$(pwd)/bin" RELAY="${SCRIPT_DIR}/contributor-relay.sh" @@ -901,6 +1009,24 @@ contribute-hive backend="" mode="docker": check-version claude|litellm) PERM_FLAG=$(claude_family_local_perm_flag_shell) ;; + copilot) + PERM_FLAG=$(copilot_local_perm_flag_shell) + ;; + opencode) + PERM_FLAG=$(opencode_local_perm_flag_shell) + ;; + codex) + PERM_FLAG=$(backend_perm_flag_shell "$BACKEND" 2>/dev/null || echo "") + ;; + goose|agy|bob|pi|aider|kilo) + # No sandbox, filesystem allowlist, or command deny-list exists for + # any of these six (see the "no confinement mechanism at all" + # block in backends.conf) — refuse to launch unconfined by + # default rather than silently grant full host access (#4918). + if ! PERM_FLAG=$(unconfined_local_perm_flag_shell "$BACKEND"); then + exit 1 + fi + ;; *) PERM_FLAG=$(backend_perm_flag_shell "$BACKEND" 2>/dev/null || echo "") ;; @@ -943,6 +1069,10 @@ contribute-hive backend="" mode="docker": check-version if [[ -n "${AGENT_MODEL:-}" ]]; then PERM_FLAG="${PERM_FLAG} --model ${AGENT_MODEL}" fi + elif [[ "$BACKEND" == "pi" ]]; then + # Same canonical selection used by the container entrypoint and every + # relay restart. %q keeps model IDs with shell metacharacters one argv. + PERM_FLAG="${PERM_FLAG:+${PERM_FLAG} }--model $(printf %q "$AGENT_MODEL")" fi # Create tmux session with the CLI. @@ -1114,6 +1244,26 @@ contribute-hive backend="" mode="docker": check-version cp -a "$src" "$dst" 2>/dev/null || true fi } + # claude_staged_credential_usable : can the container authenticate + # with the credential we just staged, without a human completing a login? + # + # Mirrors pkg/claude's ReadAccessToken rule — a claudeAiOauth block with a + # non-empty accessToken, not past its expiresAt — with ONE deliberate + # addition: an expired access token that still carries a refreshToken is + # treated as usable, because Claude Code refreshes it silently and no + # login prompt appears. Warning there would be crying wolf, and the + # refreshed token being discarded with the staging dir costs nothing, + # since the host's refreshToken still works on the next run. + # + # Without jq the check cannot run; stay silent rather than guess. Expiry is + # compared in milliseconds (what Claude Code writes) built from `date +%s` + # rather than %3N, which BSD/macOS date does not support. + claude_staged_credential_usable() { + local path="$1" + [ -f "$path" ] || return 1 + command -v jq >/dev/null 2>&1 || return 0 + jq -e --argjson now "$(( $(date +%s) * 1000 ))" '.claudeAiOauth as $o | (($o.accessToken // "") != "") and ((($o.expiresAt // 0) == 0) or (($o.expiresAt // 0) >= $now) or (($o.refreshToken // "") != ""))' "$path" >/dev/null 2>&1 + } CLI_MOUNTS="" case "${BACKEND}" in claude) @@ -1121,6 +1271,58 @@ contribute-hive backend="" mode="docker": check-version stage_copy "${HOME}/.config/claude-code" "claude-code" mkdir -p "${CLI_STAGE}/.claude" "${CLI_STAGE}/claude-code" CLI_MOUNTS="-v ${CLI_STAGE}/.claude:/home/dev/.claude${VOLSUF} -v ${CLI_STAGE}/claude-code:/home/dev/.config/claude-code${VOLSUF}" + # #5088: say so when the staged credential cannot authenticate. + # + # The container gets a COPY of ~/.claude in an ephemeral staging dir + # that the cleanup trap deletes on exit (see the H6/CWE-668 note + # above). That containment is deliberate and stays. What it also does, + # silently, is throw away a login performed INSIDE the container — so + # a contributor whose host credential has expired reaches the CLI's + # login menu, completes the whole browser flow, works for a session, + # and is back at the login menu on the next run with nothing to show + # for it. Reported in #5088 after exactly that sequence. + # + # Interactive: warn, and name the fix (log in on the HOST once, where + # the credential persists). Headless: fail, because there is no human + # to answer a login prompt and the pod would sit at it forever — + # #2538's "never wait silently" rule. + # ANTHROPIC_API_KEY is a complete alternative to the OAuth file: the + # provider-env block below forwards it into the container with -e, so a + # contributor authenticating that way needs no .credentials.json at all + # and must never be warned — let alone hard-failed in headless mode, + # which would refuse to start a run that would have worked. Checked here + # rather than at the forwarding site because the headless refusal exits + # long before that code is reached. + if [[ -z "${ANTHROPIC_API_KEY:-}" ]] && ! claude_staged_credential_usable "${CLI_STAGE}/.claude/.credentials.json"; then + if [[ "${CONTRIBUTOR_MODE:-}" == "headless" ]]; then + echo "ERROR: no usable Claude credential to stage into the container." >&2 + echo " A headless run has no way to complete a login prompt, so it would" >&2 + echo " sit at one indefinitely. Authenticate on this host first:" >&2 + echo "" >&2 + echo " claude # then /login, and quit once it reports you signed in" >&2 + echo "" >&2 + echo " Then re-run this command." >&2 + # The staging dir already holds a copy of ~/.claude, and the + # cleanup trap that would remove it is not registered until just + # before the container starts — exiting here without this rm would + # leave that credential copy sitting in /tmp indefinitely. + rm -rf "${CLI_STAGE}" + exit 1 + fi + echo "⚠ No usable Claude credential was staged into the container." + echo "" + echo " The CLI will come up at its login menu. You CAN log in there and it" + echo " will work — but only for this run: the container writes to a throwaway" + echo " copy of ~/.claude that is deleted when this command exits (#5088), so" + echo " the next run starts from the login menu again." + echo "" + echo " To log in once and keep it, quit this and run claude on the host:" + echo "" + echo " claude # then /login, and quit once it reports you signed in" + echo "" + echo " then re-run: just contribute-hive ${BACKEND}" + echo "" + fi ;; copilot) if [ -d "${HOME}/.copilot" ]; then @@ -1144,6 +1346,10 @@ contribute-hive backend="" mode="docker": check-version pi) if [ -d "${HOME}/.pi" ]; then stage_copy "${HOME}/.pi" ".pi" + # Keep only the selected provider's official auth/custom-provider + # entries in the ephemeral copy. The agent must not inherit keys for + # every provider merely because the host is signed into them. + node bin/pi-backend.js --stage "${AGENT_MODEL}" "${CLI_STAGE}/.pi" CLI_MOUNTS="-v ${CLI_STAGE}/.pi:/home/dev/.pi${VOLSUF}" fi # SECURITY (H6 / CWE-668) EXCEPTION: pi alone gets host networking. @@ -1167,21 +1373,73 @@ contribute-hive backend="" mode="docker": check-version # was a silent no-op. Stage whichever is present (legacy first-run # installs may still use the old path) so neither layout is dropped. # - # Staging state is NOT the same as staging a session: agy authenticates - # through an interactive Google OAuth flow and keeps no credential file - # under HOME that a container can inherit (verified on 1.1.13 — a clean - # container asks for a browser login regardless of what is mounted). - # The /contribute page therefore offers agy in HOST mode only. - if [ -d "${HOME}/.gemini/antigravity-cli" ]; then - stage_copy "${HOME}/.gemini/antigravity-cli" "antigravity-cli" - CLI_MOUNTS="-v ${CLI_STAGE}/antigravity-cli:/home/dev/.gemini/antigravity-cli${VOLSUF}" + # CORRECTION (#5048): an earlier version of this comment claimed agy + # "keeps no credential file under HOME that a container can inherit." + # That was wrong. agy DOES persist OAuth state under ${HOME}/.gemini — + # ${HOME}/.gemini/oauth_creds.json (with a refresh_token, not just a + # short-lived access_token) and ${HOME}/.gemini/google_accounts.json — + # but as SIBLINGS of antigravity-cli/, one level up from what this + # recipe staged. Staging only antigravity-cli/ mounted agy's state + # directory (conversations, cache, settings) while silently omitting + # both credential files, so a "clean container asks for a browser + # login regardless of what is mounted" was actually observing an + # incomplete mount, not an absence of inheritable credentials. Stage + # the whole ${HOME}/.gemini directory so the credential files travel + # alongside the state dir. + # + # This still does NOT make agy's headless/unattended container + # authentication a verified path: whether a mounted refresh_token + # actually re-authenticates a headless agy (vs. agy consulting an OS + # keyring/Secret Service in some auth modes — the binary links + # go-keyring) has not been confirmed end-to-end. Treat a mounted + # ${HOME}/.gemini as "gives agy in the container the best chance of + # inheriting a signed-in session," not as a guarantee. If the mount + # is insufficient, sign in interactively inside the container once + # (same `agy` interactive OAuth flow as on a host). + # + # H6 (CWE-668) is unaffected: this stages into the same ephemeral, + # 0700, cleanup_container-destroyed staging dir as every other + # backend below, not the host's real ${HOME}/.gemini. A poisoned + # agent still cannot write back to the host's real credentials. + if [ -d "${HOME}/.gemini" ]; then + stage_copy "${HOME}/.gemini" ".gemini" + CLI_MOUNTS="-v ${CLI_STAGE}/.gemini:/home/dev/.gemini${VOLSUF}" elif [ -d "${HOME}/.antigravitycli" ]; then stage_copy "${HOME}/.antigravitycli" ".antigravitycli" CLI_MOUNTS="-v ${CLI_STAGE}/.antigravitycli:/home/dev/.antigravitycli${VOLSUF}" fi ;; + opencode) + # opencode auth login writes a credential file (not an interactive + # per-session OAuth flow like agy), so it CAN inherit a signed-in + # session via a mount — stage it if present. + if [ -d "${HOME}/.local/share/opencode" ]; then + stage_copy "${HOME}/.local/share/opencode" "opencode" + CLI_MOUNTS="-v ${CLI_STAGE}/opencode:/home/dev/.local/share/opencode${VOLSUF}" + fi + ;; esac CONTAINER_NAME="hive-contributor-${BACKEND}-$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' ')" + # Pi receives ONLY the selected provider's official credential variables. + # A contributor may have keys for several providers in their shell; handing + # all of them to an unconfined agent would violate least privilege and make + # provider selection observable through unrelated secrets (#5039). + PROVIDER_ENV_ARGS=() + add_provider_env() { + local name="$1" + # Docker/Podman resolve a name-only --env from this process. The secret + # value therefore never appears in the runtime command's argv. + if [[ -n "${!name:-}" ]]; then PROVIDER_ENV_ARGS+=("-e" "${name}"); fi + } + if [[ "$BACKEND" == "pi" ]]; then + while IFS= read -r name; do + if [[ -n "$name" ]]; then add_provider_env "$name"; fi + done < <(node bin/pi-backend.js --env-names "${AGENT_MODEL}") + else + for name in ANTHROPIC_API_KEY OPENAI_API_KEY GOOGLE_API_KEY GOOSE_API_KEY GOOSE_PROVIDER GOOSE_MODEL BOBSHELL_API_KEY HIVE_LITELLM_ENDPOINT HIVE_LITELLM_API_KEY KILO_AUTH_CONTENT KILO_CONFIG_CONTENT KILO_API_KEY KILO_ORG_ID; do + add_provider_env "$name" + done + fi # NOTE: deliberately NOT --rm. With --rm the runtime deletes the # container the instant it exits, taking its logs with it — so a # container that dies during startup leaves nothing to diagnose @@ -1207,17 +1465,11 @@ contribute-hive backend="" mode="docker": check-version -e GH_TOKEN="${GH_TOKEN:-}" \ -e HIVE_USE_CONTRIBUTOR_GH=true \ -e HIVE_CONTAINER_NAME="${CONTAINER_NAME}" \ - ${ANTHROPIC_API_KEY:+-e ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}"} \ - ${GOOGLE_API_KEY:+-e GOOGLE_API_KEY="${GOOGLE_API_KEY}"} \ - ${GOOSE_API_KEY:+-e GOOSE_API_KEY="${GOOSE_API_KEY}"} \ - ${GOOSE_PROVIDER:+-e GOOSE_PROVIDER="${GOOSE_PROVIDER}"} \ - ${GOOSE_MODEL:+-e GOOSE_MODEL="${GOOSE_MODEL}"} \ - ${OPENAI_API_KEY:+-e OPENAI_API_KEY="${OPENAI_API_KEY}"} \ - ${BOBSHELL_API_KEY:+-e BOBSHELL_API_KEY="${BOBSHELL_API_KEY}"} \ - ${HIVE_LITELLM_ENDPOINT:+-e HIVE_LITELLM_ENDPOINT="${HIVE_LITELLM_ENDPOINT}"} \ - ${HIVE_LITELLM_API_KEY:+-e HIVE_LITELLM_API_KEY="${HIVE_LITELLM_API_KEY}"} \ + -e HIVE_CONTAINER_RUNTIME="${RUNTIME}" \ + "${PROVIDER_ENV_ARGS[@]}" \ ${AGENT_MODEL:+-e AGENT_MODEL="${AGENT_MODEL}"} \ ${AGENT_REASONING_EFFORT:+-e AGENT_REASONING_EFFORT="${AGENT_REASONING_EFFORT}"} \ + ${CONTRIBUTOR_MODE:+-e CONTRIBUTOR_MODE="${CONTRIBUTOR_MODE}"} \ {{hive_image}} > /dev/null echo "Container: ${CONTAINER_NAME}" @@ -1316,15 +1568,34 @@ contribute-hive backend="" mode="docker": check-version contribute-status: #!/usr/bin/env bash set -euo pipefail - HUB_HTTP=$(echo "{{hive_hub}}" | sed 's|^wss://|https://|;s|^ws://|http://|;s|/contribute$||') - echo "=== Hub Status ===" - curl -sf "${HUB_HTTP}/api/contribute/status" 2>/dev/null | jq . || echo "Hub unreachable at ${HUB_HTTP}" + # Resolve the hub from contributor.env BEFORE deriving any URL from it. + # {{hive_hub}} is just's PARSE-time value and cannot see the config file this + # recipe sources, so every query below used to go to the built-in default hub + # while reporting a CONTRIBUTOR_ID that only exists on the configured one — + # a guaranteed 404 ("Could not fetch profile") for anyone on a hosted spoke. + CONTRIBUTOR_ID="" if [[ -f "{{config_dir}}/contributor.env" ]]; then + # shellcheck source=/dev/null source "{{config_dir}}/contributor.env" - echo "" - echo "=== Your Profile ===" - curl -sf "${HUB_HTTP}/api/contributors/${CONTRIBUTOR_ID}" 2>/dev/null | jq . || echo "Could not fetch profile" fi + HIVE_HUB="${HIVE_HUB:-{{hive_hub}}}" + # HIVE_HUB and CONTRIBUTOR_ID are comma-separated and POSITION-ALIGNED + # (hub[i] ↔ id[i]) for a contributor registered with more than one hub — the + # same convention contributor.env documents and the relay already honors. Walk + # them together rather than reporting only the first. + IFS=',' read -r -a _HUBS <<< "${HIVE_HUB}" + IFS=',' read -r -a _IDS <<< "${CONTRIBUTOR_ID}" + for i in "${!_HUBS[@]}"; do + HUB_HTTP=$(echo "${_HUBS[$i]}" | sed 's|^wss://|https://|;s|^ws://|http://|;s|/contribute$||') + echo "=== Hub Status (${HUB_HTTP}) ===" + curl -sf "${HUB_HTTP}/api/contribute/status" 2>/dev/null | jq . || echo "Hub unreachable at ${HUB_HTTP}" + if [[ -n "${_IDS[$i]:-}" ]]; then + echo "" + echo "=== Your Profile (${_IDS[$i]}) ===" + curl -sf "${HUB_HTTP}/api/contributors/${_IDS[$i]}" 2>/dev/null | jq . || echo "Could not fetch profile" + fi + echo "" + done # Browse available Hive projects to contribute to contribute-browse: @@ -1471,6 +1742,99 @@ contribute-k8s namespace="hive-contributor" outfile="" image_tag="v4": printf '%s' "$1" | base64 | tr -d '\n' } + # ── Backend credential preflight (#5103) ── + # + # Before this existed the generated workload carried NO credential for the + # agent CLI it was told to run: the pod authenticated to the hub + # (HIVE_REGISTRATION_TOKEN) and to GitHub (GH_TOKEN), then launched a + # backend with nothing to authenticate WITH — it deployed cleanly, went + # Ready, accepted a task, and could do no work. All five allow-listed + # headless backends had the gap. + # + # Each backend below either contributes its credential material to the + # Secret, or the generation REFUSES with a message naming exactly what is + # missing — a refusal at generation beats a manifest that cannot work. + # HIVE_K8S_ALLOW_MISSING_BACKEND_CREDENTIALS=1 is the explicit escape hatch + # for an operator who supplies credentials out of band (their own Secret, + # an injector, a patched pod); it downgrades every refusal to a stderr + # warning, mirroring the unsupported-backend warning above. + # + # A backend that is not headless-capable at all skips this preflight: the + # warning above already says the pod cannot work, and failing it again over + # credentials would bury the real message. + CRED_YAML="" + add_cred() { CRED_YAML+=" $1: $2"$'\n'; } + CRED_MISSING="" + if [[ "$BACKEND_HEADLESS_OK" == true ]]; then + case "$BACKEND" in + claude) + # Two routes, explicit key first (operator intent beats a file that + # happens to exist): ANTHROPIC_API_KEY travels as itself and the CLI + # reads it natively; otherwise the operator's logged-in OAuth + # credential file travels base64-wrapped in one env var and the + # container entrypoint materializes it at ~/.claude/.credentials.json + # (bin/contributor-agent.sh). The pod refreshes tokens against its + # own ephemeral copy; the laptop's file is never written back. + if [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then + add_cred "ANTHROPIC_API_KEY" "$(b64 "${ANTHROPIC_API_KEY}")" + elif [[ -f "${HOME}/.claude/.credentials.json" ]]; then + add_cred "HIVE_CLAUDE_CREDENTIALS_B64" "$(b64 "$(base64 < "${HOME}/.claude/.credentials.json" | tr -d '\n')")" + else + CRED_MISSING="claude has no credential to ship: set ANTHROPIC_API_KEY in this shell, or log the CLI in once on this machine (run 'claude', then /login) so ~/.claude/.credentials.json exists." + fi + ;; + litellm) + # The entrypoint maps these to ANTHROPIC_BASE_URL/ANTHROPIC_API_KEY + # for the claude CLI (bin/contributor-agent.sh) — same wiring the + # laptop container path uses. The endpoint is persisted by setup; the + # key is env-only by design and must be present when generating. + if [[ -n "${HIVE_LITELLM_ENDPOINT:-}" && -n "${HIVE_LITELLM_API_KEY:-}" ]]; then + add_cred "HIVE_LITELLM_ENDPOINT" "$(b64 "${HIVE_LITELLM_ENDPOINT}")" + add_cred "HIVE_LITELLM_API_KEY" "$(b64 "${HIVE_LITELLM_API_KEY}")" + else + CRED_MISSING="litellm needs HIVE_LITELLM_ENDPOINT and HIVE_LITELLM_API_KEY set in this shell when generating." + fi + ;; + goose) + # The entrypoint writes ~/.config/goose/config.yaml from + # GOOSE_PROVIDER/GOOSE_MODEL if absent; goose reads GOOSE_API_KEY + # from the environment. A hosted provider without its key cannot + # work, so the key is required alongside the provider; the local + # ollama default that works on a laptop does not exist in a pod. + if [[ -n "${GOOSE_PROVIDER:-}" && -n "${GOOSE_API_KEY:-}" ]]; then + add_cred "GOOSE_PROVIDER" "$(b64 "${GOOSE_PROVIDER}")" + add_cred "GOOSE_API_KEY" "$(b64 "${GOOSE_API_KEY}")" + if [[ -n "${GOOSE_MODEL:-}" ]]; then add_cred "GOOSE_MODEL" "$(b64 "${GOOSE_MODEL}")"; fi + else + CRED_MISSING="goose needs GOOSE_PROVIDER and GOOSE_API_KEY set in this shell when generating (GOOSE_MODEL optional)." + fi + ;; + copilot|codex) + # Both authenticate through OAuth state directories (~/.copilot, + # ~/.codex) whose refresh/rewrite behavior inside an unattended pod + # is UNVERIFIED — shipping a mechanism that may sign the pod out + # mid-task would recreate this bug with extra steps. Refuse honestly + # and point at the paths that are verified. Plumbing these is + # tracked in kubestellar/hive#5103. + CRED_MISSING="${BACKEND} authenticates via an OAuth state directory whose behavior in an unattended pod is unverified (kubestellar/hive#5103); use 'just contribute-hive ${BACKEND}' (container) or a claude/litellm/goose pod instead." + ;; + esac + fi + if [[ -n "$CRED_MISSING" ]]; then + if [[ "${HIVE_K8S_ALLOW_MISSING_BACKEND_CREDENTIALS:-}" == "1" ]]; then + echo "WARNING: emitting a workload with NO ${BACKEND} credential (escape hatch set)." >&2 + echo " ${CRED_MISSING}" >&2 + echo " The pod will deploy, go Ready, accept a task, and be unable to run it" >&2 + echo " unless you provide the credential out of band." >&2 + else + echo "ERROR: ${CRED_MISSING}" >&2 + echo " Refusing to emit a workload whose agent CLI cannot authenticate" >&2 + echo " (kubestellar/hive#5103). Set HIVE_K8S_ALLOW_MISSING_BACKEND_CREDENTIALS=1" >&2 + echo " to emit anyway if you provide the credential out of band." >&2 + exit 1 + fi + fi + # ── Build the YAML ── REG_TOKEN_B64=$(b64 "${HIVE_REGISTRATION_TOKEN:-}") GH_TOKEN_B64=$(b64 "${GH_TOKEN:-}") @@ -1517,6 +1881,10 @@ contribute-k8s namespace="hive-contributor" outfile="" image_tag="v4": YAML+="data:"$'\n' YAML+=" HIVE_REGISTRATION_TOKEN: ${REG_TOKEN_B64}"$'\n' YAML+=" GH_TOKEN: ${GH_TOKEN_B64}"$'\n' + # Backend credential material from the preflight above (#5103): the agent + # CLI's own credential, delivered the same way as GH_TOKEN and covered by + # the same interim credential note on the Deployment below. + YAML+="${CRED_YAML}" # ── Probe command (#2660 status file) ── # The kubelet execs this against the pod. It reads the coarse lifecycle state @@ -1545,8 +1913,11 @@ contribute-k8s namespace="hive-contributor" outfile="" image_tag="v4": YAML+="# Kubernetes restarts it on failure and keeps a stable identity — the"$'\n' YAML+="# exact reason an operator wants a cluster over a laptop."$'\n' YAML+="#"$'\n' - YAML+="# INTERIM CREDENTIAL NOTE (#2537): the Secret above carries a long-lived,"$'\n' - YAML+="# personal GH_TOKEN (scope repo,read:org). In a cluster it is base64 (NOT"$'\n' + YAML+="# INTERIM CREDENTIAL NOTE (#2537, #5103): the Secret above carries a"$'\n' + YAML+="# long-lived, personal GH_TOKEN (scope repo,read:org) and, when the"$'\n' + YAML+="# selected backend requires one, that backend's own credential (an API"$'\n' + YAML+="# key, or a Claude OAuth credential file with a refresh token)."$'\n' + YAML+="# In a cluster these are base64 (NOT"$'\n' YAML+="# encrypted), readable by anyone with 'get secrets' in this namespace and"$'\n' YAML+="# by cluster-scoped operators/backups. This is materially more exposed"$'\n' YAML+="# than a 0600 file on a laptop. Revoke any time with: gh auth logout (or"$'\n' diff --git a/NOTICE b/NOTICE index eea1c0f09..c3c34bb87 100644 --- a/NOTICE +++ b/NOTICE @@ -28,4697 +28,4752 @@ release (see src/docs/releases.md, "Software bill of materials (SBOM)"). ----------------------------------------------------------------------------- Package: cel.dev/expr +Version: v0.25.2 License: Apache-2.0 Source: https://github.com/cel-expr/cel-spec/blob/v0.25.2/LICENSE - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. ----------------------------------------------------------------------------- Package: github.com/antlr4-go/antlr/v4 +Version: v4.13.1 License: BSD-3-Clause Source: https://github.com/antlr4-go/antlr/blob/v4.13.1/LICENSE - Copyright (c) 2012-2023 The ANTLR Project. All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions - are met: - - 1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - - 3. Neither name of copyright holders nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR - CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, - EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, - PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR - PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Copyright (c) 2012-2023 The ANTLR Project. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +3. Neither name of copyright holders nor the names of its contributors +may be used to endorse or promote products derived from this software +without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: github.com/aymanbagabas/go-osc52/v2 +Version: v2.0.1 License: MIT Source: https://github.com/aymanbagabas/go-osc52/blob/v2.0.1/LICENSE - MIT License - - Copyright (c) 2022 Ayman Bagabas - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. +MIT License + +Copyright (c) 2022 Ayman Bagabas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/cenkalti/backoff/v5 +Version: v5.0.3 License: MIT Source: https://github.com/cenkalti/backoff/blob/v5.0.3/LICENSE - The MIT License (MIT) - - Copyright (c) 2014 Cenk Altı - - Permission is hereby granted, free of charge, to any person obtaining a copy of - this software and associated documentation files (the "Software"), to deal in - the Software without restriction, including without limitation the rights to - use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - the Software, and to permit persons to whom the Software is furnished to do so, - subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +The MIT License (MIT) + +Copyright (c) 2014 Cenk Altı + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/cespare/xxhash/v2 +Version: v2.3.0 License: MIT Source: https://github.com/cespare/xxhash/blob/v2.3.0/LICENSE.txt - Copyright (c) 2016 Caleb Spare - - MIT License - - Permission is hereby granted, free of charge, to any person obtaining - a copy of this software and associated documentation files (the - "Software"), to deal in the Software without restriction, including - without limitation the rights to use, copy, modify, merge, publish, - distribute, sublicense, and/or sell copies of the Software, and to - permit persons to whom the Software is furnished to do so, subject to - the following conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE - LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION - OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Copyright (c) 2016 Caleb Spare + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/charmbracelet/bubbletea +Version: v1.3.10 License: MIT Source: https://github.com/charmbracelet/bubbletea/blob/v1.3.10/LICENSE - MIT License - - Copyright (c) 2020-2025 Charmbracelet, Inc - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. +MIT License + +Copyright (c) 2020-2025 Charmbracelet, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/charmbracelet/colorprofile +Version: v0.3.2 License: MIT Source: https://github.com/charmbracelet/colorprofile/blob/v0.3.2/LICENSE - MIT License - - Copyright (c) 2020-2024 Charmbracelet, Inc - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. +MIT License + +Copyright (c) 2020-2024 Charmbracelet, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/charmbracelet/lipgloss +Version: v1.1.0 License: MIT Source: https://github.com/charmbracelet/lipgloss/blob/v1.1.0/LICENSE - MIT License - - Copyright (c) 2021-2023 Charmbracelet, Inc - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. +MIT License + +Copyright (c) 2021-2023 Charmbracelet, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/charmbracelet/x/ansi +Version: v0.10.1 License: MIT Source: https://github.com/charmbracelet/x/blob/ansi/v0.10.1/ansi/LICENSE - MIT License - - Copyright (c) 2023 Charmbracelet, Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. +MIT License + +Copyright (c) 2023 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/charmbracelet/x/cellbuf +Version: v0.0.13-0.20250311204145-2c3ea96c31dd License: MIT Source: https://github.com/charmbracelet/x/blob/2c3ea96c31dd/cellbuf/LICENSE - MIT License - - Copyright (c) 2023 Charmbracelet, Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. +MIT License + +Copyright (c) 2023 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/charmbracelet/x/term +Version: v0.2.1 License: MIT Source: https://github.com/charmbracelet/x/blob/term/v0.2.1/term/LICENSE - MIT License - - Copyright (c) 2023 Charmbracelet, Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. +MIT License + +Copyright (c) 2023 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/fsnotify/fsnotify +Version: v1.10.1 License: BSD-3-Clause Source: https://github.com/fsnotify/fsnotify/blob/v1.10.1/LICENSE - Copyright © 2012 The Go Authors. All rights reserved. - Copyright © fsnotify Authors. All rights reserved. - - Redistribution and use in source and binary forms, with or without modification, - are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, this - list of conditions and the following disclaimer in the documentation and/or - other materials provided with the distribution. - * Neither the name of Google Inc. nor the names of its contributors may be used - to endorse or promote products derived from this software without specific - prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR - ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Copyright © 2012 The Go Authors. All rights reserved. +Copyright © fsnotify Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. +* Neither the name of Google Inc. nor the names of its contributors may be used + to endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: github.com/go-logr/logr +Version: v1.4.4 License: Apache-2.0 Source: https://github.com/go-logr/logr/blob/v1.4.4/LICENSE - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright {yyyy} {name of copyright owner} - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. ----------------------------------------------------------------------------- Package: github.com/go-logr/stdr +Version: v1.2.2 License: Apache-2.0 Source: https://github.com/go-logr/stdr/blob/v1.2.2/LICENSE - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. ----------------------------------------------------------------------------- Package: github.com/golang-jwt/jwt/v5 +Version: v5.3.1 License: MIT Source: https://github.com/golang-jwt/jwt/blob/v5.3.1/LICENSE - Copyright (c) 2012 Dave Grijalva - Copyright (c) 2021 golang-jwt maintainers - - Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - +Copyright (c) 2012 Dave Grijalva +Copyright (c) 2021 golang-jwt maintainers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ----------------------------------------------------------------------------- Package: github.com/google/cel-go +Version: v0.31.0 License: Apache-2.0 Source: https://github.com/google/cel-go/blob/v0.31.0/LICENSE - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - =========================================================================== - The common/types/pb/equal.go modification of proto.Equal logic - =========================================================================== - Copyright (c) 2018 The Go Authors. All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - * Neither the name of Google Inc. nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +=========================================================================== +The common/types/pb/equal.go modification of proto.Equal logic +=========================================================================== +Copyright (c) 2018 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: github.com/google/go-github/v72/github +Version: v72.0.0 License: BSD-3-Clause Source: https://github.com/google/go-github/blob/v72.0.0/LICENSE - Copyright (c) 2013 The go-github AUTHORS. All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - * Neither the name of Google Inc. nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Copyright (c) 2013 The go-github AUTHORS. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: github.com/google/go-querystring/query +Version: v1.1.0 License: BSD-3-Clause Source: https://github.com/google/go-querystring/blob/v1.1.0/LICENSE - Copyright (c) 2013 Google. All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - * Neither the name of Google Inc. nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Copyright (c) 2013 Google. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: github.com/google/uuid +Version: v1.6.0 License: BSD-3-Clause Source: https://github.com/google/uuid/blob/v1.6.0/LICENSE - Copyright (c) 2009,2014 Google Inc. All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - * Neither the name of Google Inc. nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Copyright (c) 2009,2014 Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: github.com/gorilla/websocket +Version: v1.5.3 License: BSD-2-Clause Source: https://github.com/gorilla/websocket/blob/v1.5.3/LICENSE - Copyright (c) 2013 The Gorilla WebSocket Authors. All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are met: - - Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - - Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE - FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER - CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, - OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Copyright (c) 2013 The Gorilla WebSocket Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: github.com/grpc-ecosystem/grpc-gateway/v2 +Version: v2.30.0 License: BSD-3-Clause -Source: https://github.com/grpc-ecosystem/grpc-gateway/blob/v2.29.0/LICENSE - - Copyright (c) 2015, Gengo, Inc. - All rights reserved. - - Redistribution and use in source and binary forms, with or without modification, - are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - * Neither the name of Gengo, Inc. nor the names of its - contributors may be used to endorse or promote products derived from this - software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR - ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Source: https://github.com/grpc-ecosystem/grpc-gateway/blob/v2.30.0/LICENSE + +Copyright (c) 2015, Gengo, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of Gengo, Inc. nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: github.com/ledongthuc/pdf +Version: v0.0.0-20250511090121-5959a4027728 License: BSD-3-Clause Source: https://github.com/ledongthuc/pdf/blob/5959a4027728/LICENSE - Copyright (c) 2009 The Go Authors. All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - * Neither the name of Google Inc. nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: github.com/lucasb-eyer/go-colorful +Version: v1.2.0 License: MIT Source: https://github.com/lucasb-eyer/go-colorful/blob/v1.2.0/LICENSE - Copyright (c) 2013 Lucas Beyer - - Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Copyright (c) 2013 Lucas Beyer + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/mattn/go-isatty +Version: v0.0.20 License: MIT Source: https://github.com/mattn/go-isatty/blob/v0.0.20/LICENSE - Copyright (c) Yasuhiro MATSUMOTO - - MIT License (Expat) - - Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Copyright (c) Yasuhiro MATSUMOTO + +MIT License (Expat) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/mattn/go-runewidth +Version: v0.0.16 License: MIT Source: https://github.com/mattn/go-runewidth/blob/v0.0.16/LICENSE - The MIT License (MIT) - - Copyright (c) 2016 Yasuhiro Matsumoto - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. +The MIT License (MIT) + +Copyright (c) 2016 Yasuhiro Matsumoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/muesli/ansi +Version: v0.0.0-20230316100256-276c6243b2f6 License: MIT Source: https://github.com/muesli/ansi/blob/276c6243b2f6/LICENSE - MIT License - - Copyright (c) 2021 Christian Muehlhaeuser - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. +MIT License + +Copyright (c) 2021 Christian Muehlhaeuser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/muesli/cancelreader +Version: v0.2.2 License: MIT Source: https://github.com/muesli/cancelreader/blob/v0.2.2/LICENSE - MIT License - - Copyright (c) 2022 Erik Geiser and Christian Muehlhaeuser - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. +MIT License + +Copyright (c) 2022 Erik Geiser and Christian Muehlhaeuser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/muesli/termenv +Version: v0.16.0 License: MIT Source: https://github.com/muesli/termenv/blob/v0.16.0/LICENSE - MIT License - - Copyright (c) 2019 Christian Muehlhaeuser - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. +MIT License + +Copyright (c) 2019 Christian Muehlhaeuser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/rivo/uniseg +Version: v0.4.7 License: MIT Source: https://github.com/rivo/uniseg/blob/v0.4.7/LICENSE.txt - MIT License - - Copyright (c) 2019 Oliver Kuederle - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. +MIT License + +Copyright (c) 2019 Oliver Kuederle + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/robfig/cron/v3 +Version: v3.0.1 License: MIT Source: https://github.com/robfig/cron/blob/v3.0.1/LICENSE - Copyright (C) 2012 Rob Figueiredo - All Rights Reserved. - - MIT LICENSE - - Permission is hereby granted, free of charge, to any person obtaining a copy of - this software and associated documentation files (the "Software"), to deal in - the Software without restriction, including without limitation the rights to - use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - the Software, and to permit persons to whom the Software is furnished to do so, - subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Copyright (C) 2012 Rob Figueiredo +All Rights Reserved. + +MIT LICENSE + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/skip2/go-qrcode +Version: v0.0.0-20200617195104-da1b6568686e License: MIT Source: https://github.com/skip2/go-qrcode/blob/da1b6568686e/LICENSE - Copyright (c) 2014 Tom Harwood - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - THE SOFTWARE. +Copyright (c) 2014 Tom Harwood + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. ----------------------------------------------------------------------------- Package: github.com/spf13/cobra +Version: v1.10.2 License: Apache-2.0 Source: https://github.com/spf13/cobra/blob/v1.10.2/LICENSE.txt - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. ----------------------------------------------------------------------------- Package: github.com/spf13/pflag +Version: v1.0.10 License: BSD-3-Clause Source: https://github.com/spf13/pflag/blob/v1.0.10/LICENSE - Copyright (c) 2012 Alex Ogier. All rights reserved. - Copyright (c) 2012 The Go Authors. All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - * Neither the name of Google Inc. nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Copyright (c) 2012 Alex Ogier. All rights reserved. +Copyright (c) 2012 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: github.com/xo/terminfo +Version: v0.0.0-20220910002029-abceb7e1c41e License: MIT Source: https://github.com/xo/terminfo/blob/abceb7e1c41e/LICENSE - The MIT License (MIT) - - Copyright (c) 2016 Anmol Sethi - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. +The MIT License (MIT) + +Copyright (c) 2016 Anmol Sethi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ----------------------------------------------------------------------------- Package: go.etcd.io/bbolt +Version: v1.5.0 License: MIT Source: https://github.com/etcd-io/bbolt/blob/v1.5.0/LICENSE - The MIT License (MIT) - - Copyright (c) 2013 Ben Johnson - - Permission is hereby granted, free of charge, to any person obtaining a copy of - this software and associated documentation files (the "Software"), to deal in - the Software without restriction, including without limitation the rights to - use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - the Software, and to permit persons to whom the Software is furnished to do so, - subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +The MIT License (MIT) + +Copyright (c) 2013 Ben Johnson + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----------------------------------------------------------------------------- Package: go.opentelemetry.io/auto/sdk +Version: v1.2.1 License: Apache-2.0 Source: https://github.com/open-telemetry/opentelemetry-go-instrumentation/blob/sdk/v1.2.1/sdk/LICENSE - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. ----------------------------------------------------------------------------- Package: go.opentelemetry.io/otel +Version: v1.46.0 License: Apache-2.0 -Source: https://github.com/open-telemetry/opentelemetry-go/blob/v1.45.0/LICENSE - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - -------------------------------------------------------------------------------- - - Copyright 2009 The Go Authors. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - * Neither the name of Google LLC nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Source: https://github.com/open-telemetry/opentelemetry-go/blob/v1.46.0/LICENSE + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +-------------------------------------------------------------------------------- + +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: go.opentelemetry.io/otel/exporters/otlp/otlptrace +Version: v1.46.0 License: Apache-2.0 -Source: https://github.com/open-telemetry/opentelemetry-go/blob/exporters/otlp/otlptrace/v1.45.0/exporters/otlp/otlptrace/LICENSE - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - -------------------------------------------------------------------------------- - - Copyright 2009 The Go Authors. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - * Neither the name of Google LLC nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Source: https://github.com/open-telemetry/opentelemetry-go/blob/exporters/otlp/otlptrace/v1.46.0/exporters/otlp/otlptrace/LICENSE + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +-------------------------------------------------------------------------------- + +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp +Version: v1.46.0 License: Apache-2.0 -Source: https://github.com/open-telemetry/opentelemetry-go/blob/exporters/otlp/otlptrace/otlptracehttp/v1.45.0/exporters/otlp/otlptrace/otlptracehttp/LICENSE - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - -------------------------------------------------------------------------------- - - Copyright 2009 The Go Authors. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - * Neither the name of Google LLC nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Source: https://github.com/open-telemetry/opentelemetry-go/blob/exporters/otlp/otlptrace/otlptracehttp/v1.46.0/exporters/otlp/otlptrace/otlptracehttp/LICENSE + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +-------------------------------------------------------------------------------- + +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: go.opentelemetry.io/otel/metric +Version: v1.46.0 License: Apache-2.0 -Source: https://github.com/open-telemetry/opentelemetry-go/blob/metric/v1.45.0/metric/LICENSE - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - -------------------------------------------------------------------------------- - - Copyright 2009 The Go Authors. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - * Neither the name of Google LLC nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Source: https://github.com/open-telemetry/opentelemetry-go/blob/metric/v1.46.0/metric/LICENSE + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +-------------------------------------------------------------------------------- + +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: go.opentelemetry.io/otel/sdk +Version: v1.46.0 License: Apache-2.0 -Source: https://github.com/open-telemetry/opentelemetry-go/blob/sdk/v1.45.0/sdk/LICENSE - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - -------------------------------------------------------------------------------- - - Copyright 2009 The Go Authors. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - * Neither the name of Google LLC nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Source: https://github.com/open-telemetry/opentelemetry-go/blob/sdk/v1.46.0/sdk/LICENSE + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +-------------------------------------------------------------------------------- + +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: go.opentelemetry.io/otel/trace +Version: v1.46.0 License: Apache-2.0 -Source: https://github.com/open-telemetry/opentelemetry-go/blob/trace/v1.45.0/trace/LICENSE - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - -------------------------------------------------------------------------------- - - Copyright 2009 The Go Authors. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - * Neither the name of Google LLC nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Source: https://github.com/open-telemetry/opentelemetry-go/blob/trace/v1.46.0/trace/LICENSE + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +-------------------------------------------------------------------------------- + +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: go.opentelemetry.io/proto/otlp +Version: v1.11.0 License: Apache-2.0 Source: https://github.com/open-telemetry/opentelemetry-proto-go/blob/otlp/v1.11.0/otlp/LICENSE - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. ----------------------------------------------------------------------------- Package: go.uber.org/automaxprocs +Version: v1.6.0 License: MIT Source: https://github.com/uber-go/automaxprocs/blob/v1.6.0/LICENSE - Copyright (c) 2017 Uber Technologies, Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - THE SOFTWARE. +Copyright (c) 2017 Uber Technologies, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. ----------------------------------------------------------------------------- Package: go.yaml.in/yaml/v3 +Version: v3.0.5 License: MIT -Source: https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE - - - This project is covered by two different licenses: MIT and Apache. - - #### MIT License #### - - The following files were ported to Go from C files of libyaml, and thus - are still covered by their original MIT license, with the additional - copyright staring in 2011 when the project was ported over: - - apic.go emitterc.go parserc.go readerc.go scannerc.go - writerc.go yamlh.go yamlprivateh.go - - Copyright (c) 2006-2010 Kirill Simonov - Copyright (c) 2006-2011 Kirill Simonov - - Permission is hereby granted, free of charge, to any person obtaining a copy of - this software and associated documentation files (the "Software"), to deal in - the Software without restriction, including without limitation the rights to - use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies - of the Software, and to permit persons to whom the Software is furnished to do - so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - - ### Apache License ### - - All the remaining project files are covered by the Apache license: - - Copyright (c) 2011-2019 Canonical Ltd - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +Source: https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE + + +This project is covered by two different licenses: MIT and Apache. + +#### MIT License #### + +The following files were ported to Go from C files of libyaml, and thus +are still covered by their original MIT license, with the additional +copyright staring in 2011 when the project was ported over: + + apic.go emitterc.go parserc.go readerc.go scannerc.go + writerc.go yamlh.go yamlprivateh.go + +Copyright (c) 2006-2010 Kirill Simonov +Copyright (c) 2006-2011 Kirill Simonov + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +### Apache License ### + +All the remaining project files are covered by the Apache license: + +Copyright (c) 2011-2019 Canonical Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. ----------------------------------------------------------------------------- Package: golang.org/x/exp +Version: v0.0.0-20240823005443-9b4947da3948 License: BSD-3-Clause Source: https://cs.opensource.google/go/x/exp/+/9b4947da:LICENSE - Copyright 2009 The Go Authors. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - * Neither the name of Google LLC nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: golang.org/x/net +Version: v0.58.0 License: BSD-3-Clause -Source: https://cs.opensource.google/go/x/net/+/v0.57.0:LICENSE - - Copyright 2009 The Go Authors. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - * Neither the name of Google LLC nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Source: https://cs.opensource.google/go/x/net/+/v0.58.0:LICENSE + +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: golang.org/x/sys/unix +Version: v0.47.0 License: BSD-3-Clause Source: https://cs.opensource.google/go/x/sys/+/v0.47.0:LICENSE - Copyright 2009 The Go Authors. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - * Neither the name of Google LLC nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: golang.org/x/text +Version: v0.41.0 License: BSD-3-Clause -Source: https://cs.opensource.google/go/x/text/+/v0.40.0:LICENSE - - Copyright 2009 The Go Authors. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - * Neither the name of Google LLC nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Source: https://cs.opensource.google/go/x/text/+/v0.41.0:LICENSE + +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: google.golang.org/genproto/googleapis/api +Version: v0.0.0-20260819154853-08b0e4226688 License: Apache-2.0 -Source: https://github.com/googleapis/go-genproto/blob/6ac0973c030d/googleapis/api/LICENSE - - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +Source: https://github.com/googleapis/go-genproto/blob/08b0e4226688/googleapis/api/LICENSE + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. ----------------------------------------------------------------------------- Package: google.golang.org/genproto/googleapis/rpc/status +Version: v0.0.0-20260819154853-08b0e4226688 License: Apache-2.0 -Source: https://github.com/googleapis/go-genproto/blob/6ac0973c030d/googleapis/rpc/LICENSE - - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +Source: https://github.com/googleapis/go-genproto/blob/08b0e4226688/googleapis/rpc/LICENSE + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. ----------------------------------------------------------------------------- Package: google.golang.org/grpc +Version: v1.83.1 License: Apache-2.0 -Source: https://github.com/grpc/grpc-go/blob/v1.83.0/LICENSE - - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +Source: https://github.com/grpc/grpc-go/blob/v1.83.1/LICENSE + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. ----------------------------------------------------------------------------- Package: google.golang.org/protobuf +Version: v1.36.12 License: BSD-3-Clause -Source: https://github.com/protocolbuffers/protobuf-go/blob/v1.36.11/LICENSE - - Copyright (c) 2018 The Go Authors. All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - * Neither the name of Google Inc. nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Source: https://github.com/protocolbuffers/protobuf-go/blob/v1.36.12/LICENSE + +Copyright (c) 2018 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: gopkg.in/natefinch/lumberjack.v2 +Version: v2.2.1 License: MIT Source: https://github.com/natefinch/lumberjack/blob/v2.2.1/LICENSE - The MIT License (MIT) - - Copyright (c) 2014 Nate Finch - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. +The MIT License (MIT) + +Copyright (c) 2014 Nate Finch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ----------------------------------------------------------------------------- Package: gopkg.in/yaml.v3 +Version: v3.0.1 License: MIT Source: https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE - - This project is covered by two different licenses: MIT and Apache. - - #### MIT License #### - - The following files were ported to Go from C files of libyaml, and thus - are still covered by their original MIT license, with the additional - copyright staring in 2011 when the project was ported over: - - apic.go emitterc.go parserc.go readerc.go scannerc.go - writerc.go yamlh.go yamlprivateh.go - - Copyright (c) 2006-2010 Kirill Simonov - Copyright (c) 2006-2011 Kirill Simonov - - Permission is hereby granted, free of charge, to any person obtaining a copy of - this software and associated documentation files (the "Software"), to deal in - the Software without restriction, including without limitation the rights to - use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies - of the Software, and to permit persons to whom the Software is furnished to do - so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - - ### Apache License ### - - All the remaining project files are covered by the Apache license: - - Copyright (c) 2011-2019 Canonical Ltd - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + +This project is covered by two different licenses: MIT and Apache. + +#### MIT License #### + +The following files were ported to Go from C files of libyaml, and thus +are still covered by their original MIT license, with the additional +copyright staring in 2011 when the project was ported over: + + apic.go emitterc.go parserc.go readerc.go scannerc.go + writerc.go yamlh.go yamlprivateh.go + +Copyright (c) 2006-2010 Kirill Simonov +Copyright (c) 2006-2011 Kirill Simonov + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +### Apache License ### + +All the remaining project files are covered by the Apache license: + +Copyright (c) 2011-2019 Canonical Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. ----------------------------------------------------------------------------- diff --git a/README.md b/README.md index 262520673..c603840b2 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,7 @@ Teardown: `bin/hive-podman-teardown.sh`. The [Hive Hub](https://hive.kubestellar.io) provides hosted hives with OAuth-protected dashboards, a public registry, and cross-hive leaderboards. No cluster required. -If you need to run your own private hub instead, see the v2 +If you need to run your own private hub instead, see the [self-hosted hub deployment guide](src/docs/hub-deployment.md). ### Self-Hosted Deployment @@ -412,7 +412,7 @@ kubectl apply -f src/deploy/k8s/service.yaml ## Configuration -All v2 runtime config lives in a single `hive.yaml`. Environment variables are interpolated with `${VAR}` syntax. See [src/hive.yaml.example](src/hive.yaml.example) for the full reference, [src/docs/env-vars.md](src/docs/env-vars.md) for the centralized environment variable reference, [src/docs/agent-configuration.md](src/docs/agent-configuration.md) for agent configuration, [src/AGENT-DEFINITION.md](src/AGENT-DEFINITION.md) for the portable agent YAML format, [src/docs/supervisor.md](src/docs/supervisor.md) for the supervisor agent, [docs/backend-setup.md](docs/backend-setup.md) for CLI backends, [docs/inference-backends.md](docs/inference-backends.md) for model gateways, [docs/migration-v1-v2.md](docs/migration-v1-v2.md) for v1→v2 migration, and [src/docs/migration-v2-v4.md](src/docs/migration-v2-v4.md) for upgrading a v2 deployment to v4. +All runtime config lives in a single `hive.yaml`. Environment variables are interpolated with `${VAR}` syntax. See [src/hive.yaml.example](src/hive.yaml.example) for the full reference, [src/docs/env-vars.md](src/docs/env-vars.md) for the centralized environment variable reference, [src/docs/agent-configuration.md](src/docs/agent-configuration.md) for agent configuration, [src/AGENT-DEFINITION.md](src/AGENT-DEFINITION.md) for the portable agent YAML format, [src/docs/supervisor.md](src/docs/supervisor.md) for the supervisor agent, [src/docs/telemetry.md](src/docs/telemetry.md) and [src/docs/operations.md](src/docs/operations.md) for the L5/L6-only opt-in observability and operational-readiness agents, [docs/backend-setup.md](docs/backend-setup.md) for CLI backends, [docs/inference-backends.md](docs/inference-backends.md) for model gateways, [docs/migration-v1-v2.md](docs/migration-v1-v2.md) for v1→v2 migration, and [src/docs/migration-v2-v4.md](src/docs/migration-v2-v4.md) for upgrading a v2 deployment to v4. The top-level deterministic shell pipeline uses a separate project file, `config/hive-project.yaml.example`; see [config/README.md](config/README.md) diff --git a/bin/README.md b/bin/README.md index 4197c7330..682edb3a2 100644 --- a/bin/README.md +++ b/bin/README.md @@ -13,6 +13,7 @@ Most production scripts are installed under `/usr/local/bin` by `bin/hive-deploy | `issue-classifier.sh` | Classifier | Enriches `actionable.json` with deterministic metadata such as complexity tier, model recommendation, tracker status, cluster key, lane, and architecture-review flag. | | `architecture-detector.sh` | Classifier | Adds architecture signals to actionable issues from `hive-project.yaml` rules so the classifier can route them to the architect lane. | | `pr-cluster-detector.sh` | Classifier | Groups related actionable issues into clusters using component, reporter timing, label-combo, and failure-mode signals. | +| `hive-baseline-check.sh` | Classifier | Compares one exact failing check with the repository's default branch and open sibling PRs, returning shared/isolated/unknown so agents do not retry a repository-wide incident per PR. | | `merge-gate.sh` | Gate | Writes `/var/run/hive-metrics/merge-eligible.json`; PRs qualify only when required CI passes, they are not drafts or excluded, and author/review policy allows merge. | | `conflict-sweeper.sh` | Gate/enforcer | Processes AI-authored PRs with `mergeable=CONFLICTING`, attempts a rebase, force-pushes clean rebases, or closes unrebasable PRs and reopens the original issue. | | `copilot-comment-checker.sh` | Monitor | Prefetches unaddressed Copilot review comments into `/var/run/hive-metrics/copilot-comments.json` for reviewer agents. | @@ -42,7 +43,9 @@ Most production scripts are installed under `/usr/local/bin` by `bin/hive-deploy | `gh-app-token.sh` | Credentials | Generates and caches (0600, hub-only) a GitHub App installation token; `--export` prints shell exports for callers; `--scoped [repos]` prints a JSON tier-scoped token for a contributor agent and never touches the shared cache. | | `git-credential-hive.sh` | Credentials | Git credential helper that serves cached GitHub App tokens and honors the host requested by Git. | | `gh-wrapper.sh` | Enforcement | `gh` wrapper that injects App tokens and enforces global/per-agent restriction rules from `/etc/hive/restrictions/.json`. | -| `hive-open-pr.sh` | Enforcement | Agent-side wrapper for PR creation requests. It writes a request file for the Hive watcher so PRs are opened by the GitHub App bot and pass the same ACMM authorization checks. | +| `hive-open-pr.sh` | Enforcement | Agent-side wrapper for PR creation requests. It writes a request file for the Hive watcher so PRs are opened by the GitHub App bot and pass the same ACMM authorization checks. See [`src/docs/hive-open-pr.md`](../src/docs/hive-open-pr.md). | +| `hive-open-issue.sh` | Enforcement | Agent-side wrapper for issue creation and comments. Agents call it INSTEAD of `gh issue create` / `gh issue comment`; it writes a request file for the Hive watcher so the work is attributed to the GitHub App bot and passes the same ACMM authorization checks. See [`src/docs/hive-open-issue.md`](../src/docs/hive-open-issue.md). | +| `hive-merge.sh` | Enforcement | Agent-side wrapper for merging a PR. Agents call it INSTEAD of the GitHub MCP `merge_pull_request` tool, so the merge is performed by the App bot under the same ACMM gating rather than with an agent's own credential. See [`src/docs/hive-merge.md`](../src/docs/hive-merge.md). | | `hive-review.sh` | Enforcement | Agent-side wrapper for `gh pr review`. Writes a review-request file the Hive submits with the App token and audits as `agent_pr_reviewed`, so PR-review activity is attributed and visible to hive-health (a direct `gh pr review` is invisible). | | `setup-proxy-iptables.sh` | Enforcement | Installs iptables rules in the container to force GitHub HTTPS traffic through the ACMM proxy even if an agent unsets proxy variables. | | `agent-env-scrub.sh` | Enforcement | Sourced (never executed) at the start of every shell in an agent's process tree, via `BASH_ENV`/`ENV` from `agent-launch.sh` and an `/etc/bash.bashrc` guard, to unset the live GitHub credentials backend CLIs re-export into agent tool shells (#4045). | @@ -62,6 +65,9 @@ Most production scripts are installed under `/usr/local/bin` by `bin/hive-deploy | `hive-podman-preflight.sh` | Bootstrap | Read-only Podman diagnostics before a lifecycle runs: engine/version, the connection it is actually talking to, rootless vs rootful, and cgroup version. Runs only when `HIVE_DEPLOY_RUNTIME` selects podman; `hive-prereq-check.sh` invokes it. | | `hive-podman-preflight-host.sh` | Deploy | Read-only Podman preflight for SELinux state and mount labeling, configuration/secrets readability, and published host-port availability. Runs only when `HIVE_DEPLOY_RUNTIME=podman`. See [`src/docs/podman-preflight-host.md`](../src/docs/podman-preflight-host.md). | | `hive-podman-preflight-ids.sh` | Deploy | Read-only Podman preflight for rootless subordinate UID/GID delegation, unsupported (NFS and other distributed) container storage, and the rootless network backend/helper. Never edits `/etc/subuid` or `/etc/subgid`. Runs only when `HIVE_DEPLOY_RUNTIME=podman`. See [`src/docs/podman-preflight-ids.md`](../src/docs/podman-preflight-ids.md). | +| `hive-podman-setup.sh` | Bootstrap | One-command standalone Podman install (#4470), the Podman counterpart to `hive-setup.sh`'s Docker path. | +| `hive-podman-update.sh` | Deploy | Deliberate manual update and rollback for the Hive Quadlet unit (#4378). Updates are explicit rather than automatic, per ADR-0017. | +| `hive-podman-lifecycle-probe.sh` | Deploy | Exercises the Quadlet lifecycle — stop, start, restart, recreate, and boot wiring (#4377) — to verify the unit behaves correctly across each transition. | | `federation-heartbeat.sh` | Federation | Sends live contributor and actionable-work stats to the Hive federation registry. | | `notify.sh` | Notifications | Shared Bash notification library for ntfy, Slack incoming webhooks, and Discord webhooks. | @@ -71,6 +77,7 @@ Most production scripts are installed under `/usr/local/bin` by `bin/hive-deploy |---|---|---| | `contributor-agent.sh` | Contributor runtime | Contributor-container entrypoint: detects authenticated CLI backend, starts the relay, launches the CLI in tmux, and creates `${HOME}/agent.md` only from a verified live knowledge export. | | `contributor-relay.sh` | Contributor runtime | Node.js WebSocket client for ClankeR contributor agents. It authenticates to one or more hubs, receives tasks, injects GitHub tokens, reports progress/results, and supports interactive tmux or headless one-shot delivery. | +| `pi-backend.js` | Contributor runtime | Pi contributor adapter contract (#5039). `AGENT_MODEL` is the one contributor-owned selection input; the adapter derives the provider's official credential variable names from it so only the selected provider's keys are handed to the container. | ## Model, token, and experiment helpers @@ -91,6 +98,16 @@ Most production scripts are installed under `/usr/local/bin` by `bin/hive-deploy | `contributor-agent.test.sh` | Contributor-agent regression for knowledge export handling. | | `contributor-relay.test.js` | Contributor relay task/restart/headless behavior; loads `contributor-relay.sh` as JavaScript with stubs. | | `gh-wrapper.test.sh` | `gh-wrapper.sh` author-gate and restriction regressions using a mock `gh` binary. | +| `test_agent_env_scrub.sh` | `agent-env-scrub.sh` (#4045): backend CLIs must not re-export live GitHub credentials into the tool shells they spawn. Behavioural plus source assertions. | +| `test_gh_auth_native_no_cat.sh` | The N14 (#3842) fix: a native/systemd-install agent kicked via `kick-agents.sh` — no Go AgentManager, no per-agent `HIVE_AGENT_TOKEN_CACHE` — still authenticates without leaking the token through `cat`. | +| `test_gh_wrapper_gates.sh` | `gh-wrapper.sh`'s enforcement gates. | +| `test_git_credential_hive.sh` | `git-credential-hive.sh` behaviour. | +| `test_hive_open_issue.sh` | `hive-open-issue.sh`, the agent issue-creation chokepoint. See [`src/docs/hive-open-issue.md`](../src/docs/hive-open-issue.md). | +| `test_hive_review.sh` | `hive-review.sh`, the agent PR-review chokepoint. | +| `test_hive_podman_setup.sh` | Contract tests for `hive-podman-setup.sh` (#4470). | +| `test_hive_podman_update.sh` | Contract tests for `hive-podman-update.sh` (#4378). | +| `test_hive_podman_lifecycle_probe.sh` | Contract tests for `hive-podman-lifecycle-probe.sh` (#4377). | +| `test_hive_baseline_check.sh` | Shared-CI classifier regressions for red default branches, exact-name sibling thresholds, reruns, pending checks, and fail-closed API errors. | | `test_bin_suites_wired.sh` | Fails when a test suite in this directory is not run by any workflow, Justfile target, or hook (#4363). | | `test_hive_standalone_runtime.sh` | `hive-standalone-runtime.sh` engine selection: Docker default, explicit Podman, and no silent fallback. | | `test_hive_podman_cleanup.sh` | `hive-podman-cleanup.sh` ownership labels and cleanup guard. Analyses arguments only: it contacts no container engine and deletes nothing. | diff --git a/bin/contributor-agent.sh b/bin/contributor-agent.sh index 66b63d6f6..0619c9994 100755 --- a/bin/contributor-agent.sh +++ b/bin/contributor-agent.sh @@ -144,6 +144,17 @@ GOOSECFG agy) ln -sf "$agent_md" "${HOME}/CLAUDE.md" ;; + opencode) + # opencode reads AGENTS.md (https://opencode.ai/docs/rules/). Keep the + # CLAUDE.md compatibility link too, matching codex/pi above. + ln -sf "$agent_md" "${HOME}/AGENTS.md" + ln -sf "$agent_md" "${HOME}/CLAUDE.md" + ;; + kilo) + # Kilo reads AGENTS.md-compatible project instructions. + ln -sf "$agent_md" "${HOME}/AGENTS.md" + ln -sf "$agent_md" "${HOME}/CLAUDE.md" + ;; *) ln -sf "$agent_md" "${HOME}/CLAUDE.md" ;; @@ -212,6 +223,29 @@ if [[ "$AGENT_BACKEND" == "litellm" ]]; then fi fi +# ── Materialize a delivered Claude credential (#5103) ── +# A K8s contributor pod cannot mount the operator's ~/.claude, so `just +# contribute-k8s` ships the operator's logged-in .credentials.json base64- +# wrapped in one Secret-backed env var. Written before anything launches the +# CLI, with the strict mode claude expects; decoded to a temp file first so a +# corrupt value leaves no half-written credential behind. The variable is +# unset afterwards — the relay and CLI children have no reason to inherit a +# second copy of the credential in their environment. +if [[ -n "${HIVE_CLAUDE_CREDENTIALS_B64:-}" ]]; then + mkdir -p "${HOME}/.claude" + _cred_tmp="$(mktemp "${HOME}/.claude/.credentials.json.tmp.XXXXXX")" + if printf '%s' "$HIVE_CLAUDE_CREDENTIALS_B64" | base64 -d > "$_cred_tmp" 2>/dev/null \ + && [[ -s "$_cred_tmp" ]]; then + chmod 600 "$_cred_tmp" + mv "$_cred_tmp" "${HOME}/.claude/.credentials.json" + echo "Claude credential materialized from the delivered secret." + else + rm -f "$_cred_tmp" + echo "WARNING: HIVE_CLAUDE_CREDENTIALS_B64 was set but did not decode to a non-empty file; ignoring it." >&2 + fi + unset HIVE_CLAUDE_CREDENTIALS_B64 _cred_tmp +fi + if [[ "${HIVE_CONTRIBUTOR_AGENT_TEST_RESOLVE_BACKEND:-}" == "1" ]]; then echo "backend_binary=$(backend_binary "$AGENT_BACKEND")" echo "backend_perm_flag=$(backend_perm_flag "$AGENT_BACKEND")" @@ -236,6 +270,15 @@ if [[ "${HIVE_CONTRIBUTOR_AGENT_TEST_LINK_KNOWLEDGE:-}" == "1" ]]; then exit 0 fi +validate_pi_selection() { + node "${SCRIPT_DIR}/pi-backend.js" "${AGENT_MODEL:-}" +} + +if [[ "${HIVE_CONTRIBUTOR_AGENT_TEST_PI_SELECTION:-}" == "1" ]]; then + validate_pi_selection + exit $? +fi + codex_auth_file() { local codex_home="${CODEX_HOME:-${HOME}/.codex}" echo "${codex_home}/auth.json" @@ -325,6 +368,13 @@ detect_cli() { agy) if agy --version &>/dev/null; then echo "OK"; else echo "NOT_AUTHED"; fi ;; + opencode) + if opencode --version &>/dev/null; then echo "OK"; else echo "NOT_AUTHED"; fi + ;; + kilo) + # Credentials are environment-only; never mount a whole Kilo home. + if kilo --version &>/dev/null; then echo "OK"; else echo "NOT_AUTHED"; fi + ;; *) echo "UNKNOWN" ;; @@ -341,6 +391,21 @@ echo "Hub: $HIVE_HUB" echo "Backend: $AGENT_BACKEND" echo "" +# Pi has no official PI_PROVIDER/PI_MODEL environment contract. Hive accepts +# exactly one contributor preference, AGENT_MODEL=provider/model, and Pi itself +# resolves that canonical token. Validate before the relay authenticates so a +# missing/malformed selection cannot claim ready and fail only after assignment. +if [[ "$AGENT_BACKEND" == "pi" ]]; then + if ! PI_SELECTION_JSON="$(validate_pi_selection)"; then + echo "ERROR: Pi requires AGENT_MODEL=provider/model (for example, openai/gpt-5)." + exit 1 + fi + echo "Pi selection: ${PI_SELECTION_JSON}" + while IFS= read -r name; do + if [[ -n "$name" ]]; then unset "$name"; fi + done < <(node "${SCRIPT_DIR}/pi-backend.js" --unselected-env-names "${AGENT_MODEL}") +fi + # Check CLI readiness STATUS=$(detect_cli "$AGENT_BACKEND") case "$STATUS" in @@ -364,7 +429,11 @@ case "$STATUS" in exit 1 ;; OK) - echo "$AGENT_BACKEND CLI detected and ready." + if [[ "$AGENT_BACKEND" == "pi" ]]; then + echo "pi CLI binary is present; authentication remains unverified until a request succeeds." + else + echo "$AGENT_BACKEND CLI detected and ready." + fi ;; esac @@ -542,7 +611,10 @@ fi # hub's ensureClaudeSettings pattern (src/pkg/agent/manager.go). The key is # stored both in full and as its last 20 chars — customApiKeyResponses # matching differs across Claude Code versions. -if [[ "$AGENT_BACKEND" == "litellm" ]]; then +# Also for claude driven by a delivered ANTHROPIC_API_KEY (#5103, the K8s +# contributor path): the CLI raises the same custom-API-key approval prompt, +# which nothing can answer in a headless pod. +if [[ "$AGENT_BACKEND" == "litellm" ]] || { [[ "$AGENT_BACKEND" == "claude" ]] && [[ -n "${ANTHROPIC_API_KEY:-}" ]]; }; then python3 - <<'PYEOF' 2>/dev/null || true import json, os p = os.path.join(os.path.expanduser('~'), '.claude.json') @@ -614,12 +686,21 @@ fi echo "" CONTAINER_NAME="${HIVE_CONTAINER_NAME:-hive-contributor}" +# The engine that launched this container, passed in by the `just contribute-hive` +# recipe from the runtime it resolved (kubestellar/hive#5145). A container cannot +# see its own launcher, so without this the attach hint below guessed "docker" and +# was simply wrong on every podman run — the operator pasted it and got a +# docker-socket permission error, or "no such container" if docker also happened to +# be running. Defaulting to docker keeps a bare-docker launch, or an image started +# by something older than the recipe that passes this, printing exactly what it +# printed before. +CONTAINER_RUNTIME="${HIVE_CONTAINER_RUNTIME:-docker}" echo "Contributor agent is running." echo " Mode: $CONTRIBUTOR_MODE" echo " CLI: $CMD" echo " ClankeR: PID $RELAY_PID" if [[ "$CONTRIBUTOR_MODE" == "interactive" ]]; then - echo " Tmux: docker exec -it $CONTAINER_NAME tmux attach -t $TMUX_SESSION" + echo " Tmux: $CONTAINER_RUNTIME exec -it $CONTAINER_NAME tmux attach -t $TMUX_SESSION" else # Headless: no pane to attach to. The relay drives a one-shot CLI per task and # writes its lifecycle state (waiting/working/done/failed) here for a probe. diff --git a/bin/contributor-agent.test.sh b/bin/contributor-agent.test.sh index 1012db128..405189a57 100755 --- a/bin/contributor-agent.test.sh +++ b/bin/contributor-agent.test.sh @@ -192,6 +192,46 @@ done echo "contributor-agent hook override tests passed" echo "contributor-agent knowledge fetch tests passed" +# Pi uses one shared provider/model parser for first launch and relay restarts. +# The test hook exits before any tmux/network setup, so these are deterministic +# startup-contract checks rather than a claim that a real provider authenticated. +pi_selection_output="$( + env -i \ + PATH="${PATH}" \ + HOME="$HOME_DIR" \ + OPENAI_API_KEY="synthetic-invalid-pi-key" \ + HIVE_REGISTRATION_TOKEN="test-token" \ + HIVE_CONTRIBUTOR_AGENT_TEST_PI_SELECTION=1 \ + AGENT_BACKEND=pi \ + AGENT_MODEL=openai/gpt-5 \ + bash "${ROOT_DIR}/bin/contributor-agent.sh" +)" +case "$pi_selection_output" in + *'"provider":"openai"'*'"model":"openai/gpt-5"'*'"authentication":"configured_unverified"'* ) ;; + *) + echo "expected canonical Pi selection/readiness JSON; got: $pi_selection_output" >&2 + exit 1 + ;; +esac +case "$pi_selection_output" in + *"synthetic-invalid-pi-key"* ) + echo "Pi readiness leaked a provider credential" >&2 + exit 1 + ;; +esac +if env -i \ + PATH="${PATH}" \ + HOME="$HOME_DIR" \ + HIVE_REGISTRATION_TOKEN="test-token" \ + HIVE_CONTRIBUTOR_AGENT_TEST_PI_SELECTION=1 \ + AGENT_BACKEND=pi \ + AGENT_MODEL=bare-model \ + bash "${ROOT_DIR}/bin/contributor-agent.sh" >/dev/null 2>&1; then + echo "expected Pi startup to reject an unqualified model" >&2 + exit 1 +fi +echo "contributor-agent Pi selection tests passed" + rm -f "${HOME_DIR}/CLAUDE.md" rm -rf "${HOME_DIR}/.bob" BOB_AGENT_MD="${HOME_DIR}/agent.md" diff --git a/bin/contributor-relay.sh b/bin/contributor-relay.sh index 0a9de4737..552d315fd 100755 --- a/bin/contributor-relay.sh +++ b/bin/contributor-relay.sh @@ -29,6 +29,11 @@ const WebSocket = require('ws'); const { execSync, execFile, execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); +const { + parsePiModelSelection, + redactPiCredentials, + piReadiness, +} = require('./pi-backend.js'); const rawHub = process.env.HIVE_HUB || 'wss://hive.kubestellar.io:3001/contribute'; // Multi-hub (kubestellar/hive#multi-hive): HIVE_HUB and HIVE_REGISTRATION_TOKEN @@ -44,7 +49,16 @@ if (rawHubList.length > 1 && rawTokenList.length !== rawHubList.length) { process.exit(1); } const BACKEND = process.env.AGENT_BACKEND || 'claude'; -const MODEL = process.env.AGENT_MODEL || process.env.GOOSE_MODEL || ''; +// GOOSE_MODEL is a Goose-only compatibility input. Letting it fall back for Pi +// made a restart silently select a Goose model the initial Pi launcher never +// requested (#5039). +const MODEL = process.env.AGENT_MODEL || (BACKEND === 'goose' ? process.env.GOOSE_MODEL : '') || ''; +const PI_SELECTION = parsePiModelSelection(MODEL); +// Process environment is immutable for a running container in normal use. Keep +// the startup view so readiness/redaction stays consistent across reconnects +// (and so a later test/process mutation cannot change the declared contract). +const PI_ENV = BACKEND === 'pi' ? { ...process.env } : {}; +let piInvocationState = 'untested'; const REASONING_EFFORT = process.env.AGENT_REASONING_EFFORT || ''; const AGENT_ROLE = (process.env.HIVE_AGENT_ROLE || '').trim(); // Neutral directory both entrypoints launch the CLI from ($HOME). Used to pin @@ -118,6 +132,90 @@ const HEADLESS_STATE_FAILED = 'failed'; // last task failed (non-zero/spawn er const PANE_STATE_WORKING = 'WORKING'; const PANE_STATE_BLOCKED_ON_HUMAN = 'BLOCKED_ON_HUMAN'; const PANE_STATE_IDLE_COMPLETE = 'IDLE_COMPLETE'; +// A retryable API failure left the CLI parked at its idle prompt with the +// response truncated (kubestellar/hive#5094). This is NOT completion and NOT a +// stall: the turn ended, but it ended in an error, and the same request can +// succeed on a retry. +const PANE_STATE_TRANSIENT_API_ERROR = 'TRANSIENT_API_ERROR'; +// An API failure a retry CANNOT clear — an authorization refusal or an exhausted +// quota — left the CLI parked at its idle prompt. Also not completion: the turn +// ended having shipped nothing. Retrying it would loop the agent against a wall, +// so this is failed at once rather than nudged. +const PANE_STATE_FATAL_API_ERROR = 'FATAL_API_ERROR'; +// An API failure matching NEITHER curated list ended the turn at the idle +// prompt (kubestellar/hive#5121). Still not completion: the turn shipped +// nothing. Nobody can say from a pattern table whether a retry clears it, so +// it takes the bounded transient path — if it was retryable the retry wins, +// and if not the budget runs out and the task is handed back as an honest +// environment failure. Either way, never a fabricated completion. +const PANE_STATE_UNKNOWN_API_ERROR = 'UNKNOWN_API_ERROR'; + +// ── Transient API-error recovery (kubestellar/hive#5094) ───────────────────── +// +// THE DEFECT: Claude Code prints a turn-duration summary ("✻ Cogitated for +// 9m 24s") whenever a turn ENDS — including when it ends in an API error — and +// classifyTmuxPane's claude branch matched exactly that line as its completion +// marker. An errored turn was therefore indistinguishable from a finished one, +// so the relay reported task_complete for work that shipped nothing. Observed +// live: issue #5061 was picked up at 11:46:38 and booked "completed" at +// 11:57:40 with no PR, its half-written work still uncommitted in the tree. +// +// These patterns mirror src/pkg/agent/manager.go's transientAPIErrorPatterns +// (#4697), which the hub's own fleet has used for this same error since. Keep +// the two lists in step. Membership is deliberately narrow: every entry must be +// an error where REPEATING THE SAME REQUEST CAN SUCCEED. +const TRANSIENT_API_ERROR_PATTERNS = [ + 'connection lost mid-response', + 'connection error', + 'request timed out', + 'overloaded_error', +]; +// 500/502/503/529 are retryable upstream failures. Whole tokens only, so a +// request id or token count under the same "API Error:" chrome cannot trip it. +const TRANSIENT_API_ERROR_STATUS_RE = /\b(?:500|502|503|529)\b/; +// Errors a retry CANNOT fix. Claude Code renders every API failure under the +// same "API Error:" prefix, so a substring match alone cannot tell an +// overloaded upstream from a refused one — these are re-checked separately and +// veto the retry, exactly as the hub path does via +// lineShowsUpstreamAuthorizationError / paneShowsQuotaExhausted. Nudging one of +// these loops the agent against a wall and burns tokens to no effect. +const UNRETRYABLE_API_ERROR_PATTERNS = [ + 'not allowed to access model', + 'team not allowed to access', + 'exceeded your monthly quota', + 'used all your copilot free chat requests', + 'budget_exceeded', + 'budget has been exceeded', + 'provider spending limit reached', + 'refused the request on a spending limit', + 'gone over your budget allowance', + 'bobcoins', +]; +// 403 is authorization, not authentication: the caller IS identified and is not +// permitted, so neither a retry nor a login changes anything (#4400). +const UNRETRYABLE_API_ERROR_STATUS_RE = /\bAPI Error: 403\b/i; +// The visible tail the error must appear in. Matching the whole pane would let +// an error the agent already recovered from read as current. +const TRANSIENT_API_ERROR_TAIL_LINES = 12; +// What we type. Short and free of shell metacharacters by construction — it is +// interpolated into a tmux send-keys command line below. +const TRANSIENT_API_ERROR_NUDGE_MESSAGE = 'try again'; +// Bounded so a persistent upstream failure ends as an honest task failure +// rather than an infinite typing loop. Mirrors the hub's cap and cooldown. +const TRANSIENT_API_ERROR_MAX_NUDGES = 3; +const TRANSIENT_API_ERROR_NUDGE_COOLDOWN_MS = 90000; + +// What the relay types at an unattended pane that stopped to ask a question +// (kubestellar/hive#5281). The task prompts already tell agents to decide for +// themselves — an agent that stops to ask is one that forgot, and a human +// watching would type exactly this line. When nobody is watching, nobody does. +// +// Letters, spaces and one comma, by construction: tmuxSendNudge interpolates +// this into a single-quoted `tmux send-keys -l '...'`, so a quote or a shell +// metacharacter here would be a command-injection shaped bug rather than a +// typo. There is a test pinning that. +const AUTONOMY_NUDGE_MESSAGE = + 'no human is available to answer, so proceed autonomously with your best judgment'; // Cap on captured child output kept in memory / sent to the hub, so a chatty // CLI cannot grow the buffer without bound. The tail is what matters for an @@ -131,13 +229,47 @@ const PROGRESS_REPORT_INTERVAL_MS = 120000; const MAX_RECONNECT_DELAY_MS = 60000; const BASE_RECONNECT_DELAY_MS = 1000; const TOKEN_REFRESH_MARGIN_MS = 300000; +// MAX_TASK_DURATION_MS is a PROGRESS lease, not a wall-clock budget +// (kubestellar/hive#5321). It bounds how long a task may go without the relay +// observing forward progress; every tick that sees new pane output re-arms it +// from now. It is NOT "the longest a task may take". +// +// It used to be exactly that, and the result was a bug: the timer was armed +// once in startProgressReporting() and never re-armed, so a task was killed at +// a flat 30 minutes however hard the agent was working. Observed live on +// 2026-08-31 it killed an agent that had already committed and pushed and was +// blocked on a green `go test` run — the hub booked the task `failed` 57 +// seconds before that task's PR (#5320) was opened, and returned the issue to +// the failure cooldown. Any task whose honest duration exceeds this bound was +// not slow, it was impossible. +// +// The hang case the wall was nominally there for is covered — better — by +// PANE_STALL_TIMEOUT_MS, which fails a frozen pane in 20 minutes and confirms +// the verdict over multiple ticks. What remains here is a coarser second +// opinion on the same question, kept because it is armed from the timer wheel +// rather than from the tick loop and so still fires if the tick loop itself +// dies. const MAX_TASK_DURATION_MS = 1800000; + +// ABSOLUTE_TASK_DEADLINE_MS is the backstop the progress lease deliberately +// does not provide: a ceiling on total elapsed time from task assignment, +// re-armed by nothing. A task that produces output forever (a retry loop +// redrawing a spinner is output) would otherwise hold its lease indefinitely. +// +// Set far above the working range — the point is to bound the pathological +// case, not to second-guess a long one. Crossing it is a statement about this +// runtime, not about the agent's work, so it is reported as an `environment` +// failure (see the failCurrentTask contract). +const ABSOLUTE_TASK_DEADLINE_MS = Number(process.env.HIVE_ABSOLUTE_TASK_DEADLINE_MS) || 4 * 60 * 60 * 1000; + // Hard ceiling on a single headless one-shot invocation (kubestellar/hive#2538). -// The interactive path bounds a task with MAX_TASK_DURATION_MS via a -// tmux-scraping watchdog; the headless child gets the SAME bound enforced -// directly on the process, so a wedged CLI is killed and reported failed rather -// than hanging the pod forever. -const HEADLESS_TASK_TIMEOUT_MS = MAX_TASK_DURATION_MS; +// The interactive path has no pane to scrape for progress on the headless path, +// so a headless child cannot use the progress lease above: there is no +// equivalent signal. It gets the ABSOLUTE bound enforced directly on the +// process instead, so a wedged CLI is killed and reported failed rather than +// hanging the pod forever — and, per #5321, a long-but-live headless run is no +// longer killed at 30 minutes either. +const HEADLESS_TASK_TIMEOUT_MS = Number(process.env.HIVE_HEADLESS_TASK_TIMEOUT_MS) || ABSOLUTE_TASK_DEADLINE_MS; const NETWORK_ERROR_RETRY_DELAY_MS = 5000; // After the hub sends an explicit task_unavailable negative-ack (no admissible // work, a disabled tier, a concurrency limit, or a token-mint failure — see @@ -273,6 +405,27 @@ function injectGhToken(token) { const CLI_READY_POLL_MS = 2000; const CLI_READY_TIMEOUT_MS = 600000; const CONTAINER_NAME = process.env.HIVE_CONTAINER_NAME || 'hive-contributor'; +// ATTACH_COMMAND is the paste-able command that puts a human on the CLI's tmux +// pane. It is computed once, here, because it is printed at the one moment a +// wrong answer really costs: the "needs authentication" banner fires when the +// agent is BLOCKED and a person must intervene, so a command that fails is +// worse than no command at all (kubestellar/hive#5145). +// +// Two facts the relay cannot infer and so is told: +// +// * HIVE_CONTAINER_NAME is set ONLY by the container arm of the +// `just contribute-hive` recipe. Local mode runs this relay directly on the +// host, beside the tmux server it drives — there is no container to exec +// into, and the hint is plain `tmux attach`, which is what the recipe's own +// status line four lines earlier already says. +// * HIVE_CONTAINER_RUNTIME carries the engine the recipe resolved. A +// container cannot see its own launcher, so hardcoding "docker" handed +// every podman operator a command that fails. It defaults to docker, so a +// bare-docker launch prints exactly what it printed before. +const CONTAINER_RUNTIME = process.env.HIVE_CONTAINER_RUNTIME || 'docker'; +const ATTACH_COMMAND = process.env.HIVE_CONTAINER_NAME + ? `${CONTAINER_RUNTIME} exec -it ${CONTAINER_NAME} tmux attach -t ${TMUX_SESSION}` + : `tmux attach -t ${TMUX_SESSION}`; // detectCapabilities builds the OPTIONAL, client-declared capability object the // relay reports in auth_response (kubestellar/hive#2547, declare half). Every @@ -316,6 +469,7 @@ function detectCapabilities() { // operator could not see. Best-effort: omitted entirely when the probe fails. const cliVersion = detectAgentCLIVersion(); if (cliVersion) caps.agent_cli_version = cliVersion; + if (BACKEND === 'pi') Object.assign(caps, piReadiness(PI_SELECTION, !!cliVersion, piInvocationState, PI_ENV)); cachedCapabilities = caps; return caps; } @@ -486,9 +640,32 @@ function resolveBackend() { // effectiveReasoningEffort() below, which must agree on whether a model is in // play — agy's effort is conditional on exactly that. function modelFlagFor() { + if (BACKEND === 'pi' && !PI_SELECTION.valid) throw new Error(PI_SELECTION.error); return MODEL && !NO_MODEL_FLAG_BACKENDS.includes(BACKEND) ? `--model ${MODEL}` : ''; } +function effectiveProvider() { + return BACKEND === 'pi' && PI_SELECTION.valid ? PI_SELECTION.provider : ''; +} + +// Receipt fields are bounded selections, never credentials. Provider is +// transported canonically inside model; the separate field is evidence for +// local status/receipts, not another input or authority source. +function effectiveSelectionFields() { + const out = { cli_backend: BACKEND }; + const model = effectiveModel(); + const provider = effectiveProvider(); + if (provider) out.provider = provider; + if (model) out.model = model; + return out; +} + +function setPiInvocationState(state) { + if (BACKEND !== 'pi') return; + piInvocationState = state; + if (cachedCapabilities) Object.assign(cachedCapabilities, piReadiness(PI_SELECTION, !!cachedCapabilities.agent_cli_version, state, PI_ENV)); +} + // effectiveReasoningEffort is the SINGLE source of truth for the effort actually // in effect for this launch — the value the CLI is really running with, not the // value the contributor happened to export. @@ -734,7 +911,7 @@ function buildLaunchCommand() { // prompt is appended as the final, distinct argv element. // // Backends NOT listed here have no known non-interactive entry point (bob / -// pi drive an interactive TUI), so headless mode refuses them LOUDLY at +// aider drive an interactive TUI), so headless mode refuses them LOUDLY at // task time rather than silently stalling. Extending this table is how a // future PR adds a backend once its headless invocation is verified. const HEADLESS_BACKENDS = { @@ -757,6 +934,10 @@ const HEADLESS_BACKENDS = { // that `run`, `-t` and `--no-session` all exist and that a failed run exits // non-zero, which is the exit-code contract runHeadlessTask() relies on. goose: { flag: ['run', '--no-session', '-t'] }, + // pi --print --mode json — Pi's bounded non-interactive entry point. + // AGENT_MODEL is already the canonical provider/model token, so no separate + // --provider input is needed (or allowed) and restart/headless stay identical. + pi: { flag: ['--print', '--mode', 'json'] }, // agy -p "" — Antigravity's print mode ("Run a single prompt // non-interactively and print the response", `agy --help`). Verified against // agy 1.1.13: a print-mode run answers on stdout and exits 0, which is the @@ -767,6 +948,16 @@ const HEADLESS_BACKENDS = { // K8S_HEADLESS_BACKENDS on the /contribute page and out of the contributor // image. The capability and the credential are separate questions. agy: { flag: '-p' }, + // opencode run "" — opencode's one-shot headless invocation + // (kubestellar/hive#4970). Unlike agy, opencode is the ONLY launch mode + // this backend gets: there is no interactive-tmux wiring for it (see the + // getCLIState()/classifyTmuxPane() backend lists below, which opencode + // deliberately does not join), so it is only ever reached through + // CONTRIBUTOR_MODE=headless. `opencode run` exits with a real status code + // on completion, the exit-code contract runHeadlessTask() relies on. + opencode: { flag: 'run' }, + // Kilo is OpenCode-derived but uses distinct credentials and config. + kilo: { flag: 'run' }, }; // headlessSupportsBackend reports whether the configured backend has a known @@ -784,6 +975,7 @@ function headlessSupportsBackend() { function buildHeadlessArgv(prompt) { const spec = HEADLESS_BACKENDS[BACKEND]; if (!spec) return null; + if (BACKEND === 'pi' && !PI_SELECTION.valid) throw new Error(PI_SELECTION.error); const { cmd, perm } = resolveBackend(); const permArgs = perm ? perm.split(/\s+/).filter(Boolean) : []; const modelArgs = MODEL && !NO_MODEL_FLAG_BACKENDS.includes(BACKEND) ? ['--model', MODEL] : []; @@ -806,6 +998,8 @@ function writeHeadlessStatus(state, extra) { const payload = Object.assign({ mode: MODE_HEADLESS, backend: BACKEND, + ...effectiveSelectionFields(), + ...(BACKEND === 'pi' ? piReadiness(PI_SELECTION, !!detectCapabilities().agent_cli_version, piInvocationState, PI_ENV) : {}), state, updated_at: new Date().toISOString(), }, extra || {}); @@ -837,10 +1031,19 @@ function runHeadlessTask(task) { return; } - const { bin, args } = buildHeadlessArgv(prompt); + let built; + try { + built = buildHeadlessArgv(prompt); + } catch (e) { + const reason = e.message; + writeHeadlessStatus(HEADLESS_STATE_FAILED, { task_id: task.task_id, task_gen: task.task_gen, result: 'failed', reason }); + failCurrentTask(reason, { permanent: true, kind: 'environment' }); + return; + } + const { bin, args } = built; console.log(`Headless: running ${bin} (one-shot) for ${task.repo}#${task.number}`); - writeHeadlessStatus(HEADLESS_STATE_WORKING, { task_id: task.task_id, repo: task.repo, number: task.number }); - send({ type: 'task_progress', seq: nextSeq(), task_id: task.task_id, task_gen: task.task_gen, kind: task.kind, repo: task.repo, number: task.number, title: task.title, status: 'working' }); + writeHeadlessStatus(HEADLESS_STATE_WORKING, { task_id: task.task_id, task_gen: task.task_gen, repo: task.repo, number: task.number, result: 'working' }); + send({ type: 'task_progress', seq: nextSeq(), task_id: task.task_id, task_gen: task.task_gen, kind: task.kind, repo: task.repo, number: task.number, title: task.title, status: 'working', ...effectiveSelectionFields() }); let settled = false; const finish = (fn) => { if (settled) return; settled = true; fn(); }; @@ -855,6 +1058,13 @@ function runHeadlessTask(task) { // Tokens can appear in agent output; redact before the tail leaves the host. const outTail = redactTokens(String(stdout || '') + String(stderr || '')) .split('\n').slice(-TMUX_TAIL_LINES); + // A revoke clears currentTask before killing the child. Ignore any callback + // that arrives afterwards — including a raced exit 0 — so stale work cannot + // emit completion after its assignment generation was fenced out. + if (!currentTask || currentTask.task_id !== task.task_id || currentTask.task_gen !== task.task_gen) { + writeHeadlessStatus(HEADLESS_STATE_WAITING, { revoked_task_id: task.task_id }); + return; + } if (err) { // A non-zero exit, a spawn failure (ENOENT), or the timeout kill all land // here. err.killed && err.signal signals the timeout; report a real @@ -870,13 +1080,18 @@ function runHeadlessTask(task) { ? `headless task exceeded ${HEADLESS_TASK_TIMEOUT_MS / 60000}min and was killed` : `headless CLI exited with error: ${err.code !== undefined ? `code ${err.code}` : err.message}${diagnosticSuffix}`; finish(() => { + setPiInvocationState('failed'); console.error(`Headless task ${task.task_id} failed: ${reason}`); - writeHeadlessStatus(HEADLESS_STATE_FAILED, { task_id: task.task_id, reason }); - failCurrentTask(reason, { permanent: false }); + writeHeadlessStatus(HEADLESS_STATE_FAILED, { task_id: task.task_id, task_gen: task.task_gen, result: 'failed', reason }); + failCurrentTask(reason, { + permanent: false, + kind: BACKEND === 'pi' ? 'environment' : undefined, + }); }); return; } finish(() => { + setPiInvocationState('succeeded'); console.log(`Headless task ${task.task_id} completed (exit 0)`); const prURL = detectPRURL(outTail, task.repo); if (prURL) console.log(`Detected PR for ${task.task_id}: ${prURL}`); @@ -885,8 +1100,13 @@ function runHeadlessTask(task) { // the claim with "shipped" anyway). const noWork = prURL ? null : detectNoWorkVerdict(outTail); if (noWork) console.log(`Detected no_work_needed verdict for ${task.task_id}: ${noWork.reason || '(no reason)'}`); - writeHeadlessStatus(HEADLESS_STATE_DONE, { task_id: task.task_id, pr_url: prURL }); - send({ type: 'task_complete', seq: nextSeq(), task_id: task.task_id, task_gen: task.task_gen, result: 'completed', summary: 'Headless one-shot invocation exited 0', tmux_output: outTail, pr_url: prURL, verdict: noWork ? noWork.verdict : undefined, verdict_reason: noWork ? noWork.reason : undefined }); + writeHeadlessStatus(HEADLESS_STATE_DONE, { task_id: task.task_id, task_gen: task.task_gen, result: 'completed', pr_url: prURL }); + // #5353: the one-shot child has already exited (this callback is its + // exit), so there is no process to stop — but the task-scoped token it + // was given stays valid for the rest of wsTokenTTL. Drop it with the + // task, so a credential never outlives the assignment it belongs to. + stopAgentForTaskExit(); + send({ type: 'task_complete', seq: nextSeq(), task_id: task.task_id, task_gen: task.task_gen, result: 'completed', summary: 'Headless one-shot invocation exited 0', tmux_output: outTail, pr_url: prURL, verdict: noWork ? noWork.verdict : undefined, verdict_reason: noWork ? noWork.reason : undefined, ...effectiveSelectionFields() }); currentTask = null; taskAssignedAt = 0; tasksCompletedCount++; @@ -1135,7 +1355,7 @@ function waitForCLI() { console.log('╔══════════════════════════════════════════════════════════╗'); console.log('║ Claude Code needs authentication. ║'); console.log('║ In another terminal, run: ║'); - console.log(`║ docker exec -it ${CONTAINER_NAME} tmux attach -t ${TMUX_SESSION}`); + console.log(`║ ${ATTACH_COMMAND}`); console.log('║ Then type: /login ║'); console.log('║ Complete the login, then press Ctrl-B D to detach. ║'); console.log('║ Waiting for login to complete... ║'); @@ -1158,6 +1378,8 @@ let pendingTask = null; // Used so the eventual recovery re-advertises availability to the hub, which // we deliberately withheld at failure time (see armCLIReadyWait). let cliReadyFailed = false; +// Set only by an interactive revoke. The next ready is delayed until a fresh CLI is confirmed. +let readyAfterInteractiveRevoke = false; if (CONTRIBUTOR_MODE === MODE_HEADLESS) { // Headless mode has no tmux pane to scrape for readiness. Each task spawns @@ -1196,9 +1418,14 @@ if (CONTRIBUTOR_MODE === MODE_HEADLESS) { // churn one task per timeout window forever. function armCLIReadyWait() { const hadFailed = cliReadyFailed; + const becameReadyAfterRevoke = readyAfterInteractiveRevoke; waitForCLI().then(() => { cliReady = true; cliReadyFailed = false; + if (becameReadyAfterRevoke) { + readyAfterInteractiveRevoke = false; + send({ type: 'ready', seq: nextSeq() }); + } // Only re-advertise if we previously withdrew by failing a task; the normal // startup path is already advertised by the auth_ok handler. if (hadFailed) send({ type: 'ready', seq: nextSeq() }); @@ -1212,7 +1439,11 @@ function armCLIReadyWait() { pendingTask = null; if (currentTask) { // environment: the agent CLI never reached its prompt on this host. - failCurrentTask(`CLI never became ready: ${e.message}`, { skipReady: true, kind: 'environment' }); + // skipCLI: this IS the relaunch path — armCLIReadyWait() re-arms itself + // below and the pane already has a launch in flight. Quitting and + // relaunching from here would nest a second launch inside the first + // (#5353). The credential is still dropped by failCurrentTask. + failCurrentTask(`CLI never became ready: ${e.message}`, { skipReady: true, skipCLI: true, kind: 'environment' }); } // Keep waiting. The CLI may still come up (a slow login, an operator // attaching to clear a prompt we don't recognize), and when it does the @@ -1382,11 +1613,16 @@ function redactTokens(text) { // {36,} not {36}: GitHub documents that token length may grow, and an exact // bound would redact only the first 36 characters of a longer token, leaking // its tail into the hub log line (kubestellar/hive#4267). - return text.replace(/gho_[A-Za-z0-9]{36,}/g, 'gho_***REDACTED***') + const githubRedacted = text.replace(/gho_[A-Za-z0-9]{36,}/g, 'gho_***REDACTED***') .replace(/ghp_[A-Za-z0-9]{36,}/g, 'ghp_***REDACTED***') .replace(/ghs_[A-Za-z0-9]{36,}/g, 'ghs_***REDACTED***') .replace(/ghu_[A-Za-z0-9]{36,}/g, 'ghu_***REDACTED***') - .replace(/ghr_[A-Za-z0-9]{36,}/g, 'ghr_***REDACTED***'); + .replace(/ghr_[A-Za-z0-9]{36,}/g, 'ghr_***REDACTED***') + // Fine-grained PATs: github_pat_ + 82 chars of [A-Za-z0-9_]. The Go-side + // redactors (dashboard, status_builder, prompt_history) already scrub this + // prefix; the relay must match or PAT material leaks into hub log lines. + .replace(/github_pat_[A-Za-z0-9_]{36,}/g, 'github_pat_***REDACTED***'); + return BACKEND === 'pi' ? redactPiCredentials(githubRedacted, PI_SELECTION, PI_ENV) : githubRedacted; } function captureTmuxLines(n) { @@ -1429,42 +1665,93 @@ function detectPRURL(lines, repo) { return repoMatch || anyMatch; } -// Best-effort scan of the agent's recent output for the no_work_needed -// sentinel (kubestellar/hive#3987). The hub's task prompt instructs the agent: -// when it affirmatively determines there is NOTHING shippable (the remainder -// is gated on an unanswered maintainer decision, or merged PRs already cover -// it), it prints a line of the exact form -// HIVE_VERDICT: no_work_needed — -// instead of opening a PR. Reported on task_complete as verdict/verdict_reason -// so the hub can park the issue for the long offer-suppression window instead -// of re-offering it every short-cooldown period forever (the #2547 shape that -// escalation only bounded). Returns null when no marker is found — the hub -// then treats the completion exactly as an idle one (today's semantics). The -// marker spelling must stay in sync with buildTaskPrompt in +// ── The HIVE_VERDICT: sentinel family (kubestellar/hive#3987, #5376) ───────── +// +// The hub's task prompt asks the agent to end a task by printing ONE line of +// the exact form +// +// HIVE_VERDICT: +// +// Two verdicts are defined: +// +// no_work_needed (#3987) — the agent affirmatively determined there is +// NOTHING shippable (the remainder is gated on an unanswered maintainer +// decision, or merged PRs already cover it). Reported on task_complete as +// verdict/verdict_reason so the hub parks the issue for the long +// offer-suppression window instead of re-offering it every short-cooldown +// period forever (the #2547 shape that escalation only bounded). +// +// complete (#5376) — the agent is DONE with the task, whatever it +// shipped. This is the completion signal the interactive relay lacked: +// before it, "is this task done" was inferred from the vendor's terminal +// rendering (see classifyTmuxPane), which produced thirteen separate +// issues (#1566, #4026, #4064, #4067, #4078, #4080, #4128, #4182, #4265, +// #5094, #5121, #5156, #5162) as one CLI after another restyled its +// chrome. Chrome is a vendor's cosmetic output; this line is the agent's +// own statement. Only the second is a contract. +// +// Both are parsed by ONE anchored, echo-guarded scanner below, deliberately: +// the anti-false-positive handling is the hard-won part and there must not be +// a second copy of it to drift. +// +// The marker spelling must stay in sync with buildTaskPrompt in // src/pkg/dashboard/contribute_ws.go. -function detectNoWorkVerdict(lines) { +const HIVE_VERDICT_NO_WORK = 'no_work_needed'; +const HIVE_VERDICT_COMPLETE = 'complete'; + +// detectHiveVerdict scans `lines` newest-first for any of `wanted` (an array of +// verdict tokens) and returns { verdict, reason } for the first — i.e. the +// LAST-printed — match, or null. +// +// Returns null rather than throwing on junk input: every caller is on a +// best-effort path reading a terminal capture that may be empty. +function detectHiveVerdict(lines, wanted) { if (!Array.isArray(lines) || lines.length === 0) return null; + if (!Array.isArray(wanted) || wanted.length === 0) return null; // Anchored at line start: the task PROMPT quotes the marker mid-sentence // ("...the exact form 'HIVE_VERDICT: ...'"), and an anchored match keeps // that instruction echo from reading as the agent's own verdict. Codex // renders its completed assistant messages with a leading bullet, which is // presentation chrome rather than part of the verdict. - const VERDICT_RE = /^\s*(?:•\s*)?HIVE_VERDICT:\s*no_work_needed\b[\s:—–-]*(.*)$/i; + // + // The verdict token is an alternation of exactly the wanted tokens with a \b + // after it, so "no_work_neededX" and "completely rewrote the parser" are both + // non-matches — a prose line that merely STARTS with a verdict word must not + // become a verdict. + const alt = wanted.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); + const VERDICT_RE = new RegExp(`^\\s*(?:•\\s*)?HIVE_VERDICT:\\s*(${alt})\\b[\\s:—–-]*(.*)$`, 'i'); // Scan newest-first so the agent's final conclusion wins over anything it // merely quoted or considered earlier in the transcript. for (let i = lines.length - 1; i >= 0; i--) { const m = VERDICT_RE.exec(lines[i]); if (!m) continue; - const reason = (m[1] || '').trim(); + const reason = (m[2] || '').trim(); // tmux may wrap the prompt's instruction so its quoted marker lands at a // visual line start; its giveaway is the literal "" // placeholder. Never treat that echo as a real verdict. if (reason.startsWith('<')) continue; - return { verdict: 'no_work_needed', reason }; + return { verdict: m[1].toLowerCase(), reason }; } return null; } +// Best-effort scan for the no_work_needed sentinel. Unchanged in behaviour +// from #3987/#4265; it now shares the scanner above. Returns null when no +// marker is found — the hub then treats the completion exactly as an idle one. +function detectNoWorkVerdict(lines) { + return detectHiveVerdict(lines, [HIVE_VERDICT_NO_WORK]); +} + +// detectCompletionVerdict reports whether the agent SAID it finished (#5376). +// +// Either verdict counts as "the agent declared this task over": no_work_needed +// is a completion too — it is the agent concluding the task with nothing to +// ship — and requiring a second `complete` line after it would make a +// compliant agent look non-compliant. +function detectCompletionVerdict(lines) { + return detectHiveVerdict(lines, [HIVE_VERDICT_COMPLETE, HIVE_VERDICT_NO_WORK]); +} + // True while a bob CLI process is alive. bob exits at the end of every turn, // so "process gone" means the turn finished — see the bob branch of // checkTmuxIdle(). Matches the launch command rather than the bare name so a @@ -1498,9 +1785,49 @@ function recentPaneLines(text, limit = 12) { .slice(-limit); } -function paneLooksBlockedOnHuman(text) { +// Why a blocked pane is blocked (kubestellar/hive#5281). BLOCKED_ON_HUMAN +// conflates two populations, and only one of them can be helped without a +// person: +// +// question — a plain "?", a y/N, an elicitation form. The agent forgot +// its standing instruction to decide for itself, and a +// one-line reminder is usually all it takes. +// menu — a numbered menu. Deliberately NOT nudge-eligible: a menu +// TUI may read typed text as a selection filter rather than +// as chat input, so covering it properly needs Escape +// handling this does not attempt. +// human-required — login, credential entry, trust/consent, permission. Only +// a person can answer these, and typing at them is actively +// harmful. +const BLOCKED_REASON_QUESTION = 'question'; +const BLOCKED_REASON_MENU = 'menu'; +const BLOCKED_REASON_HUMAN_REQUIRED = 'human-required'; + +// The confirmation half of the old blockingPatterns list: prompts an agent +// working autonomously is entitled to answer for itself. +const QUESTION_BLOCKING_PATTERNS = [ + /\[[Yy]\/[Nn]\]|\([Yy]\/[Nn]\)|\b[Yy]es\/[Nn]o\b/, + /\b(?:continue|proceed|confirm|approve|allow|deny|accept|reject|choose|select)\b.*\?/i, + /\bPress Enter to continue\b/i, + /\bEnter to confirm\b/i, +]; + +// The other half: prompts where a person is the only possible answer. Kept as +// its own list because it is a veto, not a detector — see +// classifyBlockedOnHumanReason. +const HUMAN_REQUIRED_BLOCKING_PATTERNS = [ + /\b(?:approval|consent|trust this folder|Do you trust|Confirm folder trust)\b/i, + /\bpermission\b.*\b(?:allow|approve|confirm|continue|proceed)\b/i, + /\b(?:allow|approve|confirm|continue|proceed)\b.*\bpermission\b/i, + /\b(?:Allow|Approve|Run|Execute)\b.*\b(?:command|tool|edit|file|operation)\b/i, + /\b(?:Paste|Enter).*(?:API key|token|code|password)\b/i, +]; + +// classifyBlockedOnHumanReason returns one of the BLOCKED_REASON_* constants, +// or null when the pane is not blocked at all. +function classifyBlockedOnHumanReason(text) { const lines = recentPaneLines(text); - if (lines.length === 0) return false; + if (lines.length === 0) return null; const recent = lines.join('\n'); const last = lines[lines.length - 1]; const beforePrompt = [...lines].reverse().find(line => @@ -1541,31 +1868,241 @@ function paneLooksBlockedOnHuman(text) { /\bElicitation request timed out\b/i.test(recent) || /\bTimeout waiting for user response\b/i.test(recent); const hasElicitationForm = (hasInputRequestLeadIn && hasFormStructure) || hasElicitationMarker; - const blockingPatterns = [ - // Confirmation prompts and TUI continuation screens. - /\[[Yy]\/[Nn]\]|\([Yy]\/[Nn]\)|\b[Yy]es\/[Nn]o\b/, - /\b(?:continue|proceed|confirm|approve|allow|deny|accept|reject|choose|select)\b.*\?/i, - /\bPress Enter to continue\b/i, - /\bEnter to confirm\b/i, - // Permission/auth/onboarding prompts seen from Claude/Copilot/Goose/Bob. - /\b(?:approval|consent|trust this folder|Do you trust|Confirm folder trust)\b/i, - /\bpermission\b.*\b(?:allow|approve|confirm|continue|proceed)\b/i, - /\b(?:allow|approve|confirm|continue|proceed)\b.*\bpermission\b/i, - /\b(?:Allow|Approve|Run|Execute)\b.*\b(?:command|tool|edit|file|operation)\b/i, - /\b(?:Paste|Enter).*(?:API key|token|code|password)\b/i, - ]; - - return hasQuestion || hasNumberedMenu || hasElicitationForm || blockingPatterns.some(re => re.test(beforePrompt)); + const blockingPatterns = [...QUESTION_BLOCKING_PATTERNS, ...HUMAN_REQUIRED_BLOCKING_PATTERNS]; + + const blocked = hasQuestion || hasNumberedMenu || hasElicitationForm || + blockingPatterns.some(re => re.test(beforePrompt)); + if (!blocked) return null; + + // Human-required WINS over every other signal, and is asked of the whole + // recent window rather than just the line above the prompt (#5281). A trust + // dialog or a credential request often renders its heading a few lines up + // while the cursor line is a bare "Do you want to proceed?" — classifying + // that as an ordinary question is exactly the mistake that would type an + // autonomy reminder into a /login flow or submit it as a password. + // + // Widening the window can only move a pane from question to human-required, + // never make an unblocked pane blocked: `blocked` above is computed exactly + // as it always was. When in doubt, human-required — waiting costs 30 minutes, + // a wrong nudge costs a credential prompt answered with prose. + if (HUMAN_REQUIRED_BLOCKING_PATTERNS.some(re => re.test(recent))) { + return BLOCKED_REASON_HUMAN_REQUIRED; + } + if (hasNumberedMenu) return BLOCKED_REASON_MENU; + return BLOCKED_REASON_QUESTION; +} + +// paneLooksBlockedOnHuman is the original boolean, now derived from the +// classifier so there is exactly one definition of "blocked". Its answer is +// unchanged: classifyBlockedOnHumanReason returns non-null for precisely the +// panes this used to return true for. +function paneLooksBlockedOnHuman(text) { + return classifyBlockedOnHumanReason(text) !== null; +} + +// paneTail returns the last n lines of a pane capture. Pure, so the detectors +// below are table-testable without tmux. +function paneTail(text, n) { + return String(text || '').split('\n').slice(-n).join('\n'); +} + +// paneShowsTransientAPIError reports whether the visible tail carries a +// retryable API failure. Every candidate line must carry the "API Error:" +// chrome AND a known-retryable pattern, so prose that merely mentions a dropped +// connection ("the user reported connection lost mid-response earlier") does +// not trip it. +function paneShowsTransientAPIError(text) { + const lines = paneTail(text, TRANSIENT_API_ERROR_TAIL_LINES).split('\n'); + return lines.some((line) => { + const lower = line.toLowerCase(); + if (!lower.includes('api error:')) return false; + if (TRANSIENT_API_ERROR_PATTERNS.some((pat) => lower.includes(pat))) return true; + return TRANSIENT_API_ERROR_STATUS_RE.test(line); + }); +} + +// paneShowsUnretryableAPIError detects failures a repeat cannot clear — an +// authorization refusal or an exhausted quota. LINE-WISE and gated on the same +// "API Error:" chrome as the transient detector, and the gate matters MORE +// here: this verdict actively fails the task, so a false positive fails work +// that genuinely completed. An agent working on hive's own quota-handling code +// can legitimately print "budget_exceeded" in its final summary (the repo's +// test files contain these strings verbatim); without the chrome gate that +// completed turn would be booked as an environment failure. Claude renders +// every real quota/authorization error under the chrome on the same line +// ("API Error: 429 {\"type\":\"budget_exceeded\"...}"), so the gate costs +// nothing for the errors this exists to catch. A chrome-less quota banner +// (copilot/bob render some) falls through to the pre-#5094 behavior and is +// part of the documented #5121 residual. +function paneShowsUnretryableAPIError(text) { + const lines = paneTail(text, TRANSIENT_API_ERROR_TAIL_LINES).split('\n'); + return lines.some((line) => { + const lower = line.toLowerCase(); + if (!lower.includes('api error:')) return false; + if (UNRETRYABLE_API_ERROR_PATTERNS.some((pat) => lower.includes(pat))) return true; + return UNRETRYABLE_API_ERROR_STATUS_RE.test(line); + }); +} + +// paneShowsLoginRequiredError detects an AUTHENTICATION failure — the CLI's +// credential expired mid-session and it is asking for /login. Neither of the +// other two buckets fits: a retry cannot clear it (typing "try again" at an +// expired credential is a wall), and failing it releases a task a human can +// rescue in thirty seconds by logging in. The honest state is BLOCKED_ON_HUMAN +// — a person genuinely is the only thing that can move it — which the hub +// already renders with an attention flag. +// +// 401 is authentication, NOT the 403 the fatal bucket catches: the hub's #4400 +// rule is that /login fixes a 401 and fixes nothing about a 403. Ordering in +// classifyTmuxPane preserves that: the fatal check runs first, so a line +// carrying both a login hint and a 403/authorization refusal stays fatal. +// +// Without this, a mid-session credential expiry — the exact scenario #5088 +// reported — rendered "● Please run /login · API Error: 401 …" above the idle +// prompt and was booked as a COMPLETED task. +function paneShowsLoginRequiredError(text) { + const lines = paneTail(text, TRANSIENT_API_ERROR_TAIL_LINES).split('\n'); + return lines.some((line) => { + const lower = line.toLowerCase(); + if (lower.includes('please run /login')) return true; + return lower.includes('api error:') && /\b401\b/.test(line); + }); +} + +// How long an attached tmux client must have been silent before the relay +// stops treating it as a person who owns the pane (kubestellar/hive#5277). +// +// The guard this feeds exists so a watchdog never types over someone +// mid-keystroke, and that is worth keeping. But "a client is connected" is not +// "a human is here": a dashboard terminal tab left open an hour ago +// (bin/ttyd-tmux.sh attaches one, and the dashboard's browser terminal proxies +// to it) was indistinguishable from someone actively typing, and it disabled +// API-error auto-retry for the whole 30-minute task ceiling. +// +// Five minutes, and the two bounds are asymmetric. Below ~2 minutes the +// threshold is not observable at all: the only caller runs on the +// PROGRESS_REPORT_INTERVAL_MS tick, 120s apart. Above it, every extra minute is +// a minute of a stranded task, and the cost of being wrong in that direction is +// mild — "try again" typed at a prompt nobody is typing at is visible and +// harmless, while the cost of being wrong in the other direction is the bug +// this fixes. Long enough to cover reading a diff; far short of the 30-minute +// strand it replaces. +const HUMAN_PRESENCE_IDLE_MS = Number(process.env.HIVE_HUMAN_PRESENCE_IDLE_MS) || 5 * 60 * 1000; + +// tmuxSessionHumanPresence reports whether a human is at the agent's tmux +// session, and how confident that answer is. +// +// attached — some client is connected at all. +// active — some client has typed within HUMAN_PRESENCE_IDLE_MS. This, not +// `attached`, is the question a watchdog must ask before typing. +// idleMs — how long the most recently active client has been quiet, or +// null when tmux did not say. +// +// `client_activity` is tmux's per-client timestamp of last input, in epoch +// seconds — the signal that distinguishes an abandoned tab from a person. +// +// EVERY uncertain answer resolves to active:true, because the failure this +// guard prevents (typing over someone mid-keystroke) is worse than the failure +// it causes (a retry deferred one tick). tmux erroring, tmux returning +// unparseable activity values, and a clock skewed into the future all take that +// branch. Only a client that positively reports itself quiet for long enough +// releases the pane. +function tmuxSessionHumanPresence() { + try { + const out = execSync( + `tmux list-clients -t ${TMUX_SESSION} -F '#{client_activity}' 2>/dev/null || true`, + { encoding: 'utf8', timeout: 15000 }); + const text = String(out).trim(); + if (!text) return { attached: false, active: false, idleMs: null }; + + let newestSec = null; + for (const line of text.split('\n')) { + const seconds = Number(String(line).trim()); + if (!Number.isFinite(seconds) || seconds <= 0) continue; + if (newestSec === null || seconds > newestSec) newestSec = seconds; + } + if (newestSec === null) { + // Attached, but tmux told us nothing usable about when — an old tmux + // whose client_activity is not an epoch integer, say. Presence unknown, + // so presence assumed. + return { attached: true, active: true, idleMs: null }; + } + + // A negative age means the client's clock is ahead of ours; clamping to + // zero makes that read as "just now", which is the cautious direction. + const idleMs = Math.max(0, Date.now() - newestSec * 1000); + return { attached: true, active: idleMs < HUMAN_PRESENCE_IDLE_MS, idleMs }; + } catch (_) { + return { attached: true, active: true, idleMs: null }; + } +} + +// tmuxSessionHasAttachedClient reports only whether a client is CONNECTED. It +// deliberately says nothing about whether a person is there — see +// tmuxSessionHumanPresence for the question callers actually want. Kept because +// "is anything attached at all" is still a real question, and because failing +// closed on a tmux error is the same rule at both layers. +function tmuxSessionHasAttachedClient() { + return tmuxSessionHumanPresence().attached; +} + +// tmuxSendNudge types a short literal message and submits it. +// +// Deliberately NOT tmuxSendKeys(): that function is the TASK-PROMPT path and +// carries machinery a nudge must not trigger — a /clear once the context +// crosses CLEAR_CONTEXT_THRESHOLD_PCT, the periodic every-N-tasks CLI restart, +// and the /tmp sweep. A nudge exists precisely to preserve the session context +// that makes recovery cheap; clearing or restarting would throw away the very +// thing being rescued. +function tmuxSendNudge(message) { + execSync(`tmux send-keys -t ${TMUX_SESSION} -l '${message}'`, { timeout: 15000 }); + sleepMs(ENTER_DELAY_MS); + tmuxSendEnters(); +} + +// paneUnknownAPIErrorLine returns the first line of the visible tail that +// carries Claude Code's own error rendering — a line-leading "● API Error:" — +// or null. Reached only after the three curated detectors above have NOT +// matched (classifyTmuxPane's ordering), so a hit here is an API failure the +// tables cannot name (kubestellar/hive#5121): a 400, a 404, a 429 phrased in a +// way nobody anticipated, a brand-new gateway message. +// +// The anchor is deliberately STRICTER than the curated detectors' anywhere-in- +// the-line match. They pair the chrome with a known pattern, which is already +// two independent signals; this one has no pattern to pair with, so the chrome +// must be the CLI's own rendering — the ● bullet at line start is how Claude +// Code prints its errors — or an agent whose completed-turn prose merely +// mentions "API Error: 418" would be held and retried instead of credited. +// The residual is an agent whose rendered message BEGINS with the literal +// string "API Error:", which is as narrow as this can get from pane text. +// +// Returning the line (not a boolean) is the instrumentation half of #5121: +// every hit is logged verbatim at the call site, so the curated lists can be +// grown from what actually occurs in the wild instead of from guesses. +function paneUnknownAPIErrorLine(text) { + const lines = paneTail(text, TRANSIENT_API_ERROR_TAIL_LINES).split('\n'); + for (const line of lines) { + if (/^\s*●\s*API Error:/i.test(line)) return line.trim(); + } + return null; } function classifyTmuxPane(text) { let hasIdlePrompt, hasCompletionMarker, isWorking; if (BACKEND === 'claude') { - const lastLines = text.split('\n').slice(-15).join('\n'); - hasIdlePrompt = /bypass permissions|shift\+tab to cycle/.test(text); + const claudeTail = text.split('\n').slice(-15).join('\n'); + // Claude's optional footer hints change when a background shell is still + // running. Its own state markers do not: an in-flight turn renders + // "esc to interrupt", while an idle turn retains the ⏵⏵ / agents chrome. + // Prefer those markers over transcript verbs, which may describe finished + // work. Keep the verb heuristic only for an unrecognised footer so an + // unknown Claude UI still errs toward busy. + hasIdlePrompt = /⏵⏵|← for agents|bypass permissions|shift\+tab to cycle/.test(claudeTail); hasCompletionMarker = /[✻✶✽] \S+ed for \d+[ms]|Honking|tokens\)/.test(text); - isWorking = /─.*Bash\(|Reading|Editing|Writing|Searching/.test(lastLines) || /ing…/.test(lastLines); + const claudeBusyMarker = /esc to interrupt/i.test(claudeTail); + isWorking = claudeBusyMarker || + (!hasIdlePrompt && (/─.*Bash\(|Reading|Editing|Writing|Searching/.test(claudeTail) || /ing…/.test(claudeTail))); } else if (BACKEND === 'copilot') { hasIdlePrompt = /\/ commands.*help/.test(text); hasCompletionMarker = true; @@ -1724,6 +2261,46 @@ function classifyTmuxPane(text) { if (paneLooksBlockedOnHuman(text)) return PANE_STATE_BLOCKED_ON_HUMAN; if (isWorking) return PANE_STATE_WORKING; + // A turn that ended in a RETRYABLE API failure is not a completed turn + // (kubestellar/hive#5094). This must sit above the completion test: the + // completion markers below are "the turn stopped" signals — claude's + // "✻ …ed for 9m 24s" duration summary is printed for an errored turn exactly + // as for a successful one — so without this check an API error reads as + // success and the task is reported complete having shipped nothing. + // + // Below isWorking, though: a CLI that is streaming or mid-retry (Claude Code + // retries some failures itself, rendering a countdown) is left alone, because + // interrupting that would CAUSE the stall this is meant to prevent. + // Unretryable FIRST, so a pane carrying both signals fails rather than retries + // — the veto has to win, or a 403 rendered under the same "API Error:" chrome + // as a dropped connection would be nudged forever. + // + // Both branches exist for one reason: a turn that ended in an API error did not + // complete. Closing only the retryable half (the original #5094 fix) left a 403 + // or an exhausted quota falling straight through to the completion test and + // being booked as a finished task — the same defect, one branch over. + if (paneShowsUnretryableAPIError(text)) { + return PANE_STATE_FATAL_API_ERROR; + } + // Authentication (401 / "Please run /login") AFTER the fatal check — a line + // carrying both a login hint and an authorization refusal must stay fatal, + // because /login fixes a 401 and fixes nothing about a 403 (#4400). A human + // logging in is the only recovery, so this is blocked-on-human, not an error + // to retry or fail. + if (paneShowsLoginRequiredError(text)) { + return PANE_STATE_BLOCKED_ON_HUMAN; + } + if (paneShowsTransientAPIError(text)) { + return PANE_STATE_TRANSIENT_API_ERROR; + } + // LAST of the error checks, FIRST before completion: an anchored API error + // the curated lists cannot name (#5121). Order matters twice over — the + // curated buckets get first claim on their lines, and a turn that ended in + // ANY API error must not fall through to the completion test below, which is + // exactly how #5094's false completions happened. + if (paneUnknownAPIErrorLine(text) !== null) { + return PANE_STATE_UNKNOWN_API_ERROR; + } if (hasIdlePrompt && hasCompletionMarker) return PANE_STATE_IDLE_COMPLETE; return PANE_STATE_WORKING; } @@ -1792,6 +2369,86 @@ function relaunchCLI() { return launchCmd; } +// dropTaskCredential removes the repo-scoped GitHub token this relay was given +// for the task that is ending. +// +// The token lives in exactly one place — the 0600 GH_TOKEN_CACHE written by +// injectGhToken — and it stays valid for the remainder of wsTokenTTL (~55min) +// no matter what the relay reports. Leaving it on disk after the hub has +// released the work means a turn that is still running can keep pushing and +// opening PRs against an issue the hub has already offered to someone else. +// +// Kept separate from the stop so the ordering in stopAgentForTaskExit() is +// visible at its single call site rather than buried in a compound helper. +function dropTaskCredential() { + try { fs.unlinkSync(GH_TOKEN_CACHE); } catch (_) {} + tokenExpiresAt = null; +} + +// stopAgentForTaskExit ends the AGENT, not just the bookkeeping, when a task +// stops being ours (kubestellar/hive#5353 cause B). +// +// Reporting task_complete or task_failed tells the hub to revoke the lease, +// book a cooldown and offer the issue to someone else. Before this existed, +// only five of the relay's task-exit paths touched the pane, so the other +// paths left the original agent running in the same pane, on the same context, +// holding a live scoped token — and it would eventually open a PR against an +// issue the hub had already reassigned. That is the duplicate-PR shape #2356 +// exists to prevent, produced from inside the contributor rather than outside +// it, which is why the hub's cooldown accounting cannot see it. +// +// The sequence is the one the task_revoke handler already got right, and the +// ORDER is load-bearing: +// +// 1. Unlink the credential FIRST, so a turn that survives the interrupt (or +// races it) cannot keep using it. Interrupting first leaves a window in +// which the agent is being killed but is still authorized. +// 2. Two Ctrl-Cs via quitLiveCLI() — one only cancels a claude/codex/agy +// turn and leaves the CLI running, so the relaunch command that follows +// would be typed into the CLI as a chat message (#2203). +// 3. Relaunch, which sets cliReady=false and re-arms armCLIReadyWait(), so +// the next task's prompt is queued until a clean prompt is confirmed. +// +// Re-entrancy: callers that have ALREADY stopped or relaunched the pane pass +// { skipCLI: true } and get only step 1 — nesting a second quit/relaunch into +// a relaunch already in flight is how double-launches happen. Headless mode +// has no pane at all; there the in-flight one-shot child is killed instead, +// matching what the revoke handler does. +// +// opts.reason names the exit in the relaunch log line, and opts.onRelaunchFailed +// lets a caller with its own post-relaunch latch (the revoke handler's +// readyAfterInteractiveRevoke) unwind it — the latch is only meaningful if a +// relaunch actually happened. +// +// Best-effort by design, like quitLiveCLI(): every caller is already on an +// exit path, and a relaunch that lands badly is recovered by the +// armCLIReadyWait() contract. +function stopAgentForTaskExit(opts) { + const skipCLI = !!(opts && opts.skipCLI); + const reason = (opts && opts.reason) || 'a task exit'; + // Step 1, always — even when the pane is deliberately left alone. A task + // that is no longer ours must not keep its credential under any branch. + dropTaskCredential(); + if (skipCLI) return; + if (CONTRIBUTOR_MODE === MODE_HEADLESS) { + if (headlessChild) { + try { headlessChild.kill('SIGKILL'); } catch (_) {} + headlessChild = null; + writeHeadlessStatus(HEADLESS_STATE_WAITING); + } + return; + } + cliReady = false; + quitLiveCLI(); + try { + console.log(`Relaunching ${BACKEND} after ${reason}: ${relaunchCLI()}`); + } catch (e) { + cliReadyFailed = true; + if (opts && opts.onRelaunchFailed) opts.onRelaunchFailed(); + console.error(`Failed to stop and relaunch ${BACKEND} after ${reason}: ${e.message}`); + } +} + // --- Pane stall backstop ------------------------------------------------ // // A relay that BELIEVES it is working renews the hub's task lease on every @@ -1829,6 +2486,75 @@ const PANE_STALL_TIMEOUT_MS = Number(process.env.HIVE_PANE_STALL_TIMEOUT_MS) || // path before the confirm count is ever consulted. const PANE_STALL_CONFIRM_TICKS = Math.max(1, Number(process.env.HIVE_PANE_STALL_CONFIRM_TICKS) || 2); +// ── Chrome-idle grace before an unverdicted completion (#5376) ─────────────── +// +// THE DEMOTION. classifyTmuxPane() used to be the whole completion contract: +// PANE_STATE_IDLE_COMPLETE meant "task done", full stop. It is no longer +// allowed to say that on its own. It says "this pane looks idle" — a liveness +// judgement its per-backend chrome CAN support — and the agent's own +// HIVE_VERDICT: line says whether the task is done. +// +// THE FALLBACK, and why this shape. Not every backend will emit the sentinel +// reliably; some builds ignore instructions in a long prompt, and the marker +// can scroll out of the fifteen-line tail on a chatty summary. Two honest +// options were on the table: +// +// (a) idle-without-verdict is "still running" until the progress lease +// expires. Rejected. A non-compliant agent that genuinely finished draws +// nothing more, so paneChangedSince() stops re-arming the lease and the +// task dies at PANE_STALL_TIMEOUT_MS as an `environment` FAILURE — with +// its PR already open. That converts every success by a non-compliant +// backend into a false failure and a wasted re-offer. It is the #4182 / +// #4127 shape (a finished task killed by the stall backstop) reintroduced +// deliberately, and it is worse than the bug this issue exists to end. +// +// (b) a BOUNDED grace period after idle, then complete anyway. Chosen. +// +// What (b) buys, precisely: the sentinel becomes the fast path — an agent that +// says it is done is believed on the spot, verdict recorded — while chrome +// alone must hold idle for CHROME_IDLE_GRACE_TICKS consecutive ticks before it +// is allowed to conclude anything. That directly targets the failure mode the +// thirteen issues share: every one of them was a MOMENTARY misread — a +// duration summary printed mid-turn, a status row between tool calls, an +// errored turn parked at the prompt. A pane that has rendered idle chrome and +// nothing else across several minutes is a far weaker claim than a single +// frame, and any new output at all resets the count (see recordChromeIdleTick). +// +// What (b) does NOT buy: it is still chrome, so it is still fallible, just +// slower and much harder to trip. The verdict path is the one that is +// trustworthy. The grace exists so that adopting it costs nothing when an +// agent does not comply, which is what makes the demotion shippable at all. +// +// The completion is marked `chrome_idle` when it comes from this path, so the +// hub and the operator can see which signal ended a task and per-backend +// non-compliance is measurable rather than guessed at. +const CHROME_IDLE_GRACE_TICKS = Math.max(1, Number(process.env.HIVE_CHROME_IDLE_GRACE_TICKS) || 3); + +// How many CONSECUTIVE ticks the pane has classified IDLE_COMPLETE with no +// completion verdict in sight. Reset on task start and on any tick that does +// not see an unverdicted idle pane. +let chromeIdleTicks = 0; + +// recordChromeIdleTick advances (or resets) the grace counter and reports +// whether chrome alone has now earned the right to end the task. +// +// PURE with respect to the pane fingerprint: it takes the already-captured +// lines and never reads the pane itself. paneStalled() is destructive — the +// first call seeing new output consumes it (#5333) — so nothing on the tick +// path may take a second reading. +function recordChromeIdleTick(idleWithoutVerdict) { + if (!idleWithoutVerdict) { + chromeIdleTicks = 0; + return false; + } + chromeIdleTicks++; + return chromeIdleTicks >= CHROME_IDLE_GRACE_TICKS; +} + +function resetChromeIdleGrace() { + chromeIdleTicks = 0; +} + let lastPaneFingerprint = null; let lastPaneChangeAt = 0; // How many CONSECUTIVE ticks paneStalled() has now returned true. Distinct @@ -1838,6 +2564,29 @@ let lastPaneChangeAt = 0; // where paneStalled() is false (new output resets the whole stall story). let stallConfirmCount = 0; +// Transient-API-error nudge state (kubestellar/hive#5094), scoped to the +// CURRENT task: how many retries we have typed and when the last one went out. +// Both are reset at task start — a previous task's exhausted budget must not +// deny this one its retries. +let transientNudgeCount = 0; +let lastTransientNudgeAt = 0; + +function resetTransientNudgeState() { + transientNudgeCount = 0; + lastTransientNudgeAt = 0; +} + +// Autonomy-nudge state (kubestellar/hive#5281), scoped to the CURRENT task. +// Budget of exactly one: a question the agent re-asks AFTER being told to +// proceed autonomously is a question it genuinely cannot answer itself, and +// re-nudging it would loop until the max-duration ceiling. Once spent, the pane +// reports blocked_on_human exactly as it does today. +let autonomyNudgeSent = false; + +function resetAutonomyNudgeState() { + autonomyNudgeSent = false; +} + function resetPaneStallClock() { lastPaneFingerprint = null; lastPaneChangeAt = Date.now(); @@ -1845,6 +2594,9 @@ function resetPaneStallClock() { // A new task also starts with a clean CLI-liveness count: shell readings from // the previous task say nothing about this one. consecutiveShellReadings = 0; + // Likewise the chrome-idle grace (#5376): idle ticks accumulated while the + // PREVIOUS task wound down must never count toward ending this one. + resetChromeIdleGrace(); } // paneStalled records the current pane content and reports whether it has been @@ -1865,6 +2617,31 @@ function paneStalled(tmuxLines) { return now - lastPaneChangeAt >= PANE_STALL_TIMEOUT_MS; } +// paneChangedSince reports whether the pane differs from the last fingerprint +// paneStalled() recorded — i.e. whether the agent produced output since the +// previous tick (kubestellar/hive#5321). +// +// PURE BY CONSTRUCTION: it must not update lastPaneFingerprint or +// lastPaneChangeAt. paneStalled() is destructive — the first call that sees new +// output records it and returns false, so a second call in the same tick sees +// no change. progressTick() calls this one FIRST and paneStalled() (via +// paneStallConfirmed) later in the same tick; if this function recorded, the +// stall detector would see an already-consumed change every time and could +// never accumulate a stall. Read only. +// +// A null fingerprint means no tick has recorded one yet (fresh task): that is +// not evidence of progress, and treating it as such would hand a task that has +// never drawn anything a free lease renewal. +function paneChangedSince(tmuxLines) { + if (lastPaneFingerprint === null) return false; + const fingerprint = Array.isArray(tmuxLines) ? tmuxLines.join('\n') : String(tmuxLines || ''); + // An empty capture means tmux told us nothing (session gone, capture failed). + // paneStalled() refuses to read that as a stall; symmetrically it must not be + // read as progress either. + if (!fingerprint) return false; + return fingerprint !== lastPaneFingerprint; +} + // paneStallConfirmed wraps paneStalled() with the multi-tick confirmation // described above it. Any tick where paneStalled() is false (new output // appeared) resets the count — the CLI gets full credit for proving it is not @@ -1945,15 +2722,38 @@ function restartBackoffMs(attempt) { // // It is advisory: the hub records and displays it and does not route, gate, or // change the work item's failure cooldown on it. Older hubs ignore the field. +// +// opts.skipCLI (kubestellar/hive#5353) says the CALLER has already dealt with +// the pane — it quit and relaunched the CLI itself, or the CLI is already gone. +// The credential is still dropped; only the quit/relaunch is skipped, so a +// relaunch already in flight is not nested inside another one. function failCurrentTask(reason, opts) { if (!currentTask) return; const permanent = !!(opts && opts.permanent); const kind = (opts && opts.kind) || undefined; const taskId = currentTask.task_id; const taskGen = currentTask.task_gen; + // Captured BEFORE the agent is stopped: the pane text is the evidence the + // hub and the operator read to understand the failure, and quitLiveCLI() + // followed by a relaunch overwrites it with launch chrome. const tmuxLines = captureTmuxLines(TMUX_TAIL_LINES); + // Cause B (#5353): the hub is about to release this issue and offer it to + // someone else. Stop the agent and drop its token FIRST, so the report and + // the reality agree at the instant the hub acts on it. + stopAgentForTaskExit({ skipCLI: !!(opts && opts.skipCLI) }); console.error(`Task ${taskId} failed${permanent ? ' permanently' : ''}${kind ? ` [${kind}]` : ''}: ${reason}`); - send({ type: 'task_failed', seq: nextSeq(), task_id: taskId, task_gen: taskGen, reason, permanent, failure_kind: kind, tmux_output: tmuxLines }); + send({ + type: 'task_failed', + seq: nextSeq(), + task_id: taskId, + task_gen: taskGen, + result: 'failed', + reason, + permanent, + failure_kind: kind, + tmux_output: tmuxLines, + ...effectiveSelectionFields(), + }); currentTask = null; taskAssignedAt = 0; if (progressInterval) { clearInterval(progressInterval); progressInterval = null; } @@ -1975,18 +2775,239 @@ function startProgressReporting() { // Every task starts with a clean stall clock — the previous task's pane // fingerprint says nothing about this one. resetPaneStallClock(); + // Likewise the retry budget: a previous task that exhausted its API-error + // retries must not deny this one its own (#5094). + resetTransientNudgeState(); + // And the one-shot autonomy reminder (#5281), for the same reason. + resetAutonomyNudgeState(); - taskTimeoutHandle = setTimeout(() => { - if (currentTask) { - failCurrentTask(`task exceeded max duration (${MAX_TASK_DURATION_MS / 60000}min)`); - } - }, MAX_TASK_DURATION_MS); + armTaskProgressLease(); progressInterval = setInterval(progressTick, PROGRESS_REPORT_INTERVAL_MS); } +// armTaskProgressLease (re)starts the max-duration timer from NOW. +// +// Called once at task start and again from every tick that observes forward +// progress, which is what turns MAX_TASK_DURATION_MS from a wall-clock budget +// into a lease (kubestellar/hive#5321). An agent producing output keeps its +// lease; a silent one lets it run down. +// +// Deliberately mirrors the sibling per-task clocks armed alongside it — +// resetPaneStallClock(), resetTransientNudgeState(), resetAutonomyNudgeState() +// — all of which were already progress-aware. This one was the odd clock out. +// +// Takes no locks and touches no shared connection state: it clears and re-sets +// a timer handle owned by this module, so it is safe to call from inside +// progressTick without regard to what the caller already holds. +function armTaskProgressLease() { + if (taskTimeoutHandle) clearTimeout(taskTimeoutHandle); + // Deliberately NOT unref'd: no other timer in this relay is, and the handle + // is cleared on every task exit (completion, failure, revoke), so it never + // outlives the task it bounds. Changing process-exit semantics is not part of + // this fix. + taskTimeoutHandle = setTimeout(onTaskProgressLeaseExpired, MAX_TASK_DURATION_MS); +} + +// onTaskProgressLeaseExpired runs when MAX_TASK_DURATION_MS elapsed with no +// observed progress. +// +// It re-checks the progress signal rather than trusting the timer alone: the +// tick loop re-arms on output, but a tick that lands microseconds after the +// timer fired would otherwise lose the race and kill a live agent for it. If +// the pane HAS changed within the lease window, the lease is simply renewed. +// +// Reaching the kill means the relay saw no progress for the lease window AND +// (normally) the stall detector already had its say — so this is a runtime +// verdict, not a judgement of the work: kind 'environment' (#5321). Previously +// this path passed no opts at all, so an infrastructure ceiling was recorded as +// a plain task failure. +function onTaskProgressLeaseExpired() { + if (!currentTask) return; + const now = Date.now(); + const elapsed = taskAssignedAt ? now - taskAssignedAt : 0; + + // Absolute backstop first: past this, no amount of output buys more time. + if (elapsed >= ABSOLUTE_TASK_DEADLINE_MS) { + failCurrentTask( + `task exceeded the absolute deadline (${Math.round(ABSOLUTE_TASK_DEADLINE_MS / 60000)}min) without completing`, + { kind: 'environment' } + ); + return; + } + + // Forward progress since the lease was armed? Renew it and say nothing. + // lastPaneChangeAt is maintained by paneStalled() on every tick, so it is the + // same signal the stall detector uses — one definition of "progress", not two. + if (lastPaneChangeAt && now - lastPaneChangeAt < MAX_TASK_DURATION_MS) { + armTaskProgressLease(); + return; + } + + failCurrentTask( + `no observed progress for ${MAX_TASK_DURATION_MS / 60000}min — the agent CLI is not visibly working`, + { kind: 'environment' } + ); +} + // One iteration of the progress/completion/crash-detection loop. Extracted from // the setInterval body so it can be driven deterministically from tests. +// handleTransientAPIError recovers a task whose turn ended in a retryable API +// failure (kubestellar/hive#5094). +// +// Before this existed the pane classified as IDLE_COMPLETE and the relay +// reported the task COMPLETED — the hub booked a completion that shipped +// nothing, reassigned the contributor, and the half-finished work was orphaned. +// Every branch here is a way of NOT doing that: retry it, hand it to the human +// already watching, or fail it honestly. None of them claims success. +// +// The goose backend has had this shape since long before #5094 — see +// checkTmuxPaneState, which presses Enter on a goose network error and returns +// WORKING. This generalises that precedent rather than inventing one. +function handleTransientAPIError(tmuxLines) { + if (!currentTask) return; + const now = Date.now(); + const progressBase = { + type: 'task_progress', + seq: nextSeq(), + task_id: currentTask.task_id, + task_gen: currentTask.task_gen, + tmux_output: tmuxLines, + }; + + // A human AT the pane owns it, and a watchdog must never type over someone + // mid-keystroke. But presence is a recency question, not a connection one + // (#5277): a dashboard terminal tab left open is a connected client and not a + // person, and treating the two alike disabled recovery entirely for as long + // as the tab lived. An attached-but-quiet client falls through to the retry + // below; only a recently active one still takes this branch. + const presence = tmuxSessionHumanPresence(); + if (presence.active) { + const since = presence.idleMs === null + ? 'activity unknown' + : `last input ${Math.round(presence.idleMs / 1000)}s ago`; + console.warn(`Task ${currentTask.task_id} stopped on a retryable API error; ` + + `someone is active on ${TMUX_SESSION} (${since}), so not typing a retry`); + send({ + ...progressBase, + status: 'blocked_on_human', + attention: true, + summary: 'Agent stopped on a retryable API error; a human is active in the pane', + ...progressModelFields(), + }); + return; + } + if (presence.attached) { + console.warn(`Task ${currentTask.task_id} stopped on a retryable API error; ` + + `a client is attached to ${TMUX_SESSION} but has been idle ` + + `${Math.round(presence.idleMs / 1000)}s, so proceeding with the retry`); + } + + // Bounded: a persistent upstream failure ends as an honest environment + // failure, which the hub records and can re-offer, rather than an infinite + // typing loop or a fabricated completion. + if (transientNudgeCount >= TRANSIENT_API_ERROR_MAX_NUDGES) { + failCurrentTask( + `agent stopped on a retryable API error and did not recover after ` + + `${TRANSIENT_API_ERROR_MAX_NUDGES} retries`, + { kind: 'environment' } + ); + return; + } + + // Give the previous retry time to land before typing another. + if (lastTransientNudgeAt && now - lastTransientNudgeAt < TRANSIENT_API_ERROR_NUDGE_COOLDOWN_MS) { + send({ ...progressBase, status: 'working', ...progressModelFields() }); + return; + } + + transientNudgeCount++; + lastTransientNudgeAt = now; + console.warn(`Transient API error on ${currentTask.task_id} — sending retry ` + + `${transientNudgeCount}/${TRANSIENT_API_ERROR_MAX_NUDGES}`); + try { + tmuxSendNudge(TRANSIENT_API_ERROR_NUDGE_MESSAGE); + } catch (e) { + console.error('Failed to send the retry nudge:', e.message); + } + send({ + ...progressBase, + status: 'working', + summary: `Retrying after a transient API error ` + + `(${transientNudgeCount}/${TRANSIENT_API_ERROR_MAX_NUDGES})`, + ...progressModelFields(), + }); +} + +// paneHasPresentHuman is the one place this file asks "is a person there?". +// +// It exists as a named seam because #5281 and #5094 must answer it the SAME +// way: a guard that diverges between two nudges is how you get a pane that is +// safe from one watchdog and not the other. +// +// Today it is the bare attached check — a client is connected. #5277 is +// replacing that with a recency test on tmux's `client_activity`, because a +// dashboard terminal tab left open is a connected client and not a person. +// When that lands this body becomes `return tmuxSessionHumanPresence().active;` +// and both callers inherit it; that one line is the whole follow-up. +function paneHasPresentHuman() { + return tmuxSessionHasAttachedClient(); +} + +// maybeSendAutonomyNudge types a one-shot reminder at an unattended pane that +// stopped to ask a question it was already instructed to answer for itself +// (kubestellar/hive#5281), and reports whether it did. +// +// Detection without recovery is what this fixes. The relay already SEES the +// question and raises `attention`, but an attention flag only helps someone who +// is watching something, and a contributor run by a user who never attaches to +// tmux is a supported way to run one. For that user every question the agent +// asks costs 20-30 minutes and a failed task. +// +// Four things must all hold, and each one is a separate way to get this wrong: +// +// 1. The pane is blocked on a QUESTION, not on something only a person can +// answer. See classifyBlockedOnHumanReason. +// 2. It is not a login/401 pane. Belt to the classifier's braces: a /login +// flow is reached by a different route through checkTmuxPaneState (#4400), +// so excluding it here makes "never nudge a login" true by construction +// rather than true by coincidence. +// 3. Nobody is at the pane. +// 4. The one-shot budget is unspent. +function maybeSendAutonomyNudge(tmuxLines) { + if (!currentTask) return false; + if (autonomyNudgeSent) return false; + + const pane = tmuxLines.join('\n'); + if (paneShowsLoginRequiredError(pane)) return false; + if (classifyBlockedOnHumanReason(pane) !== BLOCKED_REASON_QUESTION) return false; + if (paneHasPresentHuman()) return false; + + // Spend the budget BEFORE typing. A send that throws has still disturbed the + // pane, and retrying it on the next tick is the loop this budget exists to + // prevent. + autonomyNudgeSent = true; + console.warn(`Task ${currentTask.task_id} is blocked on a question with nobody attached to ` + + `${TMUX_SESSION} — reminding it to proceed autonomously (once per task)`); + try { + tmuxSendNudge(AUTONOMY_NUDGE_MESSAGE); + } catch (e) { + console.error('Failed to send the autonomy reminder:', e.message); + return false; + } + send({ + type: 'task_progress', + seq: nextSeq(), + task_id: currentTask.task_id, + task_gen: currentTask.task_gen, + status: 'working', + summary: 'Agent asked a question with no human attached; reminded it to proceed autonomously', + tmux_output: tmuxLines, + ...progressModelFields(), + }); + return true; +} + function progressTick() { lastProgressTick = Date.now(); if (!currentTask) return; @@ -2023,9 +3044,14 @@ function progressTick() { // never wedge the whole contributor. givenUpTasks.set(key, Date.now()); cliRestartCounts.delete(key); + // skipCLI: this branch's premise is that the CLI process is ALREADY + // gone (probeCLIPresence confirmed it), and the relaunch that follows + // is this path's own. There is no live turn to interrupt, so quitting + // here would only send Ctrl-Cs at a bare shell and then race the + // relaunch below. The token is dropped regardless (#5353). failCurrentTask( `CLI process exited ${MAX_TASK_CLI_RESTARTS} times for ${key} — giving up on this task (relay still accepting other work)`, - { permanent: true } + { permanent: true, skipCLI: true } ); // Bring the CLI back so the next, different task can run. try { console.log(`CLI restarted: ${relaunchCLI()}`); } catch (e) { console.error('Failed to restart CLI:', e.message); } @@ -2041,7 +3067,9 @@ function progressTick() { console.error('Failed to restart CLI:', e.message); } // environment: the agent CLI process died; nothing was judged about the work. - failCurrentTask('CLI process exited — restarted', { kind: 'environment' }); + // skipCLI for the same reason as the give-up branch above — the process + // is gone and the relaunch just above is this path's own (#5353). + failCurrentTask('CLI process exited — restarted', { kind: 'environment', skipCLI: true }); return; } // A pane sitting at a shell is never evidence that the AGENT finished: the @@ -2062,8 +3090,54 @@ function progressTick() { const paneState = checkTmuxPaneState(); const tmuxLines = captureTmuxLines(TMUX_TAIL_LINES); - if (paneState === PANE_STATE_IDLE_COMPLETE) { - console.log(`Task ${currentTask.task_id} completed — agent idle`); + + // #5321: forward progress renews the max-duration lease. Recorded here, + // before any branch below can return, so EVERY pane state gets the credit — + // an agent stepping through blocked_on_human or a retried API error is still + // visibly alive, and none of those states should burn down a deadline whose + // question is "is this thing moving at all". paneChangedSince() is a pure + // read of the fingerprint clock paneStalled() maintains; the stall detector + // below still does its own recording, unaffected. + if (paneChangedSince(tmuxLines)) armTaskProgressLease(); + + // #5376: the agent's own completion sentinel, read BEFORE the pane state is + // consulted, because it — not the chrome — is what now decides the task is + // done. Both HIVE_VERDICT: complete and HIVE_VERDICT: no_work_needed count. + // + // Read from the already-captured tmuxLines: no second pane read, so the + // destructive paneStalled() fingerprint (#5333) is untouched. + const completionVerdict = detectCompletionVerdict(tmuxLines); + + // Chrome-idle grace (#5376). classifyTmuxPane() saying IDLE_COMPLETE is now + // only a hint; it must repeat across CHROME_IDLE_GRACE_TICKS ticks before it + // may end a task on its own. A verdict short-circuits the wait entirely. + const idleWithoutVerdict = paneState === PANE_STATE_IDLE_COMPLETE && !completionVerdict; + const chromeIdleGraceElapsed = recordChromeIdleTick(idleWithoutVerdict); + + // A verdict ends the task from ANY pane state. This is the point of the + // change: an agent that says it is finished is finished, whatever its CLI + // chose to render around the statement. It is precisely the case the + // thirteen chrome issues kept getting wrong from the other side — a real + // completion the classifier read as WORKING (#4127, #4181, #4259) and the + // stall backstop then failed with the PR already open. + // + // The two error states are excluded, and deliberately: a pane showing an + // authorization refusal or a truncated retryable response has NOT completed, + // and a stale verdict line still on screen from earlier in the transcript + // must not launder that into a success. Those branches below own those panes. + const apiErrorState = paneState === PANE_STATE_TRANSIENT_API_ERROR || + paneState === PANE_STATE_UNKNOWN_API_ERROR || + paneState === PANE_STATE_FATAL_API_ERROR; + const verdictCompletes = !!completionVerdict && !apiErrorState; + + if (verdictCompletes || (paneState === PANE_STATE_IDLE_COMPLETE && chromeIdleGraceElapsed)) { + // How this task ended, recorded so the hub and the operator can tell the + // trustworthy signal from the fallback — and so per-backend sentinel + // non-compliance is measurable rather than guessed at. + const completionSignal = verdictCompletes ? 'verdict' : 'chrome_idle'; + console.log(`Task ${currentTask.task_id} completed — signal=${completionSignal}` + + (verdictCompletes ? ` (HIVE_VERDICT: ${completionVerdict.verdict})` : ` (pane idle for ${chromeIdleTicks} consecutive checks, no verdict emitted)`)); + resetChromeIdleGrace(); // Successful completion clears this work item's crash-retry budget. cliRestartCounts.delete(taskKey(currentTask)); // Best-effort: report the PR the agent opened, if one is visible in its @@ -2075,13 +3149,40 @@ function progressTick() { // #3987: only report a no_work_needed verdict when no PR was shipped — a // visible PR contradicts "nothing shippable" (the hub would override the // claim with "shipped" anyway). - const noWork = prURL ? null : detectNoWorkVerdict(tmuxLines); + const noWork = prURL || !completionVerdict || completionVerdict.verdict !== HIVE_VERDICT_NO_WORK + ? null + : completionVerdict; if (noWork) console.log(`Detected no_work_needed verdict for ${currentTask.task_id}: ${noWork.reason || '(no reason)'}`); - send({ type: 'task_complete', seq: nextSeq(), task_id: currentTask.task_id, task_gen: currentTask.task_gen, result: 'completed', summary: noWork ? 'Agent returned to idle (reported no_work_needed)' : 'Agent returned to idle', tmux_output: tmuxLines, pr_url: prURL, verdict: noWork ? noWork.verdict : undefined, verdict_reason: noWork ? noWork.reason : undefined }); + // Cause B (#5353). "Idle" here is a verdict read off the pane's rendering + // chrome, and it is wrong often enough to have produced thirteen separate + // issues. When it is wrong, the agent is still mid-turn — and reporting + // task_complete makes the hub revoke the lease, book the cooldown, and + // offer the issue to somebody else while that turn keeps running in this + // pane on this token. Stopping the CLI and dropping the credential here + // makes the misread cost a retry instead of a duplicate PR. + // + // Note the ordering against `send` below: the agent is stopped BEFORE the + // hub is told, so at the instant the hub acts on the completion the claim + // is already true. tmuxLines was captured above, so the evidence the hub + // receives is still the agent's own output and not launch chrome. + // + // bob is exempt from the quit half: it is not a persistent REPL and has + // already exited at the end of its turn, so the pane is a bare shell and + // there is nothing to interrupt — sending Ctrl-C at that shell and then + // racing the bob-specific relaunch below is how a pane ends up with two + // launches in flight. Its credential is still dropped. + const bobAlreadyExited = BACKEND === 'bob' && !bobIsRunning(); + stopAgentForTaskExit({ skipCLI: bobAlreadyExited }); + const completionSummary = noWork + ? 'Agent returned to idle (reported no_work_needed)' + : (verdictCompletes + ? 'Agent reported the task complete (HIVE_VERDICT)' + : `Agent returned to idle (no verdict emitted; pane idle for ${CHROME_IDLE_GRACE_TICKS} consecutive checks)`); + send({ type: 'task_complete', seq: nextSeq(), task_id: currentTask.task_id, task_gen: currentTask.task_gen, result: 'completed', summary: completionSummary, tmux_output: tmuxLines, pr_url: prURL, completion_signal: completionSignal, verdict: noWork ? noWork.verdict : undefined, verdict_reason: noWork ? noWork.reason : undefined }); // bob exits after each turn, so the pane is now a bare shell. Bring it // back up before the next task, or the prompt would be typed into bash // ("-bash: : command not found") and silently lost. - if (BACKEND === 'bob' && !bobIsRunning()) { + if (bobAlreadyExited) { try { // relaunchCLI() clears cliReady and re-arms the readiness callback, // which flushes any queued prompt once the CLI is confirmed up. @@ -2110,7 +3211,27 @@ function progressTick() { } else { send({ type: 'ready', seq: nextSeq() }); } + } else if (paneState === PANE_STATE_IDLE_COMPLETE) { + // Idle chrome, no verdict, grace not yet elapsed (#5376). Report progress + // and wait — this is the tick or two in which a momentary misread (a + // duration summary printed mid-turn, a status row between tool calls) + // resolves itself by the pane simply carrying on. + // + // This branch MUST exist ahead of the stall backstop below rather than + // falling into it. An idle pane is byte-for-byte identical frame to frame, + // so the stall detector would accumulate against it and eventually hand the + // task back as an `environment` failure — a finished task reported as a + // failure, which is exactly the #4127/#4182 shape and strictly worse than + // the false completion this change is removing. The grace counter above is + // the bound here; the stall clock is not. + console.log(`Task ${currentTask.task_id}: pane looks idle but no HIVE_VERDICT yet — ${chromeIdleTicks}/${CHROME_IDLE_GRACE_TICKS} checks before completing on chrome alone`); + send({ type: 'task_progress', seq: nextSeq(), task_id: currentTask.task_id, task_gen: currentTask.task_gen, status: 'working', tmux_output: tmuxLines, ...progressModelFields() }); } else if (paneState === PANE_STATE_BLOCKED_ON_HUMAN) { + // #5281: before reporting a blocked pane to a human who may not be there, + // see whether this is a question the agent was already told to answer + // itself. At most once per task; everything below is unchanged and is what + // runs on every later tick. + if (maybeSendAutonomyNudge(tmuxLines)) return; console.warn(`Task ${currentTask.task_id} is blocked waiting for human input`); send({ type: 'task_progress', @@ -2123,6 +3244,26 @@ function progressTick() { tmux_output: tmuxLines, ...progressModelFields(), }); + } else if (paneState === PANE_STATE_TRANSIENT_API_ERROR) { + handleTransientAPIError(tmuxLines); + } else if (paneState === PANE_STATE_UNKNOWN_API_ERROR) { + // Instrumentation first (#5121): log the exact line the curated lists + // could not name, so the lists can be grown from real occurrences. Then + // the bounded transient path — retry up to the budget, honest environment + // failure after it, blocked_on_human if someone is attached. Shared budget + // and cooldown with the transient state: it is the same task either way. + console.warn(`Unrecognised API error (kubestellar/hive#5121) — treating as transient: ` + + `${paneUnknownAPIErrorLine(tmuxLines.join('\n')) || '(line scrolled away)'}`); + handleTransientAPIError(tmuxLines); + } else if (paneState === PANE_STATE_FATAL_API_ERROR) { + // No retry: an authorization refusal or an exhausted quota cannot be cleared + // by repeating the request (#4400, #4583). Hand the task back honestly so the + // hub records it and can re-offer it once an operator fixes the cause — + // rather than claiming a completion that shipped nothing. + failCurrentTask( + 'agent stopped on an API failure a retry cannot clear (authorization or quota)', + { kind: 'environment' } + ); } else { // Stall backstop: a pane frozen this long is not evidence of work, and // continuing to report "working" would renew the hub's lease forever. @@ -2145,12 +3286,11 @@ function progressTick() { // a live CLI that cancels the turn without exiting, and the launch command // is then typed into the CLI as a chat prompt — #2203 again, and worse here // because the "prompt" is a shell command an agent may simply run. - quitLiveCLI(); - try { - console.log(`Relaunching ${BACKEND} after a confirmed pane stall: ${relaunchCLI()}`); - } catch (e) { - console.error('Failed to relaunch after a confirmed pane stall:', e.message); - } + // + // Now done by failCurrentTask via stopAgentForTaskExit (#5353), which + // adds the credential unlink ahead of the interrupt and captures the + // stalled pane as evidence BEFORE the relaunch overwrites it — this path + // previously reported the launch chrome as the failure's tmux_output. failCurrentTask( `no pane activity for ${Math.round(PANE_STALL_TIMEOUT_MS / 60000)}+ minutes, confirmed over ${PANE_STALL_CONFIRM_TICKS} checks — the agent CLI is not visibly working`, { kind: 'environment' } @@ -2181,6 +3321,9 @@ function handleMessage(data, hub) { seq: nextSeq(), registration_token: hub.regToken, cli_backend: BACKEND, + // Pi derives this evidence from the canonical provider/model input. It + // remains advisory and is never used by the hub to route work. + provider: effectiveProvider() || undefined, // #4117: AGENT_MODEL if set, else the model detected from the CLI's // own session transcript, else '' (today's degrade for backends with // no known transcript format). @@ -2293,7 +3436,15 @@ function handleMessage(data, hub) { injectGhToken(msg.github_token); tokenExpiresAt = msg.token_expires_at ? new Date(msg.token_expires_at).getTime() : null; } - fs.writeFileSync(TASK_FILE, JSON.stringify(msg, null, 2)); + // TASK_FILE is observability/debug state with no reader that needs the + // credential; the live token's one legitimate on-disk home is the 0600 + // GH_TOKEN_CACHE written by injectGhToken above. Strip it and keep the + // file owner-only (chmod covers overwriting a pre-existing 0644 file) + // so a task-scoped GitHub token never sits world-readable under /tmp + // (kubestellar/hive#5065). + const { github_token: _omittedToken, ...taskFileRecord } = msg; + fs.writeFileSync(TASK_FILE, JSON.stringify(taskFileRecord, null, 2), { mode: 0o600 }); + try { fs.chmodSync(TASK_FILE, 0o600); } catch (_) { /* content is already token-free */ } send({ type: 'task_accepted', seq: nextSeq(), task_id: msg.task_id, task_gen: msg.task_gen }); if (CONTRIBUTOR_MODE === MODE_HEADLESS) { // Non-interactive path (kubestellar/hive#2538): drive a one-shot CLI @@ -2334,17 +3485,33 @@ function handleMessage(data, hub) { currentTask = null; taskAssignedAt = 0; if (progressInterval) { clearInterval(progressInterval); progressInterval = null; } - // Headless mode: kill the in-flight one-shot child so the revoked task's - // process does not keep running (and holding the credential) after the - // hub took the work back. - if (CONTRIBUTOR_MODE === MODE_HEADLESS && headlessChild) { - try { headlessChild.kill('SIGKILL'); } catch (_) {} - headlessChild = null; - writeHeadlessStatus(HEADLESS_STATE_WAITING); + // The max-duration lease dies with the task it bounds. Previously leaked + // here — harmless only because the callback guards on currentTask, so a + // revoke followed by a NEW task within the window would have had the old + // timer fire against the new task's assignment. startProgressReporting() + // re-arms it, which masked this; clearing it makes the lifecycle explicit + // and matches every other task-exit path (#5321). + if (taskTimeoutHandle) { clearTimeout(taskTimeoutHandle); taskTimeoutHandle = null; } + // Stop the agent and drop its credential. This is the sequence + // stopAgentForTaskExit() was factored out of (#5353): the token is + // unlinked BEFORE the interrupt so a surviving turn cannot keep using + // it; two Ctrl-C events are required because one cancels a Claude/Codex/ + // Pi turn but leaves the CLI alive; relaunchCLI gates ready on a clean + // prompt; and in headless mode the in-flight one-shot child is killed + // instead, so the revoked task's process does not keep running. + if (CONTRIBUTOR_MODE !== MODE_HEADLESS) { + // Set before the stop: the relaunch's readiness callback consumes this + // latch to re-advertise availability, and it is only meaningful if a + // relaunch actually happened — hence the unwind on failure. + readyAfterInteractiveRevoke = true; } + stopAgentForTaskExit({ + reason: 'task revoke', + onRelaunchFailed: () => { readyAfterInteractiveRevoke = false; }, + }); // Stay with the hub that just revoked — it's clearly alive and reachable. activeHubIndex = hubs.indexOf(hub); - sendTo(hub, { type: 'ready', seq: nextSeq() }); + if (CONTRIBUTOR_MODE === MODE_HEADLESS) sendTo(hub, { type: 'ready', seq: nextSeq() }); break; case 'task_unavailable': @@ -2395,6 +3562,47 @@ function handleMessage(data, hub) { } } +// WebSocket close codes the relay can meaningfully name. Anything else is +// reported by number rather than guessed at. +const WS_CLOSE_CODE_NAMES = { + 1000: 'normal closure', + 1001: 'going away', + 1002: 'protocol error', + 1003: 'unsupported data', + 1005: 'no status received', + 1006: 'abnormal closure', + 1008: 'policy violation', + 1009: 'message too big', + 1011: 'internal server error', + 1012: 'service restart', + 1013: 'try again later', + 1015: 'TLS handshake failure', +}; + +// describeWsClose renders the close code and reason for the log line. +// +// THE GAP THIS FILLS (kubestellar/hive#5090): this handler used to ignore both +// arguments and log only "closed. Reconnecting in 1000ms...", so a contributor +// whose socket flapped every 30-90 seconds had no way to tell a deliberate +// server hangup from a network drop — the two produce identical output, and the +// backoff never grows past 1s because each reconnect succeeds, so even the delay +// carries no signal. +// +// 1006 is called out explicitly because it is the one code that is never sent +// on the wire: the `ws` library synthesises it when the connection died WITHOUT +// a close frame. Seeing it means the socket was cut — by the network, a proxy, +// or a peer calling close() without the courtesy frame — rather than closed +// with a stated reason. That distinction is the whole diagnostic. +function describeWsClose(code, reason) { + const text = reason === undefined || reason === null ? '' : String(reason).trim(); + const name = WS_CLOSE_CODE_NAMES[code]; + const label = name ? `code=${code} ${name}` : `code=${code}`; + if (code === 1006) { + return `${label} — no close frame; the socket was cut (network, proxy, or an abrupt peer close)`; + } + return text ? `${label}: ${text}` : label; +} + function connectHub(hub) { if (hub.reconnectTimer) { clearTimeout(hub.reconnectTimer); hub.reconnectTimer = null; } if (hub.heartbeatInterval) { clearInterval(hub.heartbeatInterval); hub.heartbeatInterval = null; } @@ -2417,6 +3625,16 @@ function connectHub(hub) { return; } sendTo(hub, { type: 'ping', seq: nextSeq() }); + // Also emit a PROTOCOL-level Ping control frame (kubestellar/hive#5090). + // The JSON ping above is an ordinary text frame; an L7 proxy that scores + // tunnel idleness on control-frame traffic does not count it, so a + // connection heartbeating every 30s was still reaped as idle — the + // frameless-1006 flap this issue measured. `ws` answers an inbound Ping + // with a Pong automatically, so the hub needs nothing extra to see this. + // Wrapped because ping() throws if the socket left OPEN between the + // readyState check and the call; the heartbeat-timeout check above stays + // the authority on when to give up. + try { hub.ws.ping(); } catch { /* socket already closing; close handler reconnects */ } }, HEARTBEAT_INTERVAL_MS); }); @@ -2425,9 +3643,23 @@ function connectHub(hub) { handleMessage(data.toString(), hub); }); - hub.ws.on('close', () => { + // A PROTOCOL-level Pong counts as liveness exactly as the JSON 'pong' does + // (kubestellar/hive#5090), so a hub answering only control frames cannot trip + // this relay's HEARTBEAT_TIMEOUT_MS sweep. An inbound Ping is likewise + // evidence the hub is alive; `ws` auto-replies with a Pong for us. + hub.ws.on('pong', () => { + if (gen !== hub.connectGeneration) return; + hub.lastPong = Date.now(); + }); + hub.ws.on('ping', () => { + if (gen !== hub.connectGeneration) return; + hub.lastPong = Date.now(); + }); + + hub.ws.on('close', (code, reason) => { if (gen !== hub.connectGeneration) return; - console.log(`Connection to ${hub.url} closed. Reconnecting in ${hub.reconnectDelay}ms...`); + console.log(`Connection to ${hub.url} closed (${describeWsClose(code, reason)}). ` + + `Reconnecting in ${hub.reconnectDelay}ms...`); if (hub.heartbeatInterval) { clearInterval(hub.heartbeatInterval); hub.heartbeatInterval = null; } hub.reconnectTimer = setTimeout(() => connectHub(hub), hub.reconnectDelay); hub.reconnectDelay = Math.min(hub.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS); @@ -2479,17 +3711,64 @@ if (process.env.HIVE_RELAY_TEST_MODE === '1') { PANE_STATE_WORKING, PANE_STATE_BLOCKED_ON_HUMAN, PANE_STATE_IDLE_COMPLETE, + PANE_STATE_TRANSIENT_API_ERROR, + PANE_STATE_FATAL_API_ERROR, + PANE_STATE_UNKNOWN_API_ERROR, + paneUnknownAPIErrorLine, + paneShowsTransientAPIError, + paneShowsUnretryableAPIError, + paneShowsLoginRequiredError, + handleTransientAPIError, + resetTransientNudgeState, + classifyBlockedOnHumanReason, + BLOCKED_REASON_QUESTION, + BLOCKED_REASON_MENU, + BLOCKED_REASON_HUMAN_REQUIRED, + maybeSendAutonomyNudge, + resetAutonomyNudgeState, + AUTONOMY_NUDGE_MESSAGE, + tmuxSessionHasAttachedClient, + tmuxSessionHumanPresence, + HUMAN_PRESENCE_IDLE_MS, + TRANSIENT_API_ERROR_MAX_NUDGES, + TRANSIENT_API_ERROR_NUDGE_MESSAGE, + getTransientNudgeCount: () => transientNudgeCount, + __clearTransientNudgeCooldown: () => { lastTransientNudgeAt = 0; }, // Run one progress tick with the grace period already elapsed. __crashTick: () => { taskAssignedAt = Date.now() - TASK_GRACE_PERIOD_MS - 1; progressTick(); }, paneStalled, paneStallConfirmed, + paneChangedSince, resetPaneStallClock, PANE_STALL_CONFIRM_TICKS, + // Completion-signal surface (kubestellar/hive#5376). + CHROME_IDLE_GRACE_TICKS, + HIVE_VERDICT_COMPLETE, + HIVE_VERDICT_NO_WORK, + detectHiveVerdict, + detectCompletionVerdict, + recordChromeIdleTick, + resetChromeIdleGrace, + getChromeIdleTicks: () => chromeIdleTicks, + // Max-duration lease surface (kubestellar/hive#5321). + MAX_TASK_DURATION_MS, + ABSOLUTE_TASK_DEADLINE_MS, + HEADLESS_TASK_TIMEOUT_MS, + armTaskProgressLease, + onTaskProgressLeaseExpired, + getTaskTimeoutHandle: () => taskTimeoutHandle, + // Backdate the task-assignment clock so the absolute backstop can be + // crossed without waiting hours. + __ageTaskAssignedAt: (ms) => { if (taskAssignedAt) taskAssignedAt -= ms; }, + setTaskAssignedAt: (v) => { taskAssignedAt = v; }, + getTaskAssignedAt: () => taskAssignedAt, getStallConfirmCount: () => stallConfirmCount, launchCommandWithCwd, cliProcessLooksGone, paneForegroundCommand, quitLiveCLI, + stopAgentForTaskExit, + dropTaskCredential, CLI_GONE_CONFIRMATIONS, PANE_STALL_TIMEOUT_MS, // Backdate the stall clock so a test can cross the timeout without @@ -2507,6 +3786,9 @@ if (process.env.HIVE_RELAY_TEST_MODE === '1') { refreshDetectedModel, effectiveModel, progressModelFields, + effectiveProvider, + effectiveSelectionFields, + PI_SELECTION, __setDetectedModel: (v) => { detectedModel = v; }, MAX_TASK_CLI_RESTARTS, setCliReady: (v) => { cliReady = v; }, @@ -2532,6 +3814,7 @@ if (process.env.HIVE_RELAY_TEST_MODE === '1') { parseProtocolVersion, classifyPeerProtocol, warnOnProtocolDrift, + describeWsClose, // Headless (non-interactive) mode surface (kubestellar/hive#2538). CONTRIBUTOR_MODE, MODE_INTERACTIVE, @@ -2545,6 +3828,11 @@ if (process.env.HIVE_RELAY_TEST_MODE === '1') { buildHeadlessArgv, runHeadlessTask, getHeadlessChild: () => headlessChild, + // Attach-hint surface (kubestellar/hive#5145): the exact command the + // needs-authentication banner tells a human to paste. + ATTACH_COMMAND, + CONTAINER_NAME, + CONTAINER_RUNTIME, // Coverage for previously untested pure/isolated functions (#4267). redactTokens, detectNoWorkVerdict, @@ -2569,6 +3857,13 @@ if (process.env.HIVE_RELAY_TEST_MODE === '1') { __setGivenUp: (key, at) => { givenUpTasks.set(key, at); }, }; } else { + if (BACKEND === 'pi' && !PI_SELECTION.valid) { + console.error(`FATAL: ${PI_SELECTION.error}`); + if (CONTRIBUTOR_MODE === MODE_HEADLESS) { + writeHeadlessStatus(HEADLESS_STATE_FAILED, { result: 'failed', reason: PI_SELECTION.error }); + } + process.exit(1); + } // Warm the capability cache BEFORE the first hub connection. detectCapabilities() // is called from the auth_challenge handler, and the hub bounds a handshake at // 30s (wsAuthTimeout); doing the probes here keeps every one of them — backend diff --git a/bin/contributor-relay.test.js b/bin/contributor-relay.test.js index 209e7c51b..41b26edae 100644 --- a/bin/contributor-relay.test.js +++ b/bin/contributor-relay.test.js @@ -12,6 +12,7 @@ const assert = require('assert'); const Module = require('module'); const path = require('path'); const fs = require('fs'); +const piBackend = require('./pi-backend.js'); // Set for the whole run, not just during module load: the relay checks it at // CALL time in sleepMs() to skip its busy-wait, and the restart paths sleep for @@ -25,17 +26,29 @@ const RELAY_PATH = path.join(__dirname, 'contributor-relay.sh'); // bash and no WebSocket are ever touched. // --------------------------------------------------------------------------- -function loadRelay({ backend = 'copilot', backendBinary = null, backendPerm = '--allow-all', model = '', reasoningEffort = '', cliStates = ['ready'], procAlive = true, mode = 'interactive', execFileResult = null, statusFile = null, paneText = null, env = null, cliVersion = null } = {}) { +function loadRelay({ backend = 'copilot', backendBinary = null, backendPerm = '--allow-all', model = '', reasoningEffort = '', cliStates = ['ready'], procAlive = true, mode = 'interactive', execFileResult = null, statusFile = null, paneText = null, env = null, cliVersion = null, attachedClients = false, attachedIdleMs = 0, clientActivityRaw = null, listClientsThrows = false } = {}) { const commands = []; const sent = []; // Records every execFile (headless one-shot) invocation: { bin, args, opts }. const execFileCalls = []; + const deferredExecFileCallbacks = []; let stateIdx = 0; // Guard against a runaway loop in the code under test eating all memory. const MAX_RECORDED_COMMANDS = 10000; + // #5281: lets a test model a tmux send that fails, so the one-shot budget's + // behaviour on a throwing send is pinned rather than assumed. + let failNextLiteralSend = false; + const fakeExecSync = (cmd) => { if (commands.length < MAX_RECORDED_COMMANDS) commands.push(cmd); + // Recorded BEFORE throwing: a test needs to see that the send was + // ATTEMPTED, which is the difference between "spent the budget" and + // "retried every tick". + if (failNextLiteralSend && /send-keys\b.*\s-l\s/.test(cmd)) { + failNextLiteralSend = false; + throw new Error('tmux: server exited unexpectedly'); + } // backendBinary lets a test model backends.conf mapping a backend NAME to a // different BINARY (litellm → claude); it defaults to the identity mapping // every other backend has. @@ -56,6 +69,20 @@ function loadRelay({ backend = 'copilot', backendBinary = null, backendPerm = '- if (typeof state === 'string' && state.includes('\n')) return state; return 'dev@host:~$ \n'; } + if (/list-clients/.test(cmd)) { + // #5094: the relay asks whether a human is attached before it types a + // retry into the pane. An empty answer means nobody is watching. + // + // #5277: the question is now "has anyone typed recently", asked as + // `-F '#{client_activity}'`, so the stub answers in tmux's own currency — + // epoch SECONDS of last input. attachedIdleMs defaults to 0, i.e. a + // client that just typed, which is what every pre-#5277 test meant by + // `attachedClients: true`. + if (listClientsThrows) throw new Error('tmux: no server running'); + if (!attachedClients) return ''; + if (clientActivityRaw !== null) return clientActivityRaw; + return `${Math.floor((Date.now() - attachedIdleMs) / 1000)}\n`; + } if (/display-message/.test(cmd)) { // The relay asks the PANE what it is running (pane_current_command). // procAlive:false models a CLI that exited and left the pane at a shell. @@ -79,7 +106,9 @@ function loadRelay({ backend = 'copilot', backendBinary = null, backendPerm = '- execFileCalls.push({ bin, args, opts: typeof opts === 'function' ? {} : opts }); const child = { killed: false, kill() { this.killed = true; } }; const r = execFileResult || {}; - if (callback) { + if (callback && r.defer) { + deferredExecFileCallbacks.push(callback); + } else if (callback) { // Mirror execFile's async contract closely enough for the relay's logic: // callback(err, stdout, stderr). callback(r.err || null, r.stdout || '', r.stderr || ''); @@ -167,9 +196,15 @@ function loadRelay({ backend = 'copilot', backendBinary = null, backendPerm = '- const ws = new stubs.ws(); relay.setWs(ws); relay.__commands = commands; + relay.__failNextNudge = () => { failNextLiteralSend = true; }; relay.__sent = sent; relay.__tmpDir = tmpDir; relay.__execFileCalls = execFileCalls; + relay.__completeDeferredExecFile = (err, stdout = '', stderr = '') => { + const callback = deferredExecFileCallbacks.shift(); + assert.ok(callback, 'no deferred execFile callback is pending'); + callback(err, stdout, stderr); + }; relay.__headlessStatusFile = headlessStatusFile; relay.__readHeadlessStatus = () => { try { return JSON.parse(fs.readFileSync(headlessStatusFile, 'utf8')); } catch (_) { return null; } @@ -184,6 +219,19 @@ function teardown(relay) { try { fs.rmSync(relay.__tmpDir, { recursive: true, force: true }); } catch (_) {} } +// Drive a full chrome-idle grace window (kubestellar/hive#5376). +// +// A pane that merely LOOKS idle no longer completes a task on the first tick: +// classifyTmuxPane was demoted to liveness, and chrome alone must hold idle for +// CHROME_IDLE_GRACE_TICKS consecutive ticks before it may conclude anything. A +// test that wants the fallback completion therefore has to tick that many +// times. Tests exercising the SENTINEL path do not need this — that is the +// whole point of the sentinel, and several tests below assert exactly that by +// completing in one tick. +function graceTicks(relay, tick) { + for (let i = 0; i < relay.CHROME_IDLE_GRACE_TICKS; i++) tick(); +} + const tests = []; function test(name, fn) { tests.push([name, fn]); } @@ -502,10 +550,14 @@ test('agy Gemini idle pane reports its visible PR as task_complete', () => { const relay = loadRelay({ backend: 'agy', paneText: AGY_GEMINI_IDLE_PANE }); try { assignTask(relay, 'ct-agy-gemini-idle'); - relay.__crashTick(); + // #5376: this pane carries no HIVE_VERDICT line, so it completes on the + // chrome-idle FALLBACK — after the grace window, not on the first tick. + graceTicks(relay, () => relay.__crashTick()); const complete = relay.__sent.find(m => m.type === 'task_complete'); assert.ok(complete, 'the live agy/Gemini pane shape must complete the task'); assert.strictEqual(complete.pr_url, 'https://github.com/foo/bar/pull/9'); + assert.strictEqual(complete.completion_signal, 'chrome_idle', + 'a completion inferred from chrome must be labelled as such'); } finally { teardown(relay); } }); @@ -692,8 +744,17 @@ test('a pane that reaches real IDLE_COMPLETE between stall ticks is reported as assert.strictEqual(relay.getStallConfirmCount(), 1); // The slow network call the pane was blocked on finally returns. capture = 'Pull request opened: foo/bar#4061 https://github.com/foo/bar/pull/4061\n? for shortcuts'; - relay.__agePaneStallClock(relay.PANE_STALL_TIMEOUT_MS + 1); - relay.__stallTick(); + // #5376: no HIVE_VERDICT line in this capture, so it takes the chrome-idle + // fallback and needs the grace window. The stall clock is aged past its + // timeout on every one of those ticks DELIBERATELY: an idle pane is + // byte-identical frame to frame, so if the grace window let the stall + // backstop keep running underneath it, this shipped PR would be handed back + // as an `environment` failure — the #4127 shape, reintroduced. The + // IDLE_COMPLETE branch must own the pane for the whole window. + graceTicks(relay, () => { + relay.__agePaneStallClock(relay.PANE_STALL_TIMEOUT_MS + 1); + relay.__stallTick(); + }); const completed = relay.__sent.filter(m => m.type === 'task_complete'); assert.strictEqual(completed.length, 1, `late completion must be reported as completed, not failed: ${JSON.stringify(relay.__sent.map(m => m.type))}`); @@ -758,6 +819,145 @@ test('goose is also excluded from --model', () => { } finally { teardown(relay); } }); +// --------------------------------------------------------------------------- +// Pi provider/model, readiness and receipts (kubestellar/hive#5039). +// --------------------------------------------------------------------------- + +test('Pi accepts exactly one canonical provider/model selection', () => { + assert.deepStrictEqual(piBackend.parsePiModelSelection('openrouter/moonshotai/kimi-k2.6'), { + valid: true, + state: 'configured', + provider: 'openrouter', + model: 'moonshotai/kimi-k2.6', + canonical: 'openrouter/moonshotai/kimi-k2.6', + }); + for (const bad of ['', 'openai', '/gpt-5', 'openai/', 'open ai/gpt-5', 'openai/--provider', 'openai/gpt;id']) { + assert.strictEqual(piBackend.parsePiModelSelection(bad).valid, false, `accepted malformed Pi model ${JSON.stringify(bad)}`); + } +}); + +test('Pi container staging retains only the selected provider credentials', () => { + const tmpDir = fs.mkdtempSync(path.join(__dirname, '..', '.relay-test-tmp', 'pi-stage-')); + const agentDir = path.join(tmpDir, 'agent'); + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(path.join(agentDir, 'auth.json'), JSON.stringify({ openai: { key: 'selected-key' }, anthropic: { key: 'unrelated-key' } })); + fs.writeFileSync(path.join(agentDir, 'models.json'), JSON.stringify({ providers: { openai: { apiKey: 'selected-custom-key' }, anthropic: { apiKey: 'unrelated-custom-key' } }, defaults: {} })); + try { + const selection = piBackend.parsePiModelSelection('openai/gpt-5'); + piBackend.narrowPiStage(tmpDir, selection); + assert.deepStrictEqual(Object.keys(JSON.parse(fs.readFileSync(path.join(agentDir, 'auth.json')))), ['openai']); + const models = JSON.parse(fs.readFileSync(path.join(agentDir, 'models.json'))); + assert.deepStrictEqual(Object.keys(models.providers), ['openai']); + assert.deepStrictEqual(piBackend.providerCredentialEnvNames(selection), ['OPENAI_API_KEY']); + assert.ok(piBackend.unselectedProviderCredentialEnvNames(selection).includes('ANTHROPIC_API_KEY')); + assert.ok(!piBackend.unselectedProviderCredentialEnvNames(selection).includes('OPENAI_API_KEY')); + assert.strictEqual( + piBackend.redactPiCredentials('selected-custom-key unrelated-custom-key', selection, { PI_CODING_AGENT_DIR: agentDir }), + '***REDACTED*** unrelated-custom-key', + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('Pi initial/restart command transports the same canonical model and no competing provider flag', () => { + const relay = loadRelay({ backend: 'pi', model: 'google/gemini-2.5-pro', env: { GOOSE_MODEL: 'wrong-goose/model' } }); + try { + const initial = relay.buildLaunchCommand(); + assert.match(initial, /--model google\/gemini-2\.5-pro/); + assert.ok(!/--provider/.test(initial), `canonical model is sufficient; got ${initial}`); + assert.ok(!/wrong-goose/.test(initial), `Pi inherited GOOSE_MODEL: ${initial}`); + relay.relaunchCLI(); + const restart = relay.__tmuxSends().find(c => /google\/gemini-2\.5-pro/.test(c)); + assert.ok(restart, 'Pi restart dropped the effective provider/model'); + } finally { teardown(relay); } +}); + +test('Pi readiness distinguishes configured credentials from verified authentication', () => { + const key = 'synthetic-invalid-openai-key'; + const relay = loadRelay({ backend: 'pi', model: 'openai/gpt-5', cliVersion: 'pi 0.73.1\n', env: { OPENAI_API_KEY: key } }); + try { + relay.handleMessage(JSON.stringify({ type: 'auth_challenge', seq: 1, nonce: 'n' })); + const auth = relay.__sent.find(m => m.type === 'auth_response'); + assert.strictEqual(auth.model, 'openai/gpt-5'); + assert.strictEqual(auth.provider, 'openai'); + assert.strictEqual(relay.effectiveProvider(), 'openai'); + assert.strictEqual(auth.capabilities.pi_binary, 'present'); + assert.strictEqual(auth.capabilities.pi_configuration, 'configured'); + assert.strictEqual(auth.capabilities.pi_authentication, 'configured_unverified'); + assert.strictEqual(auth.capabilities.pi_invocation, 'untested'); + assert.ok(!JSON.stringify(auth).includes(key), 'Pi credential leaked into readiness evidence'); + assert.strictEqual(relay.redactTokens(`provider rejected ${key}`), 'provider rejected ***REDACTED***'); + } finally { teardown(relay); } +}); + +test('Pi headless argv and completion receipt name effective selection, generation and result', () => { + const relay = loadRelay({ backend: 'pi', backendPerm: '', mode: 'headless', model: 'openai/gpt-5', cliVersion: 'pi 0.73.1', env: { OPENAI_API_KEY: 'synthetic-invalid-key' } }); + try { + const argv = relay.buildHeadlessArgv('make the change'); + assert.strictEqual(argv.bin, 'pi'); + assert.deepStrictEqual(argv.args, ['--model', 'openai/gpt-5', '--print', '--mode', 'json', 'make the change']); + const task = { task_id: 'pi-1', task_gen: 17, kind: 'issue', repo: 'x/y', number: 1, title: 'Pi' }; + relay.setCurrentTask(task); + relay.runHeadlessTask(task); + const complete = relay.__sent.find(m => m.type === 'task_complete'); + assert.ok(complete, 'Pi exit 0 produced no completion receipt'); + assert.strictEqual(complete.cli_backend, 'pi'); + assert.strictEqual(complete.provider, 'openai'); + assert.strictEqual(complete.model, 'openai/gpt-5'); + assert.strictEqual(complete.task_gen, 17); + assert.strictEqual(complete.result, 'completed'); + const status = relay.__readHeadlessStatus(); + assert.strictEqual(status.pi_authentication, 'verified'); + assert.strictEqual(status.pi_invocation, 'succeeded'); + assert.strictEqual(status.task_gen, undefined, 'waiting status must not retain a stale assignment generation'); + } finally { teardown(relay); } +}); + +test('Pi provider/model resolution failure is bounded, redacted environment evidence', () => { + const key = 'synthetic-invalid-openai-key'; + const error = Object.assign(new Error('Pi exited'), { code: 1 }); + const relay = loadRelay({ + backend: 'pi', + mode: 'headless', + model: 'openai/not-a-real-model', + cliVersion: 'pi 0.73.1', + env: { OPENAI_API_KEY: key }, + execFileResult: { err: error, stderr: `Unknown model; attempted credential ${key}` }, + }); + try { + const task = { task_id: 'pi-bad-model', task_gen: 18, kind: 'issue', repo: 'x/y', number: 3, title: 'bad model' }; + relay.setCurrentTask(task); + relay.runHeadlessTask(task); + const failed = relay.__sent.find(m => m.type === 'task_failed'); + assert.ok(failed, 'Pi resolver failure produced no failure receipt'); + assert.strictEqual(failed.failure_kind, 'environment'); + assert.strictEqual(failed.cli_backend, 'pi'); + assert.strictEqual(failed.provider, 'openai'); + assert.strictEqual(failed.model, 'openai/not-a-real-model'); + assert.strictEqual(failed.task_gen, 18); + assert.ok(!JSON.stringify(failed).includes(key), 'Pi failure receipt leaked its provider credential'); + const status = relay.__readHeadlessStatus(); + assert.strictEqual(status.pi_authentication, 'configured_unverified'); + assert.strictEqual(status.pi_invocation, 'failed'); + assert.ok(!JSON.stringify(status).includes(key), 'Pi failure status leaked its provider credential'); + } finally { teardown(relay); } +}); + +test('Pi revoke kills the child and rejects a raced stale completion', () => { + const relay = loadRelay({ backend: 'pi', mode: 'headless', model: 'openai/gpt-5', execFileResult: { defer: true }, cliVersion: 'pi 0.73.1' }); + try { + const task = { task_id: 'pi-revoke', task_gen: 22, kind: 'issue', repo: 'x/y', number: 2, title: 'revoke' }; + relay.setCurrentTask(task); + relay.runHeadlessTask(task); + const child = relay.getHeadlessChild(); + relay.handleMessage(JSON.stringify({ type: 'task_revoke', task_id: task.task_id, reason: 'operator stop' })); + assert.strictEqual(child.killed, true, 'revoke did not kill Pi'); + relay.__completeDeferredExecFile(null, 'late success', ''); + assert.ok(!relay.__sent.some(m => m.type === 'task_complete' && m.task_id === task.task_id), 'revoked Pi emitted stale completion'); + } finally { teardown(relay); } +}); + // --------------------------------------------------------------------------- // Bug 2 — a task prompt must never be typed into a pane that is not confirmed // ready, or the literal keystrokes land on bash and wedge it in PS2. @@ -766,6 +966,56 @@ test('goose is also excluded from --model', () => { const PROMPT_WITH_APOSTROPHES = "Work on issue foo/bar#421. Fork it with 'gh repo fork foo/bar --clone=false' first."; +// --------------------------------------------------------------------------- +// kubestellar/hive#5090 — a close must be diagnosable. +// +// The close handler used to ignore the code and reason entirely and log only +// "closed. Reconnecting in 1000ms...". A contributor whose socket flapped every +// 30-90 seconds therefore could not tell a deliberate server hangup from a +// network drop, and the backoff carried no signal either — it never grows past +// 1s because each reconnect succeeds. +// --------------------------------------------------------------------------- + +test('#5090 a 1006 close is named as a cut socket, not a stated reason', () => { + const relay = loadRelay({ backend: 'claude' }); + try { + const out = relay.describeWsClose(1006, ''); + assert.ok(out.includes('1006'), 'the code itself must appear'); + assert.ok(/no close frame/.test(out), + '1006 is synthesised by the client when the peer never sent a frame — say so'); + assert.ok(/network|proxy|abrupt/.test(out), + 'name the causes that actually produce it, so the reader knows where to look next'); + } finally { teardown(relay); } +}); + +test('#5090 a close carrying a stated reason reports both code and text', () => { + const relay = loadRelay({ backend: 'claude' }); + try { + const out = relay.describeWsClose(1008, 'invalid registration token'); + assert.ok(out.includes('1008')); + assert.ok(out.includes('policy violation'), 'known codes get their name'); + assert.ok(out.includes('invalid registration token'), 'the server-stated reason must survive to the log'); + } finally { teardown(relay); } +}); + +test('#5090 an unknown close code is reported by number rather than guessed at', () => { + const relay = loadRelay({ backend: 'claude' }); + try { + const out = relay.describeWsClose(4999, ''); + assert.ok(out.includes('4999')); + assert.ok(!/undefined/.test(out), 'an unnamed code must not render as "undefined"'); + } finally { teardown(relay); } +}); + +test('#5090 a normal closure with no text still identifies itself', () => { + const relay = loadRelay({ backend: 'claude' }); + try { + const out = relay.describeWsClose(1000, ''); + assert.ok(out.includes('1000') && out.includes('normal closure')); + assert.ok(!out.trim().endsWith(':'), 'no dangling separator when there is no reason text'); + } finally { teardown(relay); } +}); + test('task prompt is queued, not typed, while cliReady is false', () => { const relay = loadRelay({ backend: 'copilot' }); try { @@ -823,6 +1073,35 @@ test('task_assign queues rather than typing when the CLI is not ready', () => { } finally { teardown(relay); } }); +test('task_assign never persists github_token to the task file (kubestellar/hive#5065)', () => { + const relay = loadRelay({ backend: 'copilot' }); + try { + relay.setCliReady(false); + relay.setPendingTask(null); + relay.handleMessage(JSON.stringify({ + type: 'task_assign', + task_id: 'ct-token-1', + kind: 'issue', + repo: 'foo/bar', + number: 422, + title: 'token hygiene', + prompt: 'do a thing', + github_token: `ghs_${'a'.repeat(36)}`, + token_expires_at: '2099-01-01T00:00:00Z', + })); + + const taskFile = path.join(relay.__tmpDir, 'contributor-task.json'); + const raw = fs.readFileSync(taskFile, 'utf8'); + assert.ok(!raw.includes('ghs_'), 'task file must not contain the credential value'); + const persisted = JSON.parse(raw); + assert.ok(!('github_token' in persisted), 'github_token key must be stripped from the task file'); + assert.strictEqual(persisted.token_expires_at, '2099-01-01T00:00:00Z', + 'non-secret task fields must survive the strip'); + const mode = fs.statSync(taskFile).mode & 0o777; + assert.strictEqual(mode, 0o600, `task file must be owner-only, got 0o${mode.toString(8)}`); + } finally { teardown(relay); } +}); + test('auth_response includes optional HIVE_AGENT_ROLE', () => { const relay = loadRelay({ env: { HIVE_AGENT_ROLE: 'scanner' } }); try { @@ -1295,12 +1574,48 @@ test('claude bypass-permissions idle footer is not itself a blocked prompt', () } finally { teardown(relay); } }); +test('#5162 claude with a background shell still running is COMPLETE', () => { + const relay = loadRelay({ backend: 'claude' }); + try { + // Live idle pane: the shell indicator displaces "shift+tab to cycle", but + // the turn has ended and Claude's persistent footer chrome remains. + const pane = [ + '✻ Cogitated for 10m 31s · 1 shell still running', + '❯', + ' ⏵⏵ auto mode on · 1 shell · ← for agents · ↓ to manage', + ].join('\n'); + assert.strictEqual(relay.classifyTmuxPane(pane), relay.PANE_STATE_IDLE_COMPLETE, + 'a background shell is orthogonal to whether the Claude turn finished'); + } finally { teardown(relay); } +}); + +test('#5162 claude busy chrome wins over idle-looking footer chrome', () => { + const relay = loadRelay({ backend: 'claude' }); + try { + // A duration line from an older turn and the persistent ⏵⏵ chrome must not + // hide Claude's explicit marker for the turn currently in flight. + const pane = [ + '✻ Cogitated for 2m 10s', + '● Running the focused tests now.', + '❯', + ' ⏵⏵ auto mode on · esc to interrupt', + ].join('\n'); + assert.strictEqual(relay.classifyTmuxPane(pane), relay.PANE_STATE_WORKING, + 'esc to interrupt must prevent a busy Claude turn from completing'); + } finally { teardown(relay); } +}); + test('blocked interactive panes report attention instead of task_complete', () => { const blockedPane = 'Should I open a pull request for this change?\n> \n'; const relay = loadRelay({ backend: 'goose', cliStates: [blockedPane, blockedPane] }); try { relay.setCliReady(true); assignTask(relay, 'ct-blocked'); + // #5281: an unattended question now gets ONE autonomy reminder first. The + // guarantee this test exists for is unchanged and asserted below -- never a + // completion, task stays active -- but it is now the SECOND tick that + // reports it, once the one-shot budget is spent. + relay.__crashTick(); relay.__crashTick(); assert.ok(!relay.__sent.some(m => m.type === 'task_complete'), @@ -1321,6 +1636,10 @@ test('goose elicitation form is reported as blocked, never as task_complete (#28 try { relay.setCliReady(true); assignTask(relay, 'ct-elicit'); + // #5281: one autonomy reminder first (a form is a question), then today's + // report from the second tick on. The never-a-completion guarantee below is + // what this test is for and is unchanged. + relay.__crashTick(); relay.__crashTick(); assert.ok(!relay.__sent.some(m => m.type === 'task_complete'), @@ -1332,6 +1651,275 @@ test('goose elicitation form is reported as blocked, never as task_complete (#28 } finally { teardown(relay); } }); +// --------------------------------------------------------------------------- +// kubestellar/hive#5281 — an unattended agent that stops to ask a question gets +// one reminder to proceed on its own. +// +// Detection without recovery was the gap: the relay already SAW the question +// and raised `attention`, but an attention flag only helps someone watching, +// and a contributor run by a user who never attaches to tmux is a supported way +// to run one. For that user every question cost 20-30 minutes and a failed +// task, even though the task prompt had already told the agent to decide for +// itself. +// +// The dangerous half is telling a question apart from a prompt only a person +// can answer. Typing "proceed autonomously" into a /login flow, or submitting +// it as a password, is worse than waiting — so those panes are vetoed, and the +// veto is asked of the whole recent window rather than just the cursor line. +// --------------------------------------------------------------------------- + +const QUESTION_PANE = 'Should I open a pull request for this change?\n> \n'; + +function nudges(relay, from = 0) { + return relay.__tmuxSends().slice(from).filter(c => c.includes(relay.AUTONOMY_NUDGE_MESSAGE)); +} + +test('#5281 an unattended question gets exactly one autonomy reminder', () => { + const relay = loadRelay({ backend: 'goose', cliStates: [QUESTION_PANE, QUESTION_PANE], attachedClients: false }); + try { + relay.setCliReady(true); + assignTask(relay, 't-question'); + const before = relay.__tmuxSends().length; + + relay.__crashTick(); + assert.strictEqual(nudges(relay, before).length, 1, 'the first tick reminds it to proceed'); + assert.strictEqual(relay.__sent.filter(m => m.status === 'blocked_on_human').length, 0, + 'nobody is attached to be blocked on, so the first tick does not raise attention'); + + relay.__crashTick(); + assert.strictEqual(nudges(relay, before).length, 1, + 'a question re-asked after the reminder is one the agent cannot answer — do not loop'); + const blocked = relay.__sent.filter(m => m.status === 'blocked_on_human'); + assert.strictEqual(blocked.length, 1, 'from the second tick on, behaviour is exactly today\'s'); + assert.strictEqual(blocked[0].attention, true); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_complete').length, 0, + 'a blocked pane must never be booked as a completion, nudged or not'); + assert.ok(relay.getCurrentTask(), 'the task stays active'); + } finally { teardown(relay); } +}); + +test('#5281 the budget is per task, not per process', () => { + // Driven through the REAL lifecycle — ask, finish, get assigned again — + // rather than by calling the reset directly, so that a change which dropped + // resetAutonomyNudgeState() from the task-start path would fail here. + const DONE_PANE = [ + '● Done — opened https://github.com/kubestellar/hive/pull/9999', + '', + '✻ Cogitated for 3m 30s', + '', + '❯ ', + ' ⏵⏵ auto mode on (shift+tab to cycle)', + ].join('\n'); + let pane = QUESTION_PANE; + const relay = loadRelay({ backend: 'claude', paneText: () => pane, attachedClients: false }); + try { + relay.setCliReady(true); + assignTask(relay, 't-first'); + const before = relay.__tmuxSends().length; + relay.__crashTick(); + relay.__crashTick(); + assert.strictEqual(nudges(relay, before).length, 1, 'one reminder for the first task'); + + pane = DONE_PANE; + // #5376: DONE_PANE is chrome only — no HIVE_VERDICT line — so it takes the + // grace-window fallback rather than completing on the first tick. + graceTicks(relay, () => relay.__crashTick()); + assert.ok(!relay.getCurrentTask(), 'the first task should have completed'); + + // A fresh task must get its own reminder — a previous task's spent budget + // denying this one is the same bug #5094 fixed for the retry budget. + pane = QUESTION_PANE; + assignTask(relay, 't-second'); + const mid = relay.__tmuxSends().length; + relay.__crashTick(); + assert.strictEqual(nudges(relay, mid).length, 1, 'the next task gets its own one-shot'); + } finally { teardown(relay); } +}); + +test('#5281 an attached pane is never nudged', () => { + const relay = loadRelay({ backend: 'goose', cliStates: [QUESTION_PANE, QUESTION_PANE], attachedClients: true }); + try { + relay.setCliReady(true); + assignTask(relay, 't-attached-question'); + const before = relay.__tmuxSends().length; + relay.__crashTick(); + assert.strictEqual(nudges(relay, before).length, 0, + 'someone is there to answer — do not type over them'); + const blocked = relay.__sent.filter(m => m.status === 'blocked_on_human'); + assert.strictEqual(blocked.length, 1, 'and the attention report is unchanged'); + assert.strictEqual(blocked[0].attention, true); + } finally { teardown(relay); } +}); + +test('#5281 a login pane is never nudged, even when it is phrased as a question', () => { + // #4400: only a human can log in, so typing the reminder here would put the + // literal string into a /login flow. + // + // The second case is the one that makes the explicit login veto load-bearing + // rather than decorative. A bare 401 pane is already not a question, so the + // reason classifier alone would refuse it; a 401 whose next line ASKS + // something classifies as a perfectly ordinary question, and only the + // paneShowsLoginRequiredError check stops it being nudged. + const cases = { + 'bare 401': '● Please run /login · API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"OAuth token has expired"}}\n\n❯ \n', + '401 phrased as a question': [ + '● Please run /login · API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"OAuth token has expired"}}', + 'Would you like to log in now?', + '❯ ', + ].join('\n'), + }; + for (const [name, loginPane] of Object.entries(cases)) { + const relay = loadRelay({ backend: 'claude', paneText: loginPane, attachedClients: false }); + try { + assert.strictEqual(relay.classifyTmuxPane(loginPane), relay.PANE_STATE_BLOCKED_ON_HUMAN, name); + relay.setCliReady(true); + assignTask(relay, `t-login-${name.replace(/\W+/g, '-')}`); + const before = relay.__tmuxSends().length; + relay.__crashTick(); + assert.strictEqual(nudges(relay, before).length, 0, `${name} must never be nudged`); + assert.strictEqual(relay.__sent.filter(m => m.status === 'blocked_on_human').length, 1, name); + } finally { teardown(relay); } + } +}); + +test('#5281 human-required prompts are never nudged', () => { + // Each of these is a pane where typing prose is actively harmful: it would be + // submitted as a credential, or would answer a trust/permission decision the + // agent is not entitled to make. + const panes = { + 'credential entry': 'Paste your API key to continue:\n> \n', + 'folder trust': 'Do you trust this folder?\n> \n', + 'permission prompt': 'Allow Claude to run this command?\n> \n', + 'consent': 'This action requires your approval before continuing.\n> \n', + }; + for (const [name, pane] of Object.entries(panes)) { + const relay = loadRelay({ backend: 'goose', cliStates: [pane, pane], attachedClients: false }); + try { + assert.strictEqual(relay.classifyBlockedOnHumanReason(pane), relay.BLOCKED_REASON_HUMAN_REQUIRED, + `${name} must classify as human-required`); + relay.setCliReady(true); + assignTask(relay, `t-${name.replace(/\s+/g, '-')}`); + const before = relay.__tmuxSends().length; + relay.__crashTick(); + assert.strictEqual(nudges(relay, before).length, 0, `${name} must never be nudged`); + assert.strictEqual(relay.__sent.filter(m => m.status === 'blocked_on_human').length, 1, + `${name} must still report blocked_on_human`); + } finally { teardown(relay); } + } +}); + +test('#5281 the human-required veto beats a trailing question mark anywhere in the window', () => { + // The precedence rule, and the reason the veto reads the whole recent window: + // a trust dialog renders its heading a few lines up while the cursor line is + // an innocent-looking question. Classifying on the cursor line alone would + // nudge it. + const pane = [ + 'Confirm folder trust', + 'This folder has not been opened before.', + '', + 'Do you want to proceed?', + '> ', + ].join('\n'); + const relay = loadRelay({ backend: 'goose', cliStates: [pane, pane], attachedClients: false }); + try { + assert.strictEqual(relay.classifyBlockedOnHumanReason(pane), relay.BLOCKED_REASON_HUMAN_REQUIRED, + 'when in doubt, human-required — waiting is cheaper than a wrong answer'); + relay.setCliReady(true); + assignTask(relay, 't-trust-question'); + const before = relay.__tmuxSends().length; + relay.__crashTick(); + assert.strictEqual(nudges(relay, before).length, 0); + } finally { teardown(relay); } +}); + +test('#5281 a numbered menu is left in today\'s behaviour, deliberately', () => { + // Pinned rather than implemented: a menu TUI may read typed text as a + // selection filter rather than as chat input, so nudging one needs Escape + // handling this version does not attempt. If that changes, this test is the + // thing that should be rewritten, not deleted. + // Worded to match the shipping hasNumberedMenu detector: a choose/select + // lead-in, a menu-shaped line above the prompt, and two or more numbered + // options. + const menuPane = [ + 'Please choose how to continue:', + '', + '❯ 1. Rebase onto main', + ' 2. Merge main in', + ' 3. Leave it alone', + '', + '> ', + ].join('\n'); + const relay = loadRelay({ backend: 'goose', cliStates: [menuPane, menuPane], attachedClients: false }); + try { + assert.strictEqual(relay.classifyBlockedOnHumanReason(menuPane), relay.BLOCKED_REASON_MENU); + relay.setCliReady(true); + assignTask(relay, 't-menu'); + const before = relay.__tmuxSends().length; + relay.__crashTick(); + assert.strictEqual(nudges(relay, before).length, 0, 'menus are out of scope for the nudge'); + assert.strictEqual(relay.__sent.filter(m => m.status === 'blocked_on_human').length, 1, + 'and they keep reporting exactly as they do today'); + } finally { teardown(relay); } +}); + +test('#5281 an elicitation form and a y/N both count as questions', () => { + const cases = { + 'elicitation form': 'Extension needs some information to proceed:\n\n Project name: my-service\n Region: us-east-1\n\n> Enter to send\n', + 'y/N confirmation': 'Overwrite the existing branch? [y/N]\n> \n', + }; + for (const [name, pane] of Object.entries(cases)) { + const relay = loadRelay({ backend: 'goose', cliStates: [pane, pane], attachedClients: false }); + try { + assert.strictEqual(relay.classifyBlockedOnHumanReason(pane), relay.BLOCKED_REASON_QUESTION, name); + relay.setCliReady(true); + assignTask(relay, `t-${name.replace(/\W+/g, '-')}`); + const before = relay.__tmuxSends().length; + relay.__crashTick(); + assert.strictEqual(nudges(relay, before).length, 1, `${name} should be nudged`); + } finally { teardown(relay); } + } +}); + +test('#5281 an unblocked pane classifies as no reason at all', () => { + const relay = loadRelay({ backend: 'goose' }); + try { + assert.strictEqual(relay.classifyBlockedOnHumanReason('Done — opened a PR.\n> \n'), null); + assert.strictEqual(relay.classifyBlockedOnHumanReason(''), null); + } finally { teardown(relay); } +}); + +test('#5281 the reminder carries no shell metacharacters', () => { + // tmuxSendNudge interpolates this into a single-quoted `send-keys -l '...'`. + // A quote or a metacharacter here would be a command-injection shaped bug, + // not a typo, so the constraint is pinned rather than trusted. + const relay = loadRelay({ backend: 'goose' }); + try { + assert.match(relay.AUTONOMY_NUDGE_MESSAGE, /^[A-Za-z0-9 ,.]+$/, + `the nudge text must stay trivially quotable, got: ${relay.AUTONOMY_NUDGE_MESSAGE}`); + } finally { teardown(relay); } +}); + +test('#5281 a failed send still spends the budget', () => { + // A send that throws has already disturbed the pane. Retrying it every tick + // is the loop the one-shot budget exists to prevent. + const relay = loadRelay({ backend: 'goose', cliStates: [QUESTION_PANE, QUESTION_PANE], attachedClients: false }); + try { + relay.setCliReady(true); + assignTask(relay, 't-send-fails'); + const origWarn = console.error; + console.error = () => {}; + try { + relay.__failNextNudge(); + relay.__crashTick(); + relay.__crashTick(); + } finally { console.error = origWarn; } + assert.strictEqual(nudges(relay).length, 1, + 'the send was attempted exactly once and not retried on the next tick'); + assert.strictEqual(relay.__sent.filter(m => m.status === 'blocked_on_human').length, 2, + 'both ticks fell through to today\'s report'); + } finally { teardown(relay); } +}); + // --------------------------------------------------------------------------- // Multi-hub (kubestellar/hive#multi-hive) — one relay/CLI session subscribed // to more than one hub via comma-separated HIVE_HUB/HIVE_REGISTRATION_TOKEN. @@ -1445,7 +2033,7 @@ test('hub notice messages are logged for operators', () => { } }); -test('token_refresh, task_revoke, and blocked progress only affect the hub that owns the active task', () => { +test('token_refresh, task_revoke, and blocked progress only affect the hub that owns the active task', async () => { const blockedPane = 'Should I open a pull request for this change?\n> \n'; const relay = loadRelay({ backend: 'goose', cliStates: [blockedPane, blockedPane], env: MULTI_HUB_ENV }); try { @@ -1470,6 +2058,11 @@ test('token_refresh, task_revoke, and blocked progress only affect the hub that assert.strictEqual(fs.readFileSync(tokenPath, 'utf8'), 'hub-a-token'); relay.handleMessage(JSON.stringify({ type: 'task_revoke', task_id: 't1', reason: 'owner revoke' }), hubs[0]); + await Promise.resolve(); + await Promise.resolve(); + const revokeInterrupts = relay.__tmuxSends().filter(c => /C-c\s*$/.test(c)); + assert.ok(revokeInterrupts.length >= 2, 'interactive revoke must double-interrupt the configured tmux pane before ready'); + assert.strictEqual(fs.existsSync(tokenPath), false, 'revoking a task must clear its task-scoped GitHub token cache'); assert.strictEqual(relay.getCurrentTask(), null); assert.ok(sentA.some(m => m.type === 'ready'), 'owning hub is asked for work after its revoke'); assert.strictEqual(sentB.filter(m => m.type === 'ready').length, 0); @@ -1677,11 +2270,12 @@ test('buildHeadlessArgv maps each supported backend to its one-shot invocation', // agy still cannot sign in inside a pod (interactive Google OAuth, no // API-key mode), which is why the k8s manifest generator keeps warning. { backend: 'agy', tail: ['-p', PROMPT] }, - // Interactive-TUI backends with no known one-shot entry point. + // Pi has a print/JSON one-shot path and requires a canonical selection. + { backend: 'pi', model: 'openai/gpt-5', tail: ['--print', '--mode', 'json', PROMPT] }, + // Interactive-TUI backend with no known one-shot entry point. { backend: 'bob', tail: null }, - { backend: 'pi', tail: null }, ]) { - const relay = loadRelay({ backend: tc.backend, mode: 'headless' }); + const relay = loadRelay({ backend: tc.backend, mode: 'headless', model: tc.model || '' }); try { const got = relay.buildHeadlessArgv(PROMPT); if (tc.tail === null) { @@ -2257,7 +2851,7 @@ test('an idle non-active hub cannot assign work until the poll slot reaches it', } finally { teardown(relay); } }); -test('token_refresh and task_revoke only affect the hub that owns the active task', () => { +test('token_refresh and task_revoke only affect the hub that owns the active task', async () =>{ const relay = loadRelay({ env: { HIVE_HUB: 'wss://hub-a.example/contribute,wss://hub-b.example/contribute', HIVE_REGISTRATION_TOKEN: 'tok-a,tok-b', @@ -2281,6 +2875,11 @@ test('token_refresh and task_revoke only affect the hub that owns the active tas assert.strictEqual(fs.readFileSync(tokenPath, 'utf8'), 'hub-a-token'); relay.handleMessage(JSON.stringify({ type: 'task_revoke', task_id: 't1', reason: 'owner revoke' }), hubs[0]); + await Promise.resolve(); + await Promise.resolve(); + const revokeInterrupts = relay.__tmuxSends().filter(c => /C-c\s*$/.test(c)); + assert.ok(revokeInterrupts.length >= 2, 'interactive revoke must double-interrupt the configured tmux pane before ready'); + assert.strictEqual(fs.existsSync(tokenPath), false, 'revoking a task must clear its task-scoped GitHub token cache'); assert.strictEqual(relay.getCurrentTask(), null); assert.ok(sentA.some(m => m.type === 'ready'), 'owning hub is asked for work after its revoke'); assert.strictEqual(sentB.filter(m => m.type === 'ready').length, 0); @@ -2853,7 +3452,7 @@ const TOKEN_BODY = 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8'; test('#4267 redactTokens scrubs every GitHub token prefix', () => { const relay = loadRelay({}); try { - for (const prefix of ['gho_', 'ghp_', 'ghs_', 'ghu_', 'ghr_']) { + for (const prefix of ['gho_', 'ghp_', 'ghs_', 'ghu_', 'ghr_', 'github_pat_']) { const out = relay.redactTokens(`token=${prefix}${TOKEN_BODY} end`); assert.strictEqual(out, `token=${prefix}***REDACTED*** end`, `${prefix} token must be redacted, got: ${out}`); @@ -3340,6 +3939,1579 @@ test('#4267 warnOnProtocolDrift warns once per hub and stays silent when current } }); + +// --------------------------------------------------------------------------- +// kubestellar/hive#5094 — a transient API error must never read as completion. +// +// Claude Code prints a turn-duration summary ("✻ Cogitated for 9m 24s") whenever +// a turn ENDS, including when it ends in an error, and the claude branch of +// classifyTmuxPane matched exactly that line as its completion marker. So an +// errored turn was indistinguishable from a finished one and the relay reported +// task_complete for work that shipped nothing. Observed live: #5061 picked up at +// 11:46:38, booked "completed" at 11:57:40 with no PR, its half-written work +// still uncommitted. +// --------------------------------------------------------------------------- + +// The pane at the moment of the live failure: a tool row, the API error, the +// duration summary the classifier used to trust, and claude's idle chrome. +const CLAUDE_API_ERROR_PANE = [ + "● Now the app's poll loop:", + '', + ' Ran 6 shell commands', + '', + '● API Error: Connection lost mid-response. The response above may be incomplete.', + '', + '✻ Cogitated for 9m 24s', + '', + '❯ ', + ' ⏵⏵ auto mode on (shift+tab to cycle)', +].join('\n'); + +// The same pane after a turn that actually finished. +const CLAUDE_CLEAN_PANE = [ + "● Now the app's poll loop:", + '', + ' Ran 6 shell commands', + '', + '● Done — opened https://github.com/kubestellar/hive/pull/5095', + '', + '✻ Cogitated for 9m 24s', + '', + '❯ ', + ' ⏵⏵ auto mode on (shift+tab to cycle)', +].join('\n'); + +test('#5094 a claude turn ending in a transient API error does not classify as complete', () => { + const relay = loadRelay({ backend: 'claude', paneText: CLAUDE_API_ERROR_PANE }); + try { + assert.strictEqual(relay.classifyTmuxPane(CLAUDE_API_ERROR_PANE), + relay.PANE_STATE_TRANSIENT_API_ERROR, + 'the duration summary after an API error is "the turn stopped", not "the task is done"'); + } finally { teardown(relay); } +}); + +test('#5094 a claude turn that really finished still classifies as complete', () => { + // The guard that matters as much as the fix: a check broad enough to swallow + // real completions would be a worse bug than the one it closes. + const relay = loadRelay({ backend: 'claude', paneText: CLAUDE_CLEAN_PANE }); + try { + assert.strictEqual(relay.classifyTmuxPane(CLAUDE_CLEAN_PANE), + relay.PANE_STATE_IDLE_COMPLETE); + } finally { teardown(relay); } +}); + +test('#5094 the relay never reports task_complete for a turn that ended in an API error', () => { + const relay = loadRelay({ backend: 'claude', paneText: CLAUDE_API_ERROR_PANE }); + try { + relay.setCliReady(true); + assignTask(relay, 't-apierr'); + relay.__crashTick(); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_complete').length, 0, + 'an errored turn must not be booked as a completion'); + assert.ok(relay.getCurrentTask(), 'the task must still be held, not handed back as done'); + } finally { teardown(relay); } +}); + +test('#5094 the transient detector matches retryable failures only', () => { + const relay = loadRelay({ backend: 'claude' }); + try { + const retryable = [ + 'API Error: Connection lost mid-response. The response above may be incomplete.', + 'API Error: Connection error', + 'API Error: Request timed out', + 'API Error: 500 Internal Server Error', + 'API Error: 502 Bad Gateway', + 'API Error: 503 Service Unavailable', + 'API Error: 529 {"type":"overloaded_error"}', + ]; + for (const line of retryable) { + assert.ok(relay.paneShowsTransientAPIError(line), `should be retryable: ${line}`); + } + const notRetryable = [ + // Prose about an error is not an error — no "API Error:" chrome. + 'The user reported Connection lost mid-response earlier.', + // A number that merely looks like a status, under the API-error chrome. + 'API Error: request id 15003 failed validation', + // Nothing to do with the API at all. + '● Read 12 lines', + ]; + for (const line of notRetryable) { + assert.ok(!relay.paneShowsTransientAPIError(line), `should not be retryable: ${line}`); + } + } finally { teardown(relay); } +}); + +test('#5094 authorization and quota failures are never retried', () => { + // Claude Code renders every API failure under the same "API Error:" prefix, so + // the retryable list alone cannot tell an overloaded upstream from a refused + // one. Nudging these loops the agent against a wall (#4400, #4583). + const relay = loadRelay({ backend: 'claude' }); + try { + for (const line of [ + 'API Error: 403 Forbidden', + 'API Error: 403 {"message":"team not allowed to access model"}', + 'API Error: 429 {"error":{"type":"budget_exceeded"}}', + 'API Error: 429 {"error":{"message":"Budget has been exceeded!"}}', + ]) { + assert.ok(relay.paneShowsUnretryableAPIError(line), `should veto a retry: ${line}`); + } + // The chrome gate: a quota PHRASE without the "API Error:" chrome is not an + // API error. The repo's own test files contain these strings verbatim, so an + // agent working on quota-handling code can print one in a completed turn's + // summary — failing that turn would be a worse bug than the one this fixes. + for (const line of [ + 'Budget has been exceeded!', + 'I fixed the budget_exceeded handling in quota_exhaustion_test.go', + ]) { + assert.ok(!relay.paneShowsUnretryableAPIError(line), `must not veto without chrome: ${line}`); + } + // And the veto wins end to end: a 403 pane is not classified as retryable. + const forbidden = CLAUDE_API_ERROR_PANE.replace( + 'API Error: Connection lost mid-response. The response above may be incomplete.', + 'API Error: 403 Forbidden'); + assert.notStrictEqual(relay.classifyTmuxPane(forbidden), relay.PANE_STATE_TRANSIENT_API_ERROR); + } finally { teardown(relay); } +}); + +test('#5094 with nobody attached the relay types one retry per tick, within its budget', () => { + const relay = loadRelay({ backend: 'claude', paneText: CLAUDE_API_ERROR_PANE, attachedClients: false }); + try { + relay.setCliReady(true); + assignTask(relay, 't-retry'); + + // One retry per tick, up to the cap. The cooldown exists to stop the relay + // typing on every 2-minute progress tick; clearing it between ticks is how + // the test crosses it without sleeping 90 seconds three times. + for (let i = 1; i <= relay.TRANSIENT_API_ERROR_MAX_NUDGES; i++) { + relay.__clearTransientNudgeCooldown(); + const before = relay.__tmuxSends().length; + relay.__crashTick(); + const sends = relay.__tmuxSends().slice(before); + assert.ok(sends.some(c => c.includes(relay.TRANSIENT_API_ERROR_NUDGE_MESSAGE)), + `tick ${i} should have typed the retry message`); + assert.strictEqual(relay.getTransientNudgeCount(), i); + } + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_failed').length, 0, + 'the task must not be failed while retries remain'); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_complete').length, 0); + } finally { teardown(relay); } +}); + +test('#5094 the cooldown stops a retry being typed on every progress tick', () => { + const relay = loadRelay({ backend: 'claude', paneText: CLAUDE_API_ERROR_PANE, attachedClients: false }); + try { + relay.setCliReady(true); + assignTask(relay, 't-cooldown'); + relay.__crashTick(); // types retry 1 + const after = relay.__tmuxSends().length; + relay.__crashTick(); // still inside the cooldown + assert.strictEqual(relay.getTransientNudgeCount(), 1, + 'a second tick inside the cooldown must not type another retry'); + const sends = relay.__tmuxSends().slice(after); + assert.ok(!sends.some(c => c.includes(relay.TRANSIENT_API_ERROR_NUDGE_MESSAGE))); + } finally { teardown(relay); } +}); + +// A task that starts fresh gets a fresh budget: a previous task exhausting its +// retries must not deny the next one its own. Sequenced the way the hub actually +// drives it — the first task is handed back before a second is assigned, since a +// relay already holding a task does not accept another. +test('#5094 the retry budget resets per task', () => { + const relay = loadRelay({ backend: 'claude', paneText: CLAUDE_API_ERROR_PANE, attachedClients: false }); + try { + relay.setCliReady(true); + assignTask(relay, 't-first'); + for (let i = 0; i <= relay.TRANSIENT_API_ERROR_MAX_NUDGES; i++) { + relay.__clearTransientNudgeCooldown(); + relay.__crashTick(); + } + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_failed').length, 1, + 'the first task should have been handed back once its retries ran out'); + assert.ok(!relay.getCurrentTask(), 'the failed task must be released'); + + assignTask(relay, 't-second'); + assert.strictEqual(relay.getTransientNudgeCount(), 0, + 'a new task must start with a full retry budget'); + } finally { teardown(relay); } +}); + +test('#5094 an unretryable API failure is not reported complete either', () => { + // The first fix closed only the RETRYABLE case. A 403 or an exhausted quota is + // not in the retryable set, so it fell straight through to the completion test + // and was booked as a finished task exactly as a dropped connection used to be + // — the same defect, one branch over. + const pane403 = CLAUDE_API_ERROR_PANE.replace( + 'API Error: Connection lost mid-response. The response above may be incomplete.', + 'API Error: 403 Forbidden'); + const relay = loadRelay({ backend: 'claude', paneText: pane403, attachedClients: false }); + try { + relay.setCliReady(true); + assignTask(relay, 't-403'); + relay.__crashTick(); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_complete').length, 0, + 'a 403 turn shipped nothing and must never be booked as a completion'); + const failures = relay.__sent.filter(m => m.type === 'task_failed'); + assert.strictEqual(failures.length, 1, 'it should be handed back immediately, not retried'); + assert.strictEqual(failures[0].failure_kind, 'environment'); + } finally { teardown(relay); } +}); + +test('#5094 an unretryable failure is failed at once, with no retry typed', () => { + const paneQuota = CLAUDE_API_ERROR_PANE.replace( + 'API Error: Connection lost mid-response. The response above may be incomplete.', + 'API Error: 429 {"error":{"type":"budget_exceeded"}}'); + const relay = loadRelay({ backend: 'claude', paneText: paneQuota, attachedClients: false }); + try { + relay.setCliReady(true); + assignTask(relay, 't-quota'); + const before = relay.__tmuxSends().length; + relay.__crashTick(); + const sends = relay.__tmuxSends().slice(before); + assert.ok(!sends.some(c => c.includes(relay.TRANSIENT_API_ERROR_NUDGE_MESSAGE)), + 'retrying a quota failure loops the agent against a wall (#4583)'); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_complete').length, 0); + } finally { teardown(relay); } +}); + +test('#5094 a completed turn whose summary mentions a quota phrase is still complete', () => { + // The false-failure direction of the fatal bucket. This agent finished — real + // PR line, idle prompt — and its summary echoes a string from the code it was + // editing. Failing it would destroy credited work. + const pane = [ + '● Done — opened https://github.com/kubestellar/hive/pull/5095', + '', + "● Summary: hardened the budget_exceeded path in quota_exhaustion_test.go", + '', + '✻ Cogitated for 4m 10s', + '', + '❯ ', + ' ⏵⏵ auto mode on (shift+tab to cycle)', + ].join('\n'); + const relay = loadRelay({ backend: 'claude', paneText: pane, attachedClients: false }); + try { + assert.strictEqual(relay.classifyTmuxPane(pane), relay.PANE_STATE_IDLE_COMPLETE); + relay.setCliReady(true); + assignTask(relay, 't-prose'); + // #5376: chrome-only completion, so it needs the grace window. + graceTicks(relay, () => relay.__crashTick()); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_failed').length, 0, + 'a completed turn must not be failed over a quota phrase in its own prose'); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_complete').length, 1); + } finally { teardown(relay); } +}); + +test('#5094 a mid-session credential expiry is blocked-on-human, not completed', () => { + // The exact #5088 scenario: the OAuth token expired mid-task and Claude Code + // rendered its login line above the idle prompt. A retry is a wall, a failure + // releases a task a human can rescue in thirty seconds by logging in, and a + // completion — what the classifier said before this — is a fabrication. + const pane401 = CLAUDE_API_ERROR_PANE.replace( + 'API Error: Connection lost mid-response. The response above may be incomplete.', + '● Please run /login · API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"OAuth token has expired"}}'); + const relay = loadRelay({ backend: 'claude', paneText: pane401, attachedClients: false }); + try { + assert.strictEqual(relay.classifyTmuxPane(pane401), relay.PANE_STATE_BLOCKED_ON_HUMAN); + relay.setCliReady(true); + assignTask(relay, 't-401'); + const before = relay.__tmuxSends().length; + relay.__crashTick(); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_complete').length, 0, + 'an expired credential must never book a completion'); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_failed').length, 0, + 'a human can fix this by logging in — do not release the task'); + assert.ok(!relay.__tmuxSends().slice(before).some(c => c.includes(relay.TRANSIENT_API_ERROR_NUDGE_MESSAGE)), + 'typing "try again" at an expired credential is a wall'); + const blocked = relay.__sent.filter(m => m.status === 'blocked_on_human'); + assert.strictEqual(blocked.length, 1); + assert.strictEqual(blocked[0].attention, true); + } finally { teardown(relay); } +}); + +test('#5094 a login hint alongside a 403 stays fatal — /login fixes nothing about authorization', () => { + // #4400: 401 is authentication (login fixes it); 403 is authorization (the + // caller IS identified and is not permitted). A line carrying both the login + // hint and a 403 must take the fatal path, or the task waits on a human who + // cannot actually fix it. + const pane = CLAUDE_API_ERROR_PANE.replace( + 'API Error: Connection lost mid-response. The response above may be incomplete.', + '● Please run /login · API Error: 403 {"error":{"message":"team not allowed to access model"}}'); + const relay = loadRelay({ backend: 'claude', paneText: pane }); + try { + assert.strictEqual(relay.classifyTmuxPane(pane), relay.PANE_STATE_FATAL_API_ERROR); + } finally { teardown(relay); } +}); + +test('#5094 with a human attached the relay asks for attention instead of typing over them', () => { + // The hub-side nudge declines when someone is attached (manager.go, + // tmuxSessionHasAttachedClientForAgent) so a watchdog never types over a + // person. The relay honors the same rule — but says so, rather than going + // quiet and letting the stall backstop eventually fail the task. + const relay = loadRelay({ backend: 'claude', paneText: CLAUDE_API_ERROR_PANE, attachedClients: true }); + try { + relay.setCliReady(true); + assignTask(relay, 't-attached'); + const before = relay.__tmuxSends().length; + relay.__crashTick(); + const sends = relay.__tmuxSends().slice(before); + assert.ok(!sends.some(c => c.includes(relay.TRANSIENT_API_ERROR_NUDGE_MESSAGE)), + 'nothing may be typed into a pane a human is sitting in'); + const blocked = relay.__sent.filter(m => m.status === 'blocked_on_human'); + assert.strictEqual(blocked.length, 1, 'the human should be told the agent needs them'); + assert.strictEqual(blocked[0].attention, true); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_complete').length, 0); + } finally { teardown(relay); } +}); + + +// --------------------------------------------------------------------------- +// kubestellar/hive#5277 — "a client is attached" is not "a human is here". +// +// The #5094 guard above is right to refuse to type over someone, but it tested +// connection rather than presence. bin/ttyd-tmux.sh attaches a client with +// `tmux attach-session`, and the dashboard's browser terminal proxies to it, so +// a tab someone opened an hour ago and walked away from was indistinguishable +// from a person mid-keystroke — and disabled API-error auto-retry for the whole +// 30-minute task ceiling. Observed live: a contributor hit a connection-lost +// error, no `try again` was ever typed, and a human hand-typed the recovery. +// +// The fix is a recency test on tmux's own `client_activity`. Everything the +// guard used to protect is still protected; only the abandoned tab changes. +// --------------------------------------------------------------------------- + +test('#5277 a dashboard tab left open no longer disables auto-retry', () => { + // The incident, exactly: attached the whole time, idle the whole time. + const relay = loadRelay({ + backend: 'claude', paneText: CLAUDE_API_ERROR_PANE, + attachedClients: true, attachedIdleMs: 60 * 60 * 1000, + }); + try { + relay.setCliReady(true); + assignTask(relay, 't-idle-tab'); + const before = relay.__tmuxSends().length; + relay.__crashTick(); + assert.ok(relay.__tmuxSends().slice(before).some(c => c.includes(relay.TRANSIENT_API_ERROR_NUDGE_MESSAGE)), + 'an hour-idle client is a left-open tab, not a person — the retry must be typed'); + assert.strictEqual(relay.__sent.filter(m => m.status === 'blocked_on_human').length, 0, + 'nobody is there to be blocked on'); + assert.strictEqual(relay.getTransientNudgeCount(), 1); + } finally { teardown(relay); } +}); + +test('#5277 a client that typed a moment ago still owns the pane', () => { + // The control. Without it the fix could pass by simply never checking. + const relay = loadRelay({ + backend: 'claude', paneText: CLAUDE_API_ERROR_PANE, + attachedClients: true, attachedIdleMs: 30 * 1000, + }); + try { + relay.setCliReady(true); + assignTask(relay, 't-active'); + const before = relay.__tmuxSends().length; + relay.__crashTick(); + assert.ok(!relay.__tmuxSends().slice(before).some(c => c.includes(relay.TRANSIENT_API_ERROR_NUDGE_MESSAGE)), + 'someone who typed 30 seconds ago is still someone'); + const blocked = relay.__sent.filter(m => m.status === 'blocked_on_human'); + assert.strictEqual(blocked.length, 1, 'and they should be told the agent needs them'); + assert.strictEqual(blocked[0].attention, true); + } finally { teardown(relay); } +}); + +test('#5277 presence is decided by the idle threshold, not by the connection', () => { + // Both sides of the boundary, read straight off the helper. The 5s margins + // clear the stub's whole-second quantisation. + const idle = (ms) => { + const relay = loadRelay({ backend: 'claude', attachedClients: true, attachedIdleMs: ms }); + try { return relay.tmuxSessionHumanPresence(); } finally { teardown(relay); } + }; + const threshold = loadRelay({ backend: 'claude' }).HUMAN_PRESENCE_IDLE_MS; + + const justActive = idle(threshold - 5000); + assert.strictEqual(justActive.attached, true); + assert.strictEqual(justActive.active, true, 'just inside the threshold is still a person'); + + const justIdle = idle(threshold + 5000); + assert.strictEqual(justIdle.attached, true, 'the client is still connected'); + assert.strictEqual(justIdle.active, false, 'but it is no longer evidence of a person'); + assert.ok(justIdle.idleMs >= threshold, `idleMs ${justIdle.idleMs} should report the real age`); +}); + +test('#5277 the query asks tmux for client_activity, not just for a client list', () => { + // The whole fix rests on tmux being ASKED for the timestamp. A refactor that + // dropped the -F would leave every other test passing — the stub would answer + // with epoch seconds regardless — while the real tmux returned a pts line and + // silently restored the bug as "activity unknown, assume present". + const relay = loadRelay({ backend: 'claude', attachedClients: true }); + try { + relay.tmuxSessionHumanPresence(); + const query = relay.__commands.filter(c => /list-clients/.test(c)).pop(); + assert.ok(query, 'the presence check must actually ask tmux'); + assert.ok(query.includes('client_activity'), + `presence must be asked as a recency question, got: ${query}`); + } finally { teardown(relay); } +}); + +test('#5277 nobody attached reports neither attached nor active', () => { + const relay = loadRelay({ backend: 'claude', attachedClients: false }); + try { + assert.deepStrictEqual(relay.tmuxSessionHumanPresence(), { attached: false, active: false, idleMs: null }); + } finally { teardown(relay); } +}); + +test('#5277 a tmux failure still counts as someone present', () => { + // Fail closed, unchanged: not being able to ask must never license typing. + const relay = loadRelay({ + backend: 'claude', paneText: CLAUDE_API_ERROR_PANE, listClientsThrows: true, + }); + try { + assert.deepStrictEqual(relay.tmuxSessionHumanPresence(), { attached: true, active: true, idleMs: null }); + relay.setCliReady(true); + assignTask(relay, 't-tmux-broken'); + const before = relay.__tmuxSends().length; + relay.__crashTick(); + assert.ok(!relay.__tmuxSends().slice(before).some(c => c.includes(relay.TRANSIENT_API_ERROR_NUDGE_MESSAGE)), + 'a tmux hiccup must not be read as an empty room'); + assert.strictEqual(relay.__sent.filter(m => m.status === 'blocked_on_human').length, 1); + } finally { teardown(relay); } +}); + +test('#5277 an activity value tmux did not give us counts as someone present', () => { + // An older tmux whose client_activity is not an epoch integer: attached is + // known, recency is not, so recency is assumed. + const relay = loadRelay({ + backend: 'claude', paneText: CLAUDE_API_ERROR_PANE, attachedClients: true, + clientActivityRaw: '/dev/pts/3: 0 [200x50 xterm-256color] (utf8)\n', + }); + try { + const presence = relay.tmuxSessionHumanPresence(); + assert.strictEqual(presence.attached, true); + assert.strictEqual(presence.active, true); + assert.strictEqual(presence.idleMs, null, 'an unknown age must read as unknown, not as zero'); + relay.setCliReady(true); + assignTask(relay, 't-unparseable'); + const before = relay.__tmuxSends().length; + relay.__crashTick(); + assert.ok(!relay.__tmuxSends().slice(before).some(c => c.includes(relay.TRANSIENT_API_ERROR_NUDGE_MESSAGE))); + } finally { teardown(relay); } +}); + +test('#5277 a client clock ahead of ours counts as someone present', () => { + // A future timestamp clamps to "just now" rather than wrapping to a huge + // idle age, which would hand the pane away on a clock skew. + const relay = loadRelay({ backend: 'claude', attachedClients: true, attachedIdleMs: -10 * 60 * 1000 }); + try { + const presence = relay.tmuxSessionHumanPresence(); + assert.strictEqual(presence.active, true, 'skew must not read as an abandoned tab'); + assert.strictEqual(presence.idleMs, 0); + } finally { teardown(relay); } +}); + +test('#5277 the newest client decides — one active client protects the pane', () => { + // Two clients: a stale dashboard tab and a person who just typed. The person + // wins, so the tab cannot drag presence down to "nobody home". + const stale = Math.floor((Date.now() - 60 * 60 * 1000) / 1000); + const fresh = Math.floor(Date.now() / 1000); + const relay = loadRelay({ + backend: 'claude', attachedClients: true, + clientActivityRaw: `${stale}\n${fresh}\n`, + }); + try { + assert.strictEqual(relay.tmuxSessionHumanPresence().active, true); + } finally { teardown(relay); } +}); + +test('#5277 a suppressed nudge still consumes no retry budget', () => { + const relay = loadRelay({ + backend: 'claude', paneText: CLAUDE_API_ERROR_PANE, + attachedClients: true, attachedIdleMs: 10 * 1000, + }); + try { + relay.setCliReady(true); + assignTask(relay, 't-budget'); + for (let i = 0; i < 5; i++) { + relay.__clearTransientNudgeCooldown(); + relay.__crashTick(); + } + assert.strictEqual(relay.getTransientNudgeCount(), 0, + 'refusing to type must not spend a retry the agent never got'); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_failed').length, 0, + 'and it must not exhaust the budget into a failure either'); + } finally { teardown(relay); } +}); + +test('#5277 the idle-client retry is still bounded and still fails honestly', () => { + const relay = loadRelay({ + backend: 'claude', paneText: CLAUDE_API_ERROR_PANE, + attachedClients: true, attachedIdleMs: 45 * 60 * 1000, + }); + try { + relay.setCliReady(true); + assignTask(relay, 't-idle-bounded'); + for (let i = 0; i <= relay.TRANSIENT_API_ERROR_MAX_NUDGES; i++) { + relay.__clearTransientNudgeCooldown(); + relay.__crashTick(); + } + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_complete').length, 0); + const failures = relay.__sent.filter(m => m.type === 'task_failed'); + assert.strictEqual(failures.length, 1, 'an exhausted budget hands the task back exactly once'); + assert.strictEqual(failures[0].failure_kind, 'environment'); + } finally { teardown(relay); } +}); + + +// --------------------------------------------------------------------------- +// kubestellar/hive#5121 — an API error the curated lists cannot name must not +// read as a completed task either. +// +// #5094/#5106 closed the retryable and known-unretryable buckets; anything +// outside both — a 400, a 404, a novel gateway phrasing — still fell through +// to the completion test and booked a completion for a turn that shipped +// nothing. Unknown errors are anchored on the CLI's own rendering (a +// line-leading "● API Error:"), logged verbatim so the curated lists can be +// grown from real occurrences, and routed down the bounded transient path. +// --------------------------------------------------------------------------- + +const UNKNOWN_ERROR_PANE = [ + "● Now the app's poll loop:", + '', + ' Ran 6 shell commands', + '', + '● API Error: 400 {"type":"error","error":{"type":"invalid_request_error","message":"max_tokens: unexpected value"}}', + '', + '✻ Cogitated for 2m 04s', + '', + '❯ ', + ' ⏵⏵ auto mode on (shift+tab to cycle)', +].join('\n'); + +test('#5121 an unrecognised API error does not classify as complete', () => { + const relay = loadRelay({ backend: 'claude', paneText: UNKNOWN_ERROR_PANE }); + try { + assert.strictEqual(relay.classifyTmuxPane(UNKNOWN_ERROR_PANE), + relay.PANE_STATE_UNKNOWN_API_ERROR, + 'a 400 is in neither curated list, and a turn ending in ANY API error did not complete'); + } finally { teardown(relay); } +}); + +test('#5121 an unrecognised error is retried, never completed, and fails honestly when the budget runs out', () => { + const relay = loadRelay({ backend: 'claude', paneText: UNKNOWN_ERROR_PANE, attachedClients: false }); + try { + relay.setCliReady(true); + assignTask(relay, 't-unknown'); + const before = relay.__tmuxSends().length; + relay.__crashTick(); + assert.ok(relay.__tmuxSends().slice(before).some(c => c.includes(relay.TRANSIENT_API_ERROR_NUDGE_MESSAGE)), + 'the unknown bucket takes the bounded retry path'); + for (let i = 0; i < relay.TRANSIENT_API_ERROR_MAX_NUDGES; i++) { + relay.__clearTransientNudgeCooldown(); + relay.__crashTick(); + } + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_complete').length, 0, + 'an errored turn must never be booked as a completion, named or not'); + const failures = relay.__sent.filter(m => m.type === 'task_failed'); + assert.strictEqual(failures.length, 1, 'an exhausted budget hands the task back exactly once'); + assert.strictEqual(failures[0].failure_kind, 'environment'); + } finally { teardown(relay); } +}); + +test('#5121 the unmatched error line is logged verbatim for list-growing', () => { + const relay = loadRelay({ backend: 'claude', paneText: UNKNOWN_ERROR_PANE, attachedClients: false }); + const origWarn = console.warn; + const warned = []; + console.warn = (...args) => { warned.push(args.join(' ')); }; + try { + relay.setCliReady(true); + assignTask(relay, 't-instrument'); + relay.__crashTick(); + assert.ok(warned.some(w => w.includes('#5121') && w.includes('invalid_request_error')), + 'the exact unrecognised line must reach the log, or the curated lists cannot grow from it'); + } finally { + console.warn = origWarn; + teardown(relay); + } +}); + +test('#5121 the anchor requires the CLI\'s own rendering — quoted prose still completes', () => { + // An agent whose completed-turn summary MENTIONS an API error must be + // credited, not held and retried. The anchor is the line-leading ● bullet; + // a mid-line mention is prose. + const pane = [ + '● Done — opened https://github.com/kubestellar/hive/pull/9999', + '', + '● The flake was the upstream returning API Error: 418 during the outage window.', + '', + '✻ Cogitated for 3m 30s', + '', + '❯ ', + ' ⏵⏵ auto mode on (shift+tab to cycle)', + ].join('\n'); + const relay = loadRelay({ backend: 'claude', paneText: pane }); + try { + assert.strictEqual(relay.classifyTmuxPane(pane), relay.PANE_STATE_IDLE_COMPLETE); + assert.strictEqual(relay.paneUnknownAPIErrorLine(pane), null); + } finally { teardown(relay); } +}); + +test('#5121 the curated buckets keep first claim on their lines', () => { + const relay = loadRelay({ backend: 'claude' }); + try { + const mk = (line) => UNKNOWN_ERROR_PANE.replace( + '● API Error: 400 {"type":"error","error":{"type":"invalid_request_error","message":"max_tokens: unexpected value"}}', + line); + assert.strictEqual(relay.classifyTmuxPane(mk('● API Error: Connection lost mid-response. The response above may be incomplete.')), + relay.PANE_STATE_TRANSIENT_API_ERROR, 'a known-retryable error stays in its bucket'); + assert.strictEqual(relay.classifyTmuxPane(mk('● API Error: 403 Forbidden')), + relay.PANE_STATE_FATAL_API_ERROR, 'a known-fatal error stays in its bucket'); + assert.strictEqual(relay.classifyTmuxPane(mk('● Please run /login · API Error: 401 {"type":"error"}')), + relay.PANE_STATE_BLOCKED_ON_HUMAN, 'an authentication failure stays blocked-on-human'); + } finally { teardown(relay); } +}); + +// --- Attach hints must name the runtime that actually launched us ---------- +// +// kubestellar/hive#5145. Container mode resolves docker OR podman, but the +// relay runs INSIDE the container and cannot see its own launcher, so both +// in-container attach hints hardcoded `docker`. Observed live on a podman +// launch: the recipe's own host-side hint said `podman exec -it hive-...`, and +// four lines later this relay's banner said `docker exec -it hive-...` — two +// contradictory instructions for the same container in one screen of output. +// The podman operator pastes the second one and gets a docker-socket +// permission error. +// +// The banner is the site that matters: it fires exactly when the agent is +// BLOCKED and a human must attach to complete a login. A paste-able command +// that fails there reads as "the whole thing is broken". +// +// ATTACH_COMMAND is resolved at module load from the environment the recipe +// passes in, so these load the relay with that environment and read the value +// the banner will print. + +test('#5145 a podman container names podman in its attach hint', () => { + const relay = loadRelay({ env: { HIVE_CONTAINER_NAME: 'hive-contributor-agy-5b4f', HIVE_CONTAINER_RUNTIME: 'podman' } }); + try { + assert.strictEqual(relay.ATTACH_COMMAND, + 'podman exec -it hive-contributor-agy-5b4f tmux attach -t contributor', + 'a podman-launched container must not tell the operator to run docker'); + } finally { teardown(relay); } +}); + +test('#5145 a docker container is unchanged, with or without the new variable', () => { + // The recipe now passes HIVE_CONTAINER_RUNTIME, but an image or a launch + // older than that change does not. Both must print exactly what shipped + // before, or the fix trades a wrong podman hint for a wrong docker one. + const withVar = loadRelay({ env: { HIVE_CONTAINER_NAME: 'hive-contributor', HIVE_CONTAINER_RUNTIME: 'docker' } }); + try { + assert.strictEqual(withVar.ATTACH_COMMAND, 'docker exec -it hive-contributor tmux attach -t contributor'); + } finally { teardown(withVar); } + + const withoutVar = loadRelay({ env: { HIVE_CONTAINER_NAME: 'hive-contributor', HIVE_CONTAINER_RUNTIME: '' } }); + try { + assert.strictEqual(withoutVar.ATTACH_COMMAND, 'docker exec -it hive-contributor tmux attach -t contributor', + 'an older launch that passes no runtime must keep printing docker'); + } finally { teardown(withoutVar); } +}); + +test('#5145 local mode names no container at all', () => { + // HIVE_CONTAINER_NAME is set ONLY by the container arm of the recipe + // (Justfile: `-e HIVE_CONTAINER_NAME=...`). In local mode this relay runs on + // the host beside the tmux server it drives, so there is no container to exec + // into and `docker exec -it hive-contributor ...` could only ever fail — + // which is what the banner printed before this fix, on every local run. + const relay = loadRelay({ env: { HIVE_CONTAINER_NAME: '', HIVE_AGENT_SESSION: 'hive-agy-5b4f' } }); + try { + assert.strictEqual(relay.ATTACH_COMMAND, 'tmux attach -t hive-agy-5b4f', + 'local mode must print the plain tmux command the recipe itself prints'); + assert.ok(!/docker|podman|exec/.test(relay.ATTACH_COMMAND), + `local mode must not mention a container runtime: ${relay.ATTACH_COMMAND}`); + } finally { teardown(relay); } +}); + +test('#5145 the needs-authentication banner prints the resolved command', () => { + // The value is only worth computing if the banner actually uses it. A second + // hardcoded copy inside the banner would pass every assertion above. + // + // waitForCLI() is armed during module load in interactive mode and its first + // poll runs synchronously, so a pane that reads as needs-login makes the + // banner print while loadRelay() is still running — capture around it. + const lines = []; + const oldLog = console.log; + console.log = (msg) => { lines.push(String(msg)); }; + let relay; + try { + relay = loadRelay({ + backend: 'claude', + cliStates: ['Please run /login\n'], + env: { HIVE_CONTAINER_NAME: 'hive-contributor-claude-9a1c', HIVE_CONTAINER_RUNTIME: 'podman' }, + }); + } finally { + console.log = oldLog; + } + try { + assert.ok(lines.some(l => l.includes('needs authentication')), + `the login banner did not fire; captured:\n${lines.join('\n')}`); + assert.ok(lines.some(l => l.includes(relay.ATTACH_COMMAND)), + `the banner does not print ATTACH_COMMAND (${relay.ATTACH_COMMAND}); captured:\n${lines.join('\n')}`); + assert.ok(!lines.some(l => /docker exec/.test(l)), + `the banner still hardcodes a docker exec line; captured:\n${lines.join('\n')}`); + } finally { teardown(relay); } +}); + +// --- #5321: the max-duration deadline bounds HANGS, not DURATION ----------- +// +// The deadline used to be a flat wall-clock kill armed once per task and never +// re-armed. Live on 2026-08-31 it killed an agent that had already committed +// and pushed and was waiting on a green test suite; the hub booked the task +// `failed` 57 seconds before that task's own PR (#5320) was opened. Any task +// whose honest duration exceeded MAX_TASK_DURATION_MS was not slow, it was +// impossible. +// +// These pin the corrected contract: output renews the lease, silence spends it, +// an absolute backstop still terminates a truly wedged task, and neither +// ceiling is booked as the agent's fault. + +test('#5321 the max-duration ceiling is a progress LEASE, not a wall-clock budget', () => { + // The structural claim, independent of any timing: the absolute ceiling must + // be strictly larger than the lease. If a regression collapses them back into + // one constant, "long but live" becomes unrepresentable again. + const relay = loadRelay({ backend: 'agy' }); + try { + assert.ok(relay.ABSOLUTE_TASK_DEADLINE_MS > relay.MAX_TASK_DURATION_MS, + `the absolute backstop (${relay.ABSOLUTE_TASK_DEADLINE_MS}) must exceed the progress lease ` + + `(${relay.MAX_TASK_DURATION_MS}) — collapsing them restores the flat wall-clock kill`); + // And it must be far enough above the working range to be a backstop rather + // than a second deadline: a full test suite is the long pole this repo has. + assert.ok(relay.ABSOLUTE_TASK_DEADLINE_MS >= 2 * 60 * 60 * 1000, + 'the absolute backstop must sit well above the honest working range'); + } finally { teardown(relay); } +}); + +test('#5321 a task producing output past MAX_TASK_DURATION_MS is NOT failed', () => { + // The acceptance criterion, driven through the real tick loop: the pane keeps + // changing, the lease keeps being renewed, and the expiry callback — invoked + // directly, as the timer would — declines to kill a live agent. + let n = 0; + const relay = loadRelay({ + backend: 'agy', + // A fresh line every capture: this is what "the agent is working" looks + // like. No idle prompt, so the pane never classifies as IDLE_COMPLETE. + paneText: () => `running the full test suite, still going, tick ${n++}`, + }); + try { + relay.setCliReady(true); + assignTask(relay, 't-live'); + + // Simulate the task running well past the old 30-minute wall: many ticks, + // each producing new output, with the assignment clock aged accordingly. + for (let i = 0; i < 5; i++) { + relay.__stallTick(); + relay.__ageTaskAssignedAt(relay.MAX_TASK_DURATION_MS / 2); + // Fire the deadline callback exactly as the timer would. On a live pane + // it must renew, not kill. + relay.onTaskProgressLeaseExpired(); + } + + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_failed').length, 0, + 'a task whose pane keeps producing output must never be failed on duration — ' + + 'this is the #5321 regression that booked a shipped PR as a failure'); + assert.ok(relay.getCurrentTask(), 'the task must still be held'); + assert.ok(relay.__sent.some(m => m.type === 'task_progress' && m.status === 'working'), + 'and the relay must still be reporting it as working, so the hub renews its lease'); + } finally { teardown(relay); } +}); + +test('#5321 a silent pane still spends the lease, and is blamed on the environment', () => { + const relay = loadRelay({ + backend: 'agy', + paneText: 'a frozen pane with no idle prompt and nothing happening', + }); + try { + relay.setCliReady(true); + assignTask(relay, 't-silent'); + relay.__stallTick(); // records the fingerprint + + // No new output since; age the pane clock past the lease window so the + // expiry callback sees genuine silence. + relay.__agePaneStallClock(relay.MAX_TASK_DURATION_MS + 1); + relay.onTaskProgressLeaseExpired(); + + const failures = relay.__sent.filter(m => m.type === 'task_failed'); + assert.strictEqual(failures.length, 1, 'a pane silent for the whole lease window must be given back'); + assert.strictEqual(failures[0].failure_kind, 'environment', + 'a runtime ceiling is not the agent failing its work — #5321 defect 1 was that this ' + + 'path passed no opts at all, so the kind was undefined'); + } finally { teardown(relay); } +}); + +test('#5321 the absolute backstop terminates a task that never stops printing', () => { + // The case the lease deliberately does not cover: output forever (a retry + // loop redrawing a spinner is output) must not buy unbounded time. + let n = 0; + const relay = loadRelay({ + backend: 'agy', + paneText: () => `spinning forever ${n++}`, + }); + try { + relay.setCliReady(true); + assignTask(relay, 't-forever'); + relay.__stallTick(); + + // Live pane, but past the absolute ceiling. + relay.__ageTaskAssignedAt(relay.ABSOLUTE_TASK_DEADLINE_MS + 1); + relay.onTaskProgressLeaseExpired(); + + const failures = relay.__sent.filter(m => m.type === 'task_failed'); + assert.strictEqual(failures.length, 1, + 'past the absolute deadline, forward progress must no longer renew the lease'); + assert.strictEqual(failures[0].failure_kind, 'environment', + 'the backstop firing is a statement about this runtime, not about the work'); + assert.match(failures[0].reason, /absolute deadline/i, + `the reason must name the backstop, not the lease; got: ${failures[0].reason}`); + } finally { teardown(relay); } +}); + +test('#5321 paneChangedSince is a PURE read — it must not consume the stall clock', () => { + // Load-bearing: progressTick() calls paneChangedSince() first and + // paneStalled() (via paneStallConfirmed) later in the SAME tick. paneStalled() + // is destructive — it records the fingerprint. If paneChangedSince() also + // recorded, the stall detector would see an already-consumed change on every + // tick and could never accumulate a stall, silently disabling the hang + // detector this fix relies on to cover the case the wall used to. + const relay = loadRelay({ backend: 'agy' }); + try { + relay.resetPaneStallClock(); + // Nothing recorded yet: a fresh task has drawn nothing, which is not + // evidence of progress. + assert.strictEqual(relay.paneChangedSince(['first']), false, + 'with no recorded fingerprint there is no change to report'); + + relay.paneStalled(['first']); // records 'first' + assert.strictEqual(relay.paneChangedSince(['second']), true, 'new content is a change'); + // Repeated reads must keep reporting the change — proof nothing was consumed. + assert.strictEqual(relay.paneChangedSince(['second']), true, + 'paneChangedSince must be idempotent; a second read seeing false means it recorded'); + assert.strictEqual(relay.paneChangedSince(['first']), false, 'identical content is not a change'); + // An empty capture is a missing pane, and paneStalled() refuses to read it + // as a stall; symmetrically it must not be read as progress. + assert.strictEqual(relay.paneChangedSince([]), false, + 'an empty capture must never be credited as forward progress'); + + // And the stall clock is still intact after all those reads. + relay.__agePaneStallClock(relay.PANE_STALL_TIMEOUT_MS + 1); + assert.strictEqual(relay.paneStalled(['first']), true, + 'the stall detector must still trip — paneChangedSince did not disturb its clock'); + } finally { teardown(relay); } +}); + +test('#5321 a genuinely hung agent is still terminated by the stall detector', () => { + // The other half of the acceptance criteria: relaxing the wall must not have + // relaxed the hang case. The stall detector fires at 20 minutes, sooner than + // the lease, and is unaffected by the new progress-credit call. + const relay = loadRelay({ + backend: 'agy', + paneText: 'a frozen pane with no idle prompt and nothing happening', + }); + try { + relay.setCliReady(true); + assignTask(relay, 't-hung'); + relay.__stallTick(); + for (let i = 0; i < relay.PANE_STALL_CONFIRM_TICKS; i++) { + relay.__agePaneStallClock(relay.PANE_STALL_TIMEOUT_MS + 1); + relay.__stallTick(); + } + const failures = relay.__sent.filter(m => m.type === 'task_failed'); + assert.strictEqual(failures.length, 1, 'a frozen pane must still be given back'); + assert.match(failures[0].reason, /no pane activity/i, + `the stall detector, not the duration ceiling, must be the one to fire; got: ${failures[0].reason}`); + } finally { teardown(relay); } +}); + +test('#5321 the deadline timer is re-armed on progress, not left as a one-shot', () => { + // Mechanism-level: the original bug was a handle set once at :2501 and never + // re-set. Pin that a tick observing new output installs a NEW handle. + let n = 0; + const relay = loadRelay({ + backend: 'agy', + paneText: () => `working ${n++}`, + }); + try { + relay.setCliReady(true); + assignTask(relay, 't-rearm'); + relay.__stallTick(); // records the first fingerprint + const first = relay.getTaskTimeoutHandle(); + assert.ok(first, 'a task must hold a deadline handle'); + relay.__stallTick(); // new output -> must re-arm + const second = relay.getTaskTimeoutHandle(); + assert.ok(second, 'the deadline handle must still exist after a progress tick'); + assert.notStrictEqual(second, first, + 'a tick that observed new pane output must have re-armed the deadline — ' + + 'an unchanged handle is the #5321 one-shot timer'); + } finally { teardown(relay); } +}); + +test('#5321 a headless one-shot is no longer capped at the old 30-minute wall', () => { + // The headless path has no pane to scrape, so it cannot use the lease; it + // gets the absolute bound instead. Before the fix it aliased + // MAX_TASK_DURATION_MS and killed long-but-live runs for the same reason. + const relay = loadRelay({ backend: 'agy' }); + try { + assert.strictEqual(relay.HEADLESS_TASK_TIMEOUT_MS, relay.ABSOLUTE_TASK_DEADLINE_MS, + 'the headless ceiling must be the absolute backstop, not the progress lease'); + assert.ok(relay.HEADLESS_TASK_TIMEOUT_MS > relay.MAX_TASK_DURATION_MS, + 'a headless run must not be killed at the old wall-clock deadline'); + } finally { teardown(relay); } +}); + +// --------------------------------------------------------------------------- +// kubestellar/hive#5353 cause B — ending a task must stop the agent. +// +// Reporting task_complete or task_failed tells the hub to revoke the lease, +// book a cooldown and offer the issue to somebody else. Before this, only five +// of the twelve task-exit paths touched the pane, so the other seven left the +// original agent running in the same pane on the same context with a live +// repo-scoped token — and it would go on to open a PR against work the hub had +// already reassigned. +// +// Every assertion below is on OBSERVABLE state: the token file is gone, and +// the pane received the two Ctrl-Cs that actually exit a CLI followed by a +// relaunch. None of them assert that a particular function was called. +// --------------------------------------------------------------------------- + +// An IDLE_COMPLETE pane for copilot/claude — the ready chrome the classifier +// matches, with no "esc cancel" working marker. +const IDLE_PANE = '/ commands for help\n'; + +// Plant a task-scoped token exactly where injectGhToken puts it, so a test can +// watch it survive or not survive a task exit. +function plantTaskToken(relay) { + fs.mkdirSync(path.dirname(relay.GH_TOKEN_CACHE), { recursive: true }); + fs.writeFileSync(relay.GH_TOKEN_CACHE, 'gho_task_scoped_token', { mode: 0o600 }); + assert.ok(fs.existsSync(relay.GH_TOKEN_CACHE), 'test setup: token cache was not planted'); +} + +// The two Ctrl-Cs that exit a live CLI, followed by the launch command. One +// Ctrl-C only cancels a claude/codex/agy turn and leaves the CLI running, so +// "stopped" means at least two, and they must PRECEDE the relaunch or the +// launch command is typed into the CLI as a chat message (#2203). +function assertAgentStopped(sends, backend) { + const launchIdx = sends.findIndex(c => new RegExp(backend).test(c)); + assert.ok(launchIdx >= 0, `expected a relaunch of ${backend}: ${JSON.stringify(sends)}`); + const ctrlCs = sends.slice(0, launchIdx).filter(c => /C-c\s*$/.test(c)).length; + assert.ok(ctrlCs >= 2, + `a live CLI needs two Ctrl-Cs before the relaunch; saw ${ctrlCs} in ${JSON.stringify(sends)}`); +} + +test('#5353 a reported completion stops the agent and drops its token', () => { + // The headline case. The pane reads idle, the relay books a completion, the + // hub reassigns the issue — and the agent that "finished" must not still be + // sitting in the pane with a valid credential. + // #5376: completion now comes from the agent's own sentinel rather than the + // idle chrome, so the pane carries one. What this test is about is unchanged: + // whatever ended the task, the agent must be stopped and its token gone. + const relay = loadRelay({ backend: 'copilot', paneText: `HIVE_VERDICT: complete — shipped it\n${IDLE_PANE}` }); + try { + relay.setCliReady(true); + assignTask(relay, 't-complete'); + plantTaskToken(relay); + const before = relay.__tmuxSends().length; + relay.__stallTick(); + + const completed = relay.__sent.filter(m => m.type === 'task_complete'); + assert.strictEqual(completed.length, 1, 'setup: expected exactly one completion'); + assert.ok(!fs.existsSync(relay.GH_TOKEN_CACHE), + 'a completed task left its repo-scoped GitHub token on disk, valid for the rest of wsTokenTTL'); + assertAgentStopped(relay.__tmuxSends().slice(before), 'copilot'); + } finally { teardown(relay); } +}); + +test('#5353 the completion report still carries the AGENT output, not the relaunch chrome', () => { + // Stopping the agent must not cost the evidence: tmux_output is captured + // before the quit, so the hub still sees the pane the verdict was read from + // (and detectPRURL still finds the PR the agent opened). + const relay = loadRelay({ + backend: 'copilot', + paneText: 'Pull request opened: https://github.com/foo/bar/pull/909\n/ commands for help\nHIVE_VERDICT: complete — PR is open\n', + }); + try { + relay.setCliReady(true); + assignTask(relay, 't-evidence'); + relay.__stallTick(); + const completed = relay.__sent.find(m => m.type === 'task_complete'); + assert.ok(completed, 'expected a completion'); + assert.strictEqual(completed.pr_url, 'https://github.com/foo/bar/pull/909', + 'the PR the agent shipped must survive the stop'); + assert.ok(completed.tmux_output.join('\n').includes('Pull request opened'), + `tmux_output must be the agent's pane, not launch chrome: ${JSON.stringify(completed.tmux_output)}`); + } finally { teardown(relay); } +}); + +test('#5353 a progress-lease expiry stops the agent and drops its token', () => { + // "No observed progress" is a verdict about a pane, not about a process. The + // CLI is still running and still authorized until something stops it. + const relay = loadRelay({ backend: 'agy', paneText: 'still chewing on it' }); + try { + relay.setCliReady(true); + assignTask(relay, 't-lease'); + plantTaskToken(relay); + const before = relay.__tmuxSends().length; + relay.__agePaneStallClock(relay.MAX_TASK_DURATION_MS + 1); + relay.onTaskProgressLeaseExpired(); + + const failedMsgs = relay.__sent.filter(m => m.type === 'task_failed'); + assert.strictEqual(failedMsgs.length, 1, `expected one failure: ${JSON.stringify(relay.__sent.map(m => m.type))}`); + assert.ok(!fs.existsSync(relay.GH_TOKEN_CACHE), + 'a lease-expired task left its GitHub token on disk'); + assertAgentStopped(relay.__tmuxSends().slice(before), 'agy'); + } finally { teardown(relay); } +}); + +test('#5353 the absolute deadline stops the agent and drops its token', () => { + const relay = loadRelay({ backend: 'agy', paneText: 'printing forever' }); + try { + relay.setCliReady(true); + assignTask(relay, 't-deadline'); + plantTaskToken(relay); + const before = relay.__tmuxSends().length; + relay.__ageTaskAssignedAt(relay.ABSOLUTE_TASK_DEADLINE_MS + 1); + relay.onTaskProgressLeaseExpired(); + + const failedMsgs = relay.__sent.filter(m => m.type === 'task_failed'); + assert.strictEqual(failedMsgs.length, 1); + assert.match(failedMsgs[0].reason, /absolute deadline/); + assert.ok(!fs.existsSync(relay.GH_TOKEN_CACHE), + 'a task killed at the absolute deadline left its GitHub token on disk'); + assertAgentStopped(relay.__tmuxSends().slice(before), 'agy'); + } finally { teardown(relay); } +}); + +test('#5353 a fatal API error stops the agent and drops its token', () => { + // An authorization refusal or exhausted quota ends the TASK. The CLI is + // still up, and on some backends will happily continue once the operator + // fixes the cause — on an issue the hub has already given to someone else. + const relay = loadRelay({ + backend: 'claude', + paneText: 'API Error: 403 {"type":"error","error":{"type":"permission_error","message":"denied"}}\n', + }); + try { + relay.setCliReady(true); + assignTask(relay, 't-fatal'); + plantTaskToken(relay); + const before = relay.__tmuxSends().length; + relay.__stallTick(); + + const failedMsgs = relay.__sent.filter(m => m.type === 'task_failed'); + assert.strictEqual(failedMsgs.length, 1, `expected a fatal-API failure: ${JSON.stringify(relay.__sent.map(m => m.type))}`); + assert.ok(!fs.existsSync(relay.GH_TOKEN_CACHE), + 'a fatally-failed task left its GitHub token on disk'); + assertAgentStopped(relay.__tmuxSends().slice(before), 'claude'); + } finally { teardown(relay); } +}); + +test('#5353 a task exit relaunches the CLI exactly once — no nested double launch', () => { + // quitLiveCLI + relaunch paths already existed; routing every exit through + // one helper must not stack a second launch on top of an in-flight one. The + // confirmed pane stall is the case that already stopped the CLI itself. + const relay = loadRelay({ backend: 'agy', paneText: 'a frozen pane, nothing happening' }); + try { + relay.setCliReady(true); + assignTask(relay, 't-once'); + plantTaskToken(relay); + const before = relay.__tmuxSends().length; + relay.__stallTick(); + relay.__agePaneStallClock(relay.PANE_STALL_TIMEOUT_MS + 1); + relay.__stallTick(); // confirmation 1 + relay.__agePaneStallClock(relay.PANE_STALL_TIMEOUT_MS + 1); + relay.__stallTick(); // confirmation 2 -> exit + const sends = relay.__tmuxSends().slice(before); + const launches = sends.filter(c => /agy --allow-all/.test(c)).length; + assert.strictEqual(launches, 1, + `exactly one relaunch per task exit; saw ${launches} in ${JSON.stringify(sends)}`); + assert.ok(!fs.existsSync(relay.GH_TOKEN_CACHE), 'the stall path must also drop the token'); + } finally { teardown(relay); } +}); + +test('#5353 a CLI that already died is not Ctrl-C\'d at a bare shell, but still loses its token', () => { + // The deliberate exception. This branch's premise is that the process is + // GONE and the pane is a shell; quitting there would only fire Ctrl-Cs at + // bash and race this path's own relaunch. The credential still goes. + const relay = loadRelay({ backend: 'copilot', procAlive: false }); + try { + relay.setCliReady(true); + assignTask(relay, 't-dead'); + plantTaskToken(relay); + relay.__crashTick(); // reading 1 — not yet confirmed + const before = relay.__tmuxSends().length; + relay.__crashTick(); // reading 2 — confirmed death + + const failedMsgs = relay.__sent.filter(m => m.type === 'task_failed'); + assert.ok(failedMsgs.length >= 1, 'a dead CLI must still hand the task back'); + assert.ok(!fs.existsSync(relay.GH_TOKEN_CACHE), + 'a task whose CLI died left its GitHub token on disk'); + const launches = relay.__tmuxSends().slice(before).filter(c => /copilot --allow-all/.test(c)).length; + assert.strictEqual(launches, 1, + 'the crash path owns its own single relaunch — the exit helper must not add another'); + } finally { teardown(relay); } +}); + +test('#5353 a headless one-shot drops its token on completion as well as on revoke', () => { + // Headless already killed the child on revoke but kept the credential on a + // clean exit-0 completion, which is the same outlived-credential shape. + const relay = loadRelay({ backend: 'pi', mode: 'headless', model: 'openai/gpt-5', cliVersion: 'pi 0.73.1' }); + try { + const task = { task_id: 'h-done', task_gen: 4, kind: 'issue', repo: 'x/y', number: 7, title: 'headless' }; + relay.setCurrentTask(task); + plantTaskToken(relay); + relay.runHeadlessTask(task); + assert.ok(relay.__sent.some(m => m.type === 'task_complete'), 'setup: expected a headless completion'); + assert.ok(!fs.existsSync(relay.GH_TOKEN_CACHE), + 'a completed headless task left its GitHub token on disk for the rest of wsTokenTTL'); + } finally { teardown(relay); } +}); + +test('#5353 task_revoke keeps its exact behaviour after the refactor', () => { + // The path that was already correct is the one the helper was factored out + // of, so it is also the regression risk: the token must still go, the CLI + // must still be stopped and relaunched, and the revoke-specific readiness + // latch must still be armed so the relay re-advertises when it comes back. + const relay = loadRelay({ backend: 'claude' }); + try { + relay.setCliReady(true); + assignTask(relay, 't-revoke'); + plantTaskToken(relay); + const before = relay.__tmuxSends().length; + relay.handleMessage(JSON.stringify({ type: 'task_revoke', task_id: 't-revoke', reason: 'operator stop' })); + + assert.strictEqual(relay.getCurrentTask(), null, 'revoke must clear the active task'); + assert.ok(!fs.existsSync(relay.GH_TOKEN_CACHE), 'revoke must still drop the token'); + assert.strictEqual(relay.getCliReady(), false, 'revoke must clear the readiness latch'); + assertAgentStopped(relay.__tmuxSends().slice(before), 'claude'); + } finally { teardown(relay); } +}); + +test('#5353 declining an assignment touches neither the pane nor an unrelated token', () => { + // Three task_assign paths answer task_failed for work that was never + // started. There is no agent of ours to stop, and the running task's own + // credential must not be collateral damage — this is the exception the + // uniform treatment would have broken. + const relay = loadRelay({ backend: 'copilot', paneText: 'esc cancel\n' }); + try { + relay.setCliReady(true); + assignTask(relay, 't-first', 1); + plantTaskToken(relay); + const before = relay.__tmuxSends().length; + relay.handleMessage(JSON.stringify({ + type: 'task_assign', task_id: 't-second', kind: 'issue', repo: 'foo/bar', number: 2, title: 'second', + })); + + const declined = relay.__sent.filter(m => m.type === 'task_failed' && m.task_id === 't-second'); + assert.strictEqual(declined.length, 1, 'the second assignment must be declined'); + assert.ok(fs.existsSync(relay.GH_TOKEN_CACHE), + 'declining a NEW task must not delete the token of the task still being worked'); + assert.strictEqual(relay.getCurrentTask().task_id, 't-first', + 'declining must not disturb the active task'); + const ctrlCs = relay.__tmuxSends().slice(before).filter(c => /C-c\s*$/.test(c)).length; + assert.strictEqual(ctrlCs, 0, + 'declining an assignment must never interrupt the agent working the previous one'); + } finally { teardown(relay); } +}); + +// --------------------------------------------------------------------------- +// The completion signal (kubestellar/hive#5376). +// +// THE CLASS THIS SECTION EXISTS TO CATCH. Task completion in the interactive +// relay used to be inferred entirely from tmux rendering chrome — per-backend +// regexes over the last fifteen lines of the pane. Thirteen issues (#1566, +// #4026, #4064, #4067, #4078, #4080, #4128, #4182, #4265, #5094, #5121, #5156, +// #5162) are one defect repeating: a CLI restyled its cosmetic output in a +// patch release and completion broke, in one direction or the other. +// +// The tests below are written so that they hold whatever any vendor does to +// its chrome. The bar, stated plainly: +// +// 1. A task completes on the SENTINEL, whatever the pane renders around it — +// including chrome that classifyTmuxPane reads as busy, and chrome from a +// backend nobody has written a branch for. +// 2. A task does NOT complete on chrome alone in a single tick, which is what +// the whole history consists of. +// +// A future backend restyle can still move a pane between WORKING and +// IDLE_COMPLETE. What it can no longer do is decide, on its own and instantly, +// that a task finished. +// --------------------------------------------------------------------------- + +// A pane whose chrome classifyTmuxPane reads as BUSY for claude — "esc to +// interrupt" is its in-flight marker — but which carries the agent's own +// completion sentinel. This is the shape the thirteen issues kept getting +// wrong from the other side: a real completion the classifier called WORKING, +// which the stall backstop then failed with the PR already open (#4127, #4181, +// #4259). The sentinel must win. +const VERDICT_UNDER_BUSY_CHROME = [ + '● Opened https://github.com/foo/bar/pull/4242', + 'HIVE_VERDICT: complete — PR is open and ready for review', + '', + '✻ Cogitating… (esc to interrupt)', +].join('\n'); + +test('#5376 a task completes on the sentinel even while the chrome says the CLI is busy', () => { + const relay = loadRelay({ backend: 'claude', paneText: VERDICT_UNDER_BUSY_CHROME }); + try { + // The classifier is unchanged and still reads this pane as working — that + // is the point. Its verdict about the pane is no longer the task's verdict. + assert.strictEqual(relay.classifyTmuxPane(VERDICT_UNDER_BUSY_CHROME), relay.PANE_STATE_WORKING, + 'setup: the chrome must still classify as busy, or this test proves nothing'); + + relay.setCliReady(true); + assignTask(relay, 't-verdict-busy'); + relay.__crashTick(); + + const completed = relay.__sent.filter(m => m.type === 'task_complete'); + assert.strictEqual(completed.length, 1, + `the agent said it was done; the chrome must not overrule it: ${JSON.stringify(relay.__sent.map(m => m.type))}`); + assert.strictEqual(completed[0].completion_signal, 'verdict'); + assert.strictEqual(completed[0].pr_url, 'https://github.com/foo/bar/pull/4242'); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_failed').length, 0); + } finally { teardown(relay); } +}); + +test('#5376 the sentinel completes a task through chrome no backend branch has ever seen', () => { + // The generalisation of the class. Every one of the thirteen issues was + // fixed by teaching classifyTmuxPane about some CLI's new rendering. This + // asserts the property that makes the fourteenth unnecessary: completion + // holds for chrome invented right here, that no branch and no pattern in the + // relay has any knowledge of. + const ALIEN_CHROME = [ + '╭───────────────────────────────────────╮', + '│ ⟡⟡⟡ nobody has ever shipped this UI │', + '╰───────────────────────────────────────╯', + 'HIVE_VERDICT: complete — did the thing', + '⟿ ⟿ ⟿', + ].join('\n'); + const relay = loadRelay({ backend: 'claude', paneText: ALIEN_CHROME }); + try { + assert.notStrictEqual(relay.classifyTmuxPane(ALIEN_CHROME), relay.PANE_STATE_IDLE_COMPLETE, + 'setup: unrecognised chrome must not classify as complete on its own'); + relay.setCliReady(true); + assignTask(relay, 't-alien'); + relay.__crashTick(); + const completed = relay.__sent.filter(m => m.type === 'task_complete'); + assert.strictEqual(completed.length, 1, + 'a restyled CLI must not be able to break completion once the agent states it'); + assert.strictEqual(completed[0].completion_signal, 'verdict'); + } finally { teardown(relay); } +}); + +test('#5376 chrome alone does NOT complete a task on a single tick', () => { + // The other half of the bar, and the demotion itself. IDLE_COMPLETE chrome + // with no sentinel must report progress and WAIT — the momentary misreads + // behind the thirteen issues (a duration summary printed mid-turn, a status + // row between tool calls) resolve inside this window by the pane simply + // carrying on. + const IDLE_CHROME_NO_VERDICT = [ + '● Summary: looked at the tests', + '✻ Cogitated for 4m 10s', + '❯ ', + ' ⏵⏵ auto mode on (shift+tab to cycle)', + ].join('\n'); + const relay = loadRelay({ backend: 'claude', paneText: IDLE_CHROME_NO_VERDICT }); + try { + assert.strictEqual(relay.classifyTmuxPane(IDLE_CHROME_NO_VERDICT), relay.PANE_STATE_IDLE_COMPLETE, + 'setup: this is exactly the chrome that used to complete a task by itself'); + relay.setCliReady(true); + assignTask(relay, 't-chrome-only'); + relay.__crashTick(); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_complete').length, 0, + 'one frame of idle chrome must no longer end a task'); + assert.ok(relay.getCurrentTask(), 'the task is still held while the relay waits for a verdict'); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_progress' && m.status === 'working').length, 1, + 'the relay must keep reporting the task as working, not silently do nothing'); + } finally { teardown(relay); } +}); + +test('#5376 a pane that resumes work inside the grace window is never completed', () => { + // The misread, modelled. The pane shows a duration summary and idle chrome + // mid-turn; the CLI then carries on. Under the old contract that first frame + // WAS the completion, and the hub reassigned an issue whose agent was still + // working on it. The grace window has to actually absorb this, and the + // counter has to RESET rather than merely pause. + const MIDTURN_IDLE_FRAME = [ + '✻ Cogitated for 1m 02s', + '❯ ', + ' ⏵⏵ auto mode on (shift+tab to cycle)', + ].join('\n'); + const BACK_TO_WORK = [ + '● Bash(go test ./...)', + '✻ Cogitating… (esc to interrupt)', + ].join('\n'); + let pane = MIDTURN_IDLE_FRAME; + const relay = loadRelay({ backend: 'claude', paneText: () => pane }); + try { + relay.setCliReady(true); + assignTask(relay, 't-midturn'); + // Every tick of the window but the last. + for (let i = 0; i < relay.CHROME_IDLE_GRACE_TICKS - 1; i++) relay.__crashTick(); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_complete').length, 0); + // The turn was never over. + pane = BACK_TO_WORK; + relay.__crashTick(); + assert.strictEqual(relay.getChromeIdleTicks(), 0, + 'resumed work must RESET the grace counter, not leave it primed to fire on the next idle frame'); + // And a single later idle frame must not cash in the earlier ticks. + pane = MIDTURN_IDLE_FRAME; + relay.__crashTick(); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_complete').length, 0, + 'one idle frame after a reset must not complete the task'); + assert.ok(relay.getCurrentTask(), 'the task is still held'); + } finally { teardown(relay); } +}); + +test('#5376 a non-compliant agent still completes — via the bounded chrome-idle fallback', () => { + // The fallback decision, pinned. Option (a) — hold until the progress lease + // expires — was rejected because a non-compliant agent that genuinely + // finished draws nothing more, so the lease is never renewed and the task + // dies as an `environment` FAILURE with its PR already open. This asserts + // the choice that was made instead: bounded grace, then complete, labelled + // so the non-compliance is visible. + const IDLE_NO_VERDICT = [ + '● Opened https://github.com/foo/bar/pull/777', + '✻ Cogitated for 9m 24s', + '❯ ', + ' ⏵⏵ auto mode on (shift+tab to cycle)', + ].join('\n'); + const relay = loadRelay({ backend: 'claude', paneText: IDLE_NO_VERDICT }); + try { + relay.setCliReady(true); + assignTask(relay, 't-noncompliant'); + graceTicks(relay, () => relay.__crashTick()); + const completed = relay.__sent.filter(m => m.type === 'task_complete'); + assert.strictEqual(completed.length, 1, + 'an agent that never emits the sentinel must still be able to finish a task'); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_failed').length, 0, + 'the fallback must never turn a finished task into a failure'); + assert.strictEqual(completed[0].completion_signal, 'chrome_idle', + 'the weaker signal must be labelled, so per-backend non-compliance is measurable'); + assert.strictEqual(completed[0].pr_url, 'https://github.com/foo/bar/pull/777'); + } finally { teardown(relay); } +}); + +test('#5376 an idle pane awaiting its verdict is not handed to the stall backstop', () => { + // The trap in the fallback. An idle pane is byte-for-byte identical frame to + // frame, so if the grace window merely declined to complete and fell through + // to the stall detector, a finished task would be handed back as an + // `environment` failure — the #4127/#4182 shape, reintroduced by the very + // change meant to end it. The stall clock is aged past its timeout on every + // tick here to prove the IDLE_COMPLETE branch owns the pane throughout. + const IDLE_NO_VERDICT = [ + '✻ Cogitated for 9m 24s', + '❯ ', + ' ⏵⏵ auto mode on (shift+tab to cycle)', + ].join('\n'); + const relay = loadRelay({ backend: 'claude', paneText: IDLE_NO_VERDICT }); + try { + relay.setCliReady(true); + assignTask(relay, 't-idle-stall'); + graceTicks(relay, () => { + relay.__agePaneStallClock(relay.PANE_STALL_TIMEOUT_MS + 1); + relay.__stallTick(); + }); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_failed').length, 0, + 'waiting for a verdict must never be charged to the stall backstop'); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_complete').length, 1); + } finally { teardown(relay); } +}); + +test('#5376 a verdict does NOT launder an API error into a completion', () => { + // A stale sentinel from earlier in the transcript, sitting above a turn that + // ended in an authorization refusal. The verdict path must not be able to + // report this as a success — that is #5094 and #4400 in a new coat, and + // those branches own this pane. + const VERDICT_ABOVE_FATAL_ERROR = [ + 'HIVE_VERDICT: complete — finished the previous piece', + '● Continuing with the next part…', + 'API Error: 403 Forbidden', + '❯ ', + ' ⏵⏵ auto mode on (shift+tab to cycle)', + ].join('\n'); + const relay = loadRelay({ backend: 'claude', paneText: VERDICT_ABOVE_FATAL_ERROR }); + try { + assert.strictEqual(relay.classifyTmuxPane(VERDICT_ABOVE_FATAL_ERROR), relay.PANE_STATE_FATAL_API_ERROR, + 'setup: this pane must classify as a fatal API error'); + relay.setCliReady(true); + assignTask(relay, 't-verdict-vs-error'); + relay.__crashTick(); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_complete').length, 0, + 'a turn that ended in an API failure shipped nothing and must not be booked complete'); + const failed = relay.__sent.filter(m => m.type === 'task_failed'); + assert.strictEqual(failed.length, 1, 'it must be handed back honestly'); + assert.strictEqual(failed[0].failure_kind, 'environment'); + } finally { teardown(relay); } +}); + +test('#5376 the grace window does not carry across tasks', () => { + // Idle ticks accumulated while the previous task wound down must never count + // toward ending the next one — the same per-task scoping bug #5094 fixed for + // the retry budget and #5281 for the autonomy nudge. + const IDLE_NO_VERDICT = ['✻ Cogitated for 1m', '❯ ', ' ⏵⏵ auto mode on (shift+tab to cycle)'].join('\n'); + const relay = loadRelay({ backend: 'claude', paneText: IDLE_NO_VERDICT }); + try { + relay.setCliReady(true); + assignTask(relay, 't-one'); + graceTicks(relay, () => relay.__crashTick()); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_complete').length, 1, 'setup: first task completes'); + + assignTask(relay, 't-two'); + assert.strictEqual(relay.getChromeIdleTicks(), 0, 'a new task starts with a clean grace counter'); + relay.__crashTick(); + assert.strictEqual(relay.__sent.filter(m => m.type === 'task_complete' && m.task_id === 't-two').length, 0, + 'the second task must earn its own grace window, not inherit the first one\'s'); + } finally { teardown(relay); } +}); + +// --- detectCompletionVerdict / detectHiveVerdict (#5376) --------------------- + +test('#5376 detectCompletionVerdict accepts complete and no_work_needed, and nothing else', () => { + const relay = loadRelay({}); + try { + const c = relay.detectCompletionVerdict(['work happened', 'HIVE_VERDICT: complete — shipped PR #1']); + assert.strictEqual(c.verdict, 'complete'); + assert.strictEqual(c.reason, 'shipped PR #1'); + + // no_work_needed IS a completion: it is the agent concluding the task with + // nothing to ship. Requiring a second line after it would make a compliant + // agent look non-compliant. + assert.strictEqual(relay.detectCompletionVerdict(['HIVE_VERDICT: no_work_needed — gated']).verdict, + 'no_work_needed'); + + // Codex's leading bullet is presentation chrome, not part of the verdict. + assert.strictEqual(relay.detectCompletionVerdict([' • HIVE_VERDICT: complete - done']).verdict, 'complete'); + // Case-insensitive, as the no_work_needed marker has always been. + assert.strictEqual(relay.detectCompletionVerdict(['hive_verdict: COMPLETE']).verdict, 'complete'); + + // An invented verdict is not a completion. + assert.strictEqual(relay.detectCompletionVerdict(['HIVE_VERDICT: probably_fine — eh']), null); + } finally { teardown(relay); } +}); + +test('#5376 the completion sentinel inherits the anti-false-positive guards, not a second parser', () => { + // These are the guards #3987/#4265 built for no_work_needed. Extending the + // family must not have created a weaker parser alongside the hardened one — + // if it had, the prompt's own echo would read as the agent finishing before + // it started. + const relay = loadRelay({}); + try { + // The prompt's placeholder, wrapped by tmux to a visual line start. + assert.strictEqual(relay.detectCompletionVerdict(['HIVE_VERDICT: complete — ']), null, + 'the prompt echo must never read as a completion'); + // Quoted mid-sentence — the prompt instruction itself. + assert.strictEqual( + relay.detectCompletionVerdict(["print a line of the exact form 'HIVE_VERDICT: complete — '"]), null, + 'an unanchored match would complete every task the moment the prompt was typed'); + // Prose that merely begins with the verdict word. + assert.strictEqual(relay.detectCompletionVerdict(['HIVE_VERDICT: completely_wrong']), null); + // Junk in, null out — every caller is on a best-effort terminal-capture path. + assert.strictEqual(relay.detectCompletionVerdict([]), null); + assert.strictEqual(relay.detectCompletionVerdict('not-an-array'), null); + assert.strictEqual(relay.detectCompletionVerdict(['ordinary output']), null); + } finally { teardown(relay); } +}); + +test('#5376 the newest verdict wins, so a stale one cannot end a later turn', () => { + const relay = loadRelay({}); + try { + const v = relay.detectCompletionVerdict([ + 'HIVE_VERDICT: no_work_needed — nothing here', + 'actually, on reflection, there was work', + 'HIVE_VERDICT: complete — opened PR #2', + ]); + assert.strictEqual(v.verdict, 'complete'); + assert.strictEqual(v.reason, 'opened PR #2'); + } finally { teardown(relay); } +}); + +test('#5376 no_work_needed detection is unchanged by the shared parser', () => { + // detectNoWorkVerdict feeds the hub's long offer-suppression window (#3987) + // and the HEADLESS completion path, neither of which this change touches. + // It must not have started matching `complete`. + const relay = loadRelay({}); + try { + assert.strictEqual(relay.detectNoWorkVerdict(['HIVE_VERDICT: complete — shipped']), null, + 'a completion verdict is not a no_work_needed verdict'); + assert.strictEqual(relay.detectNoWorkVerdict(['HIVE_VERDICT: no_work_needed — gated']).verdict, + 'no_work_needed'); + } finally { teardown(relay); } +}); + +test('#5376 a no_work_needed verdict still completes the task and reports the verdict', () => { + // End to end on the interactive path: the #3987 contract must survive the + // demotion — and, now, complete on the sentinel rather than waiting out the + // grace window for chrome to agree. + const NO_WORK_PANE = [ + 'HIVE_VERDICT: no_work_needed — remainder is gated on a maintainer decision', + '✻ Cogitating… (esc to interrupt)', + ].join('\n'); + const relay = loadRelay({ backend: 'claude', paneText: NO_WORK_PANE }); + try { + relay.setCliReady(true); + assignTask(relay, 't-nowork'); + relay.__crashTick(); + const completed = relay.__sent.filter(m => m.type === 'task_complete'); + assert.strictEqual(completed.length, 1, 'no_work_needed is a completion'); + assert.strictEqual(completed[0].verdict, 'no_work_needed'); + assert.strictEqual(completed[0].verdict_reason, 'remainder is gated on a maintainer decision'); + assert.strictEqual(completed[0].completion_signal, 'verdict'); + } finally { teardown(relay); } +}); + +test('#5376 a shipped PR still overrides a no_work_needed claim', () => { + // #3987: a visible PR contradicts "nothing shippable", so the verdict is not + // reported (the hub would override it with "shipped" anyway). The task still + // completes — on the sentinel. + const PANE = [ + 'Opened https://github.com/foo/bar/pull/31', + 'HIVE_VERDICT: no_work_needed — I thought there was nothing to do', + '✻ Cogitating… (esc to interrupt)', + ].join('\n'); + const relay = loadRelay({ backend: 'claude', paneText: PANE }); + try { + relay.setCliReady(true); + assignTask(relay, 't-nowork-with-pr'); + relay.__crashTick(); + const completed = relay.__sent.filter(m => m.type === 'task_complete'); + assert.strictEqual(completed.length, 1); + assert.strictEqual(completed[0].pr_url, 'https://github.com/foo/bar/pull/31'); + assert.strictEqual(completed[0].verdict, undefined, + 'a visible PR contradicts no_work_needed, so the claim must not be forwarded'); + } finally { teardown(relay); } +}); + +test('#5376 classifyTmuxPane keeps every one of its stall/liveness branches', () => { + // The demotion removed the classifier's AUTHORITY over completion, not its + // patterns. Those forty-odd patterns are still correct for "is this pane + // moving", which is what the stall backstop and the blocked/error branches + // read. Deleting them would blind those paths. + const relay = loadRelay({ backend: 'claude' }); + try { + assert.strictEqual(relay.classifyTmuxPane('✻ Cogitating… (esc to interrupt)'), relay.PANE_STATE_WORKING); + assert.strictEqual( + relay.classifyTmuxPane('✻ Cogitated for 4m\n❯ \n ⏵⏵ auto mode on (shift+tab to cycle)'), + relay.PANE_STATE_IDLE_COMPLETE, + 'IDLE_COMPLETE still exists — it is a liveness reading now, not a completion'); + } finally { teardown(relay); } +}); + +test('#5376 recordChromeIdleTick fires only after the full consecutive window', () => { + const relay = loadRelay({}); + try { + relay.resetChromeIdleGrace(); + for (let i = 1; i < relay.CHROME_IDLE_GRACE_TICKS; i++) { + assert.strictEqual(relay.recordChromeIdleTick(true), false, `tick ${i} must not fire`); + } + assert.strictEqual(relay.recordChromeIdleTick(true), true, 'the last tick of the window fires'); + // Consecutive, not cumulative. + relay.resetChromeIdleGrace(); + relay.recordChromeIdleTick(true); + assert.strictEqual(relay.recordChromeIdleTick(false), false); + assert.strictEqual(relay.getChromeIdleTicks(), 0, 'a non-idle tick resets the window'); + } finally { teardown(relay); } +}); + // --------------------------------------------------------------------------- let failed = 0; diff --git a/bin/hive-baseline-check.sh b/bin/hive-baseline-check.sh new file mode 100755 index 000000000..bca80f83c --- /dev/null +++ b/bin/hive-baseline-check.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# hive-baseline-check.sh — distinguish a PR-local check failure from a shared +# repository incident by comparing the exact check on the default branch and +# across open sibling PRs. + +set -u -o pipefail + +usage() { + cat <<'EOF' +Usage: hive-baseline-check.sh [--threshold N] [--json] + +Exit status: + 0 shared failure: red on the default branch or on N sibling PRs + 1 isolated failure: neither shared-failure condition was met + 2 unknown: invalid input, missing dependency, or GitHub/API failure + +The sibling threshold defaults to 3 and can also be set with +HIVE_BASELINE_SIBLING_THRESHOLD. +EOF +} + +error() { + echo "hive-baseline-check: $*" >&2 + exit 2 +} + +if [[ $# -lt 2 ]]; then + usage >&2 + exit 2 +fi + +REPO="$1" +CHECK_NAME="$2" +shift 2 + +THRESHOLD="${HIVE_BASELINE_SIBLING_THRESHOLD:-3}" +JSON_OUTPUT=false +while [[ $# -gt 0 ]]; do + case "$1" in + --threshold) + [[ $# -ge 2 ]] || error "--threshold requires a value" + THRESHOLD="$2" + shift 2 + ;; + --json) + JSON_OUTPUT=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + error "unknown argument: $1" + ;; + esac +done + +[[ "$REPO" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || error "repository must be owner/name" +[[ -n "$CHECK_NAME" ]] || error "check name must not be empty" +[[ "$THRESHOLD" =~ ^[1-9][0-9]*$ ]] || error "threshold must be a positive integer" +command -v gh >/dev/null 2>&1 || error "gh is required" +command -v jq >/dev/null 2>&1 || error "jq is required" + +if ! REPO_JSON="$(gh api "repos/$REPO")"; then + error "could not read repository metadata for $REPO" +fi +if ! BASE_BRANCH="$(jq -er '.default_branch | select(type == "string" and length > 0)' <<<"$REPO_JSON")"; then + error "repository metadata did not contain a default branch" +fi + +# A commit may contain older attempts of the same named check after a rerun. +# Select the newest attempt so a successful rerun clears an earlier failure. +if ! BASE_RUNS_JSON="$(gh api "repos/$REPO/commits/$BASE_BRANCH/check-runs?per_page=100")"; then + error "could not read checks for $REPO@$BASE_BRANCH" +fi +if ! BASE_CONCLUSION="$(jq -er --arg check "$CHECK_NAME" ' + [.check_runs[]? | select(.name == $check)] + | sort_by(.completed_at // .started_at // "") + | (last // {}) + | (.conclusion // .status // "missing") + ' <<<"$BASE_RUNS_JSON")"; then + error "invalid check-run response for $REPO@$BASE_BRANCH" +fi +BASE_CONCLUSION="${BASE_CONCLUSION,,}" + +if ! PRS_JSON="$(gh pr list --repo "$REPO" --state open --limit 100 --json number,statusCheckRollup)"; then + error "could not read open PR checks for $REPO" +fi +if ! SIBLING_PRS="$(jq -cer --arg check "$CHECK_NAME" ' + [ + .[] + | select(any(.statusCheckRollup[]?; + ((.name // .context // "") == $check) and + (((.conclusion // .state // "") | ascii_downcase) as $state + | (["failure", "error", "cancelled", "timed_out", "action_required", "startup_failure", "stale"] + | index($state)) != null))) + | .number + ] + | unique + | sort + ' <<<"$PRS_JSON")"; then + error "invalid PR check response for $REPO" +fi +SIBLING_COUNT="$(jq -r 'length' <<<"$SIBLING_PRS")" + +BASE_RED=false +case "$BASE_CONCLUSION" in + failure|error|cancelled|timed_out|action_required|startup_failure|stale) + BASE_RED=true + ;; +esac + +SHARED=false +REASON="isolated" +if [[ "$BASE_RED" == true ]]; then + SHARED=true + REASON="default-branch" +elif (( SIBLING_COUNT >= THRESHOLD )); then + SHARED=true + REASON="sibling-prs" +fi + +if [[ "$JSON_OUTPUT" == true ]]; then + jq -cn \ + --arg repo "$REPO" \ + --arg check "$CHECK_NAME" \ + --arg base_branch "$BASE_BRANCH" \ + --arg base_conclusion "$BASE_CONCLUSION" \ + --arg reason "$REASON" \ + --argjson shared "$SHARED" \ + --argjson threshold "$THRESHOLD" \ + --argjson sibling_prs "$SIBLING_PRS" \ + '{repo:$repo, check:$check, shared:$shared, reason:$reason, + base_branch:$base_branch, base_conclusion:$base_conclusion, + sibling_threshold:$threshold, sibling_prs:$sibling_prs}' +elif [[ "$REASON" == "default-branch" ]]; then + printf 'SHARED: check "%s" on %s default branch "%s" is red (%s).\n' \ + "$CHECK_NAME" "$REPO" "$BASE_BRANCH" "$BASE_CONCLUSION" +elif [[ "$REASON" == "sibling-prs" ]]; then + printf 'SHARED: check "%s" is red on %s open sibling PR(s) in %s (threshold %s): %s\n' \ + "$CHECK_NAME" "$SIBLING_COUNT" "$REPO" "$THRESHOLD" "$SIBLING_PRS" +else + printf 'ISOLATED: check "%s" is %s on %s default branch "%s" and red on %s open sibling PR(s) (threshold %s).\n' \ + "$CHECK_NAME" "$BASE_CONCLUSION" "$REPO" "$BASE_BRANCH" "$SIBLING_COUNT" "$THRESHOLD" +fi + +if [[ "$SHARED" == true ]]; then + exit 0 +fi +exit 1 diff --git a/bin/hive-deploy.sh b/bin/hive-deploy.sh index 27e509ff5..e13bb14b8 100755 --- a/bin/hive-deploy.sh +++ b/bin/hive-deploy.sh @@ -93,6 +93,16 @@ for src in "$HIVE_REPO"/bin/*.sh; do fi done +# New helpers do not exist at the destination yet, so the generic drift loop's +# "installed files only" guard cannot bootstrap them. Keep this explicit until +# all supported native installations have received the #5110 classifier. +BASELINE_HELPER_SRC="$HIVE_REPO/bin/hive-baseline-check.sh" +BASELINE_HELPER_DST="$INSTALL_DIR/hive-baseline-check.sh" +if [ -f "$BASELINE_HELPER_SRC" ] && ! cmp -s "$BASELINE_HELPER_SRC" "$BASELINE_HELPER_DST" 2>/dev/null; then + sudo install -m 0755 "$BASELINE_HELPER_SRC" "$BASELINE_HELPER_DST" + SYNCED="$SYNCED hive-baseline-check.sh" +fi + # hive.sh is installed as /usr/local/bin/hive (no .sh extension) HIVE_CLI="$HIVE_REPO/bin/hive.sh" HIVE_INSTALLED="$INSTALL_DIR/hive" diff --git a/bin/pi-backend.js b/bin/pi-backend.js new file mode 100644 index 000000000..a8741b383 --- /dev/null +++ b/bin/pi-backend.js @@ -0,0 +1,236 @@ +'use strict'; + +// Pi contributor adapter contract (kubestellar/hive#5039). +// +// AGENT_MODEL is the one contributor-owned selection input and MUST be the +// canonical provider/model spelling Pi itself understands. There is +// deliberately no HIVE/PI provider variable: provider-specific credentials +// stay in Pi's official environment variables or ~/.pi/agent/auth.json, and +// the hub remains the sole authority for task assignment. + +const fs = require('fs'); +const path = require('path'); + +const PROVIDER_MAX_LEN = 64; +const MODEL_MAX_LEN = 256; + +// Official Pi provider credential variables. Values are inspected only for +// presence and exact-value redaction; they are never returned or logged. +const PROVIDER_CREDENTIAL_ENV = Object.freeze({ + anthropic: ['ANTHROPIC_API_KEY'], + 'azure-openai-responses': ['AZURE_OPENAI_API_KEY'], + openai: ['OPENAI_API_KEY'], + deepseek: ['DEEPSEEK_API_KEY'], + google: ['GEMINI_API_KEY'], + mistral: ['MISTRAL_API_KEY'], + groq: ['GROQ_API_KEY'], + cerebras: ['CEREBRAS_API_KEY'], + 'cloudflare-ai-gateway': ['CLOUDFLARE_API_KEY', 'CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_GATEWAY_ID'], + 'cloudflare-workers-ai': ['CLOUDFLARE_API_KEY', 'CLOUDFLARE_ACCOUNT_ID'], + xai: ['XAI_API_KEY'], + openrouter: ['OPENROUTER_API_KEY'], + 'vercel-ai-gateway': ['AI_GATEWAY_API_KEY'], + zai: ['ZAI_API_KEY'], + opencode: ['OPENCODE_API_KEY'], + 'opencode-go': ['OPENCODE_API_KEY'], + huggingface: ['HF_TOKEN'], + fireworks: ['FIREWORKS_API_KEY'], + 'kimi-coding': ['KIMI_API_KEY'], + minimax: ['MINIMAX_API_KEY'], + 'minimax-cn': ['MINIMAX_CN_API_KEY'], + xiaomi: ['XIAOMI_API_KEY'], + 'xiaomi-token-plan-cn': ['XIAOMI_TOKEN_PLAN_CN_API_KEY'], + 'xiaomi-token-plan-ams': ['XIAOMI_TOKEN_PLAN_AMS_API_KEY'], + 'xiaomi-token-plan-sgp': ['XIAOMI_TOKEN_PLAN_SGP_API_KEY'], + // Ambient cloud credentials: presence means configured, never authenticated. + 'amazon-bedrock': ['AWS_PROFILE', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'AWS_BEARER_TOKEN_BEDROCK', 'AWS_WEB_IDENTITY_TOKEN_FILE', 'AWS_REGION'], + 'google-vertex': ['GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'], +}); + +function parsePiModelSelection(raw) { + if (typeof raw !== 'string' || raw.length === 0) { + return { valid: false, state: 'missing', error: 'AGENT_MODEL is required for the Pi backend; use provider/model' }; + } + if (raw !== raw.trim() || /[\x00-\x20\x7f]/.test(raw) || raw.length > MODEL_MAX_LEN) { + return { valid: false, state: 'invalid', error: `invalid Pi AGENT_MODEL; expected one bounded provider/model token` }; + } + const slash = raw.indexOf('/'); + if (slash <= 0 || slash === raw.length - 1) { + return { valid: false, state: 'invalid', error: 'invalid Pi AGENT_MODEL; expected provider/model' }; + } + const provider = raw.slice(0, slash); + const model = raw.slice(slash + 1); + if (provider.length > PROVIDER_MAX_LEN || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(provider)) { + return { valid: false, state: 'invalid', error: 'invalid Pi provider in AGENT_MODEL' }; + } + // This token is also used by the interactive shell launcher. Restrict it to + // model-ID punctuation so a contributor preference can never become shell + // syntax while retaining paths (OpenRouter), tags (Ollama), and revisions. + if (!/^[A-Za-z0-9][A-Za-z0-9._/+:@~-]*$/.test(model)) { + return { valid: false, state: 'invalid', error: 'invalid Pi model in AGENT_MODEL' }; + } + return { valid: true, state: 'configured', provider, model, canonical: raw }; +} + +function piAgentDir(env = process.env) { + return env.PI_CODING_AGENT_DIR || path.join(env.HOME || '', '.pi', 'agent'); +} + +function readJSON(file) { + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return null; } +} + +function authEntryConfigured(entry) { + if (typeof entry === 'string') return entry.trim().length > 0; + if (!entry || typeof entry !== 'object') return false; + return Object.values(entry).some(v => typeof v === 'string' && v.trim().length > 0); +} + +function piCredentialConfiguration(selection, env = process.env) { + if (!selection || !selection.valid) return 'unknown'; + const provider = selection.provider.toLowerCase(); + const auth = readJSON(path.join(piAgentDir(env), 'auth.json')); + if (auth && authEntryConfigured(auth[provider])) return 'configured_unverified'; + + const names = PROVIDER_CREDENTIAL_ENV[provider] || []; + if (names.some(name => typeof env[name] === 'string' && env[name].trim().length > 0)) { + return 'configured_unverified'; + } + + // A custom provider may carry its narrow auth reference in models.json. We + // report only its presence; resolving or printing the value is Pi's job. + const models = readJSON(path.join(piAgentDir(env), 'models.json')); + const custom = models && models.providers && models.providers[selection.provider]; + if (custom && authEntryConfigured(custom.apiKey)) return 'configured_unverified'; + return 'missing'; +} + +function collectStrings(value, out) { + if (typeof value === 'string') { + if (value.length >= 4 && !value.startsWith('!') && !/^[A-Z][A-Z0-9_]*$/.test(value)) out.push(value); + return; + } + if (Array.isArray(value)) return value.forEach(v => collectStrings(v, out)); + if (value && typeof value === 'object') Object.values(value).forEach(v => collectStrings(v, out)); +} + +function piCredentialValues(selection, env = process.env) { + if (!selection || !selection.valid) return []; + const provider = selection.provider.toLowerCase(); + const values = []; + for (const name of PROVIDER_CREDENTIAL_ENV[provider] || []) { + if (typeof env[name] === 'string' && env[name].length >= 4) values.push(env[name]); + } + const auth = readJSON(path.join(piAgentDir(env), 'auth.json')); + if (auth) collectStrings(auth[provider], values); + const models = readJSON(path.join(piAgentDir(env), 'models.json')); + const custom = models && models.providers && models.providers[selection.provider]; + if (custom) collectStrings(custom.apiKey, values); + return [...new Set(values)].sort((a, b) => b.length - a.length); +} + +function providerCredentialEnvNames(selection) { + if (!selection || !selection.valid) return []; + return [...(PROVIDER_CREDENTIAL_ENV[selection.provider.toLowerCase()] || [])]; +} + +function unselectedProviderCredentialEnvNames(selection) { + const selected = new Set(providerCredentialEnvNames(selection)); + return [...new Set(Object.values(PROVIDER_CREDENTIAL_ENV).flat())] + .filter(name => !selected.has(name)) + .sort(); +} + +function rewriteJSON(file, value) { + const temporary = `${file}.hive-${process.pid}`; + fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + fs.renameSync(temporary, file); +} + +function onlySelectedProvider(entries, provider) { + if (!entries || typeof entries !== 'object' || Array.isArray(entries)) return entries; + const wanted = provider.toLowerCase(); + return Object.fromEntries(Object.entries(entries).filter(([name]) => name.toLowerCase() === wanted)); +} + +// The contributor container gets an ephemeral Pi config copy. Narrow the two +// official credential-bearing maps before mounting it so selecting one provider +// cannot expose auth material for every other provider in the host profile. +function narrowPiStage(stageDir, selection) { + if (!selection || !selection.valid) throw new Error(selection?.error || 'invalid Pi selection'); + const authFile = path.join(stageDir, 'agent', 'auth.json'); + const auth = readJSON(authFile); + if (fs.existsSync(authFile) && (!auth || typeof auth !== 'object' || Array.isArray(auth))) { + throw new Error('refusing to stage malformed Pi auth.json'); + } + if (auth && typeof auth === 'object' && !Array.isArray(auth)) { + rewriteJSON(authFile, onlySelectedProvider(auth, selection.provider)); + } + const modelsFile = path.join(stageDir, 'agent', 'models.json'); + const models = readJSON(modelsFile); + if (fs.existsSync(modelsFile) && (!models || typeof models !== 'object' || Array.isArray(models))) { + throw new Error('refusing to stage malformed Pi models.json'); + } + if (models && typeof models === 'object' && !Array.isArray(models) && models.providers) { + rewriteJSON(modelsFile, { ...models, providers: onlySelectedProvider(models.providers, selection.provider) }); + } +} + +function redactPiCredentials(text, selection, env = process.env) { + let out = String(text || ''); + for (const value of piCredentialValues(selection, env)) out = out.split(value).join('***REDACTED***'); + return out; +} + +function piReadiness(selection, cliPresent, invocation = 'untested', env = process.env) { + return { + pi_binary: cliPresent ? 'present' : 'unavailable', + pi_configuration: selection && selection.valid ? 'configured' : ((selection && selection.state) || 'missing'), + // A configured secret is not authentication proof. Only a successful real + // provider invocation advances this stage to verified. + pi_authentication: invocation === 'succeeded' ? 'verified' : piCredentialConfiguration(selection, env), + pi_invocation: invocation, + }; +} + +module.exports = { + parsePiModelSelection, + piCredentialConfiguration, + piCredentialValues, + providerCredentialEnvNames, + unselectedProviderCredentialEnvNames, + narrowPiStage, + redactPiCredentials, + piReadiness, + PROVIDER_CREDENTIAL_ENV, +}; + +if (require.main === module) { + const command = process.argv[2] || ''; + const selectionArg = command.startsWith('--') ? process.argv[3] : command; + const selection = parsePiModelSelection(selectionArg || ''); + if (!selection.valid) { + process.stderr.write(`${selection.error}\n`); + process.exit(2); + } + if (command === '--env-names') { + process.stdout.write(`${providerCredentialEnvNames(selection).join('\n')}\n`); + process.exit(0); + } + if (command === '--unselected-env-names') { + process.stdout.write(`${unselectedProviderCredentialEnvNames(selection).join('\n')}\n`); + process.exit(0); + } + if (command === '--stage') { + const stageDir = process.argv[4] || ''; + if (!stageDir) { + process.stderr.write('Pi stage directory is required\n'); + process.exit(2); + } + narrowPiStage(stageDir, selection); + process.exit(0); + } + // Machine-readable and credential-free: useful to shell entrypoints without + // teaching them a second parser that can drift from relay restart behavior. + process.stdout.write(`${JSON.stringify({ provider: selection.provider, model: selection.canonical, configuration: selection.state, authentication: piCredentialConfiguration(selection) })}\n`); +} diff --git a/bin/test_hive_baseline_check.sh b/bin/test_hive_baseline_check.sh new file mode 100755 index 000000000..694d2388e --- /dev/null +++ b/bin/test_hive_baseline_check.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +set -u -o pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="$ROOT_DIR/bin/hive-baseline-check.sh" +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +PASS=0 +FAIL=0 +MOCK_BIN="$TMP_ROOT/bin" +mkdir -p "$MOCK_BIN" + +cat >"$MOCK_BIN/gh" <<'EOF' +#!/usr/bin/env bash +set -u +printf '%s\n' "$*" >>"$MOCK_GH_LOG" +if [[ "${MOCK_GH_FAIL:-}" == "1" ]]; then + exit 1 +fi +case "$1:$2" in + api:repos/acme/widgets) + printf '{"default_branch":"trunk"}\n' + ;; + api:repos/acme/widgets/commits/trunk/check-runs?per_page=100) + cat "$MOCK_BASE_RUNS" + ;; + pr:list) + cat "$MOCK_PRS" + ;; + *) + echo "unexpected gh invocation: $*" >&2 + exit 1 + ;; +esac +EOF +chmod +x "$MOCK_BIN/gh" + +export MOCK_GH_LOG="$TMP_ROOT/gh.log" +export MOCK_BASE_RUNS="$TMP_ROOT/base.json" +export MOCK_PRS="$TMP_ROOT/prs.json" + +run_helper() { + set +e + OUTPUT="$(PATH="$MOCK_BIN:$PATH" "$SCRIPT" "$@" 2>&1)" + STATUS=$? + set -e +} + +check_status() { + local want="$1" label="$2" + if [[ "$STATUS" -eq "$want" ]]; then + echo " PASS: $label" + PASS=$((PASS + 1)) + else + echo " FAIL: $label (want exit $want, got $STATUS; output: $OUTPUT)" + FAIL=$((FAIL + 1)) + fi +} + +check_output() { + local needle="$1" label="$2" + if [[ "$OUTPUT" == *"$needle"* ]]; then + echo " PASS: $label" + PASS=$((PASS + 1)) + else + echo " FAIL: $label (missing '$needle'; output: $OUTPUT)" + FAIL=$((FAIL + 1)) + fi +} + +check_file_contains() { + local file="$1" needle="$2" label="$3" + if grep -Fq -- "$needle" "$file"; then + echo " PASS: $label" + PASS=$((PASS + 1)) + else + echo " FAIL: $label ($file is missing '$needle')" + FAIL=$((FAIL + 1)) + fi +} + +write_base() { + printf '%s\n' "$1" >"$MOCK_BASE_RUNS" +} + +write_prs() { + printf '%s\n' "$1" >"$MOCK_PRS" +} + +echo "=== hive-baseline-check.sh tests ===" + +write_base '{"check_runs":[{"name":"build","status":"completed","conclusion":"failure","completed_at":"2026-08-30T01:00:00Z"}]}' +write_prs '[]' +run_helper acme/widgets build +check_status 0 "red default-branch check is shared" +check_output 'default branch "trunk" is red' "human output names the real default branch" + +write_base '{"check_runs":[{"name":"build","status":"completed","conclusion":"success","completed_at":"2026-08-30T01:00:00Z"}]}' +write_prs '[ + {"number":11,"statusCheckRollup":[{"name":"build","status":"COMPLETED","conclusion":"FAILURE"}]}, + {"number":12,"statusCheckRollup":[{"name":"build","status":"COMPLETED","conclusion":"TIMED_OUT"}]}, + {"number":13,"statusCheckRollup":[{"context":"build","state":"ERROR"}]}, + {"number":14,"statusCheckRollup":[{"name":"build docs","status":"COMPLETED","conclusion":"FAILURE"}]} +]' +run_helper acme/widgets build --json +check_status 0 "three sibling PR failures are shared" +check_output '"reason":"sibling-prs"' "JSON output identifies sibling evidence" +check_output '"sibling_prs":[11,12,13]' "exact check names avoid substring false positives" + +write_prs '[ + {"number":21,"statusCheckRollup":[{"name":"build","status":"COMPLETED","conclusion":"FAILURE"}]}, + {"number":22,"statusCheckRollup":[{"name":"build","status":"COMPLETED","conclusion":"CANCELLED"}]}, + {"number":23,"statusCheckRollup":[{"name":"build","status":"IN_PROGRESS","conclusion":""}]} +]' +run_helper acme/widgets build +check_status 1 "fewer than three red siblings stay isolated" +check_output '2 open sibling PR(s)' "pending siblings are not counted as red" + +run_helper acme/widgets build --threshold 2 +check_status 0 "threshold override is honored" + +write_base '{"check_runs":[ + {"name":"build","status":"completed","conclusion":"failure","completed_at":"2026-08-30T01:00:00Z"}, + {"name":"build","status":"completed","conclusion":"success","completed_at":"2026-08-30T02:00:00Z"} +]}' +write_prs '[]' +run_helper acme/widgets build --json +check_status 1 "latest rerun wins over an older base failure" +check_output '"base_conclusion":"success"' "JSON reports the selected base conclusion" + +MOCK_GH_FAIL=1 run_helper acme/widgets build +check_status 2 "GitHub lookup failures are unknown, never isolated" + +run_helper not-a-repo build +check_status 2 "malformed repository names are rejected" + +check_file_contains "$ROOT_DIR/src/Dockerfile" \ + 'COPY bin/hive-baseline-check.sh /usr/local/bin/hive-baseline-check.sh' \ + "agent image packages the classifier" +check_file_contains "$ROOT_DIR/bin/hive-deploy.sh" \ + "sudo install -m 0755 \"\$BASELINE_HELPER_SRC\" \"\$BASELINE_HELPER_DST\"" \ + "native deploy bootstraps the classifier" + +echo "" +echo "$PASS passed, $FAIL failed" +[[ "$FAIL" -eq 0 ]] diff --git a/config/backends.conf b/config/backends.conf index a51af61dc..530f4fdd3 100644 --- a/config/backends.conf +++ b/config/backends.conf @@ -20,7 +20,7 @@ # ── Supported backends ────────────────────────────────────────────────── # BACKEND_NAME BINARY PERM_FLAG MODEL_FLAG -KNOWN_BACKENDS="claude copilot goose codex agy bob pi aider litellm" +KNOWN_BACKENDS="claude copilot goose codex agy bob pi aider litellm opencode kilo" # ── Backend → binary mapping ──────────────────────────────────────────── backend_binary() { @@ -34,6 +34,8 @@ backend_binary() { goose) echo "goose" ;; pi) echo "pi" ;; aider) echo "aider" ;; + opencode) echo "opencode" ;; + kilo) echo "kilo" ;; # litellm runs Claude Code pointed at a LiteLLM proxy via ANTHROPIC_BASE_URL litellm) echo "claude" ;; *) echo "$1" ;; @@ -122,6 +124,12 @@ claude_family_local_perm_flag_shell() { local settings settings="$(jq -cn --arg workspace "$HIVE_WORKSPACE_DIR" '{ + permissions: { + allow: [ + "Edit(" + $workspace + "/**)", + "Write(" + $workspace + "/**)" + ] + }, sandbox: { enabled: true, failIfUnavailable: true, @@ -148,6 +156,187 @@ claude_family_local_perm_flag_shell() { printf '%s\n' "${out% }" } +# copilot_local_perm_flag_shell emits the stricter posture used only by +# `just contribute-hive copilot local`. Copilot CLI has its own OS-enforced +# sandbox (MXC: Seatbelt on macOS, bubblewrap on Linux — same underlying +# bubblewrap dependency the Claude local sandbox above already requires), +# turned on with the `--sandbox` flag introduced in copilot-cli 1.0.60 and +# usable in the headless `-p` mode contributor-relay.sh already launches +# Copilot with (`copilot: { flag: '-p' }` in bin/contributor-relay.sh). +# `--no-sandbox` is also a real flag, so a stale/older `copilot` binary +# silently ignoring `--sandbox` is not something to assume — this function +# verifies the installed CLI actually understands the flag before relying on +# it, and fails closed (falls through to the deny-list-only unconfined path +# below, loudly) rather than claim a boundary an old binary does not have. +# +# --add-dir grants exactly HIVE_WORKSPACE_DIR, same as the Claude/Codex +# grants above, so build/test tooling can still reach the assigned repo. +# Copilot's sandbox does not have a documented filesystem-allowlist flag of +# its own the way Claude's --settings JSON does — --sandbox plus --add-dir is +# the full extent of what the CLI exposes. +copilot_local_perm_flag_shell() { + if is_truthy "${HIVE_COPILOT_DANGEROUSLY_BYPASS_SANDBOX:-}"; then + backend_perm_flag_shell copilot + return + fi + if ! copilot --help 2>&1 | grep -qe '--sandbox'; then + echo "WARNING: installed copilot CLI has no --sandbox flag (needs copilot-cli >= 1.0.60)." >&2 + echo " Falling back UNCONFINED — set HIVE_COPILOT_DANGEROUSLY_BYPASS_SANDBOX=1 to" >&2 + echo " silence this warning, or upgrade copilot-cli to get local confinement." >&2 + backend_perm_flag_shell copilot + return + fi + local -a args=(--sandbox --allow-all-tools) + if [[ -n "${HIVE_WORKSPACE_DIR:-}" ]]; then + args+=(--add-dir "$HIVE_WORKSPACE_DIR") + fi + + local word out="" + for word in "${args[@]}"; do + out+="$(printf '%q' "$word") " + done + printf '%s\n' "${out% }" +} + +# opencode_local_perm_flag_shell narrows opencode's local posture with the +# SAME host-state command family the claude deny-list covers, via opencode's +# own `permission.bash` pattern-match config (https://opencode.ai/docs/permissions/). +# +# THIS IS A FLOOR, NOT A SANDBOX — same honest limitation as +# CLAUDE_HOST_DENY_TOOLS above and the same as #4938's original claude +# denylist: opencode has no OS-enforced filesystem boundary of its own +# (confirmed against the current opencode CLI/docs: no bwrap/seatbelt +# integration, no workspace-write mode). "deny" rules are documented to stay +# enforced even under --auto ("auto mode only changes requests that would +# otherwise ask for approval"), so this is a real narrowing, not a no-op — +# but it is a command-name denylist like Claude's, not a filesystem +# boundary like Claude's or Copilot's --sandbox. +# +# OPENCODE_PERMISSION carries inline JSON (documented opencode env var) so no +# on-disk config file is mutated. Explicit deny wins because opencode +# evaluates patterns in order with "last matching rule wins", and this key is +# assembled with catch-all "allow" first, "*" host-state denials last. +OPENCODE_HOST_DENY_PATTERNS='sudo *:pkexec *:doas *:su *:rpm-ostree *:bootc *:ostree *:grubby *:bootctl *:efibootmgr *' + +opencode_local_perm_flag_shell() { + if is_truthy "${HIVE_OPENCODE_DANGEROUSLY_ALLOW_HOST_STATE:-}"; then + backend_perm_flag_shell opencode + return + fi + if ! command -v jq >/dev/null 2>&1; then + echo "WARNING: jq not found; opencode local mode cannot apply host-state denials." >&2 + echo " Falling back UNCONFINED for host-state commands. Install jq, or set" >&2 + echo " HIVE_OPENCODE_DANGEROUSLY_ALLOW_HOST_STATE=1 to silence this warning." >&2 + backend_perm_flag_shell opencode + return + fi + + local bash_json + bash_json="$( + IFS=':'; set -- $OPENCODE_HOST_DENY_PATTERNS + jq -cn --args '{"*": "allow"} + (($ARGS.positional) | map({(.): "deny"}) | add)' -- "$@" + )" || return + local perm_json + perm_json="$(jq -cn --argjson bash "$bash_json" '{bash: $bash}')" || return + + # opencode reads OPENCODE_PERMISSION as inline JSON (no config file + # mutation, no risk of an agent rewriting a persisted settings file). + local -a args=(--auto) + local word out + out="OPENCODE_PERMISSION=$(printf '%q' "$perm_json") " + for word in "${args[@]}"; do + out+="$(printf '%q' "$word") " + done + printf '%s\n' "${out% }" +} + +# ── Backends with no confinement mechanism at all ─────────────────────── +# +# goose, agy, bob, pi, aider, and kilo expose no OS-level sandbox, no filesystem +# write-allowlist, and no bash-command deny mechanism analogous to +# opencode's `permission.bash` — verified against each CLI's own current +# documentation, not assumed: +# - goose: GOOSE_MODE (auto/approve/chat/smart_approve) governs interactive +# APPROVAL only; none of the four modes confines writes to a directory, +# and only `auto` is usable unattended. +# - agy (Google Antigravity CLI): execution modes (default/accept-edits/ +# plan) govern approval only, same as goose; --dangerously-skip-permissions +# is already what hive passes and there is no lesser mode that still +# confines the filesystem. agy 1.1.22 also advertises `--sandbox` ("Run in +# a sandbox with terminal restrictions enabled"), but it is NOT a local +# OS-level boundary hive can wire here: the binary's own strings show it +# backed by Jetski's remote/cloud sandbox machinery (a +# ListAllSandboxesRequest / Sandbox proto with a network endpoint+port, +# the same shape as a hosted Antigravity cloud workspace), not a local +# bubblewrap/seccomp/Landlock mechanism like Codex's or Copilot's +# `--sandbox`. Treat it as unverified-and-likely-unrelated to host +# confinement until someone confirms otherwise against a live account +# (#5048's follow-up); do not wire it into backend_perm_flag on the +# strength of the flag's help text alone — that is exactly the "sounds +# like a boundary, isn't one" mistake opencode's deny-list already taught +# this repo to check before trusting. +# - bob (IBM bobshell): no sandbox, approval mode, or path-restriction +# mechanism documented anywhere in bob's own docs. +# - pi (@earendil-works/pi-coding-agent): ships with no sandbox by default; +# directory confinement exists only via a third-party extension +# (pi-permission-modes) hive does not install or depend on. +# - aider: has no sandbox of any kind; runs directly against the +# filesystem with no Docker/OS isolation option. +# - kilo (@kilocode/cli): `--auto` is an unattended auto-approve flag, not +# a boundary — same shape as opencode's. Kilo is OpenCode-derived, but +# whether it honors an OPENCODE_PERMISSION-style inline deny-list is +# UNVERIFIED against kilo's own docs/CLI, and #4918's contract forbids +# claiming confinement a backend has not verifiably got. Until someone +# verifies a kilo-native deny mechanism, it gets the same refusal gate +# as the backends above rather than opencode's denylist floor. +# +# An HONEST "unconfined, opt in to proceed" is the documented contract here +# (#4918's own ask: never claim confinement a backend does not have). Local +# mode for these backends REFUSES to launch without the explicit per-backend +# escape hatch below, matching the HIVE_CLAUDE_DANGEROUSLY_* / codex naming +# convention. Container mode remains the confined default for all of them. +unconfined_local_backend_env_var() { + case "$1" in + goose) echo "HIVE_GOOSE_DANGEROUSLY_RUN_UNCONFINED" ;; + agy) echo "HIVE_AGY_DANGEROUSLY_RUN_UNCONFINED" ;; + bob) echo "HIVE_BOB_DANGEROUSLY_RUN_UNCONFINED" ;; + pi) echo "HIVE_PI_DANGEROUSLY_RUN_UNCONFINED" ;; + aider) echo "HIVE_AIDER_DANGEROUSLY_RUN_UNCONFINED" ;; + kilo) echo "HIVE_KILO_DANGEROUSLY_RUN_UNCONFINED" ;; + *) echo "" ;; + esac +} + +# unconfined_local_perm_flag_shell is the local-mode entry point for the six +# backends above. It refuses to emit a launch line at all unless the +# operator has set that backend's own escape-hatch env var — there is no +# confinement to fall back to, so silence is not an option (#4918's +# lesson: an unconfined default that says nothing is exactly the failure +# mode this whole change exists to close). +unconfined_local_perm_flag_shell() { + local backend="$1" var + var="$(unconfined_local_backend_env_var "$backend")" + if [[ -z "$var" ]]; then + # Not one of the six — caller error, not an operator-facing case. + backend_perm_flag_shell "$backend" + return + fi + if ! is_truthy "${!var:-}"; then + echo "ERROR: ${backend} has no sandbox, no filesystem write-allowlist, and no" >&2 + echo " command deny-list hive can wire on the local launch path (verified" >&2 + echo " against ${backend}'s own current docs — this is not a placeholder)." >&2 + echo " It would run as \$(id -un) on this machine with nothing standing" >&2 + echo " between the agent and everything your user can reach." >&2 + echo "" >&2 + echo " Refusing to launch. Either:" >&2 + echo " - use container mode instead: just contribute-hive ${backend}" >&2 + echo " - or explicitly accept the unconfined risk on this host:" >&2 + echo " ${var}=1" >&2 + return 1 + fi + backend_perm_flag_shell "$backend" +} + # ── Backend → permission flag ─────────────────────────────────────────── backend_perm_flag() { case "$1" in @@ -238,6 +427,13 @@ backend_perm_flag() { goose) echo "" ;; pi) echo "" ;; aider) echo "--yes" ;; + # opencode has no OS sandbox of its own (unconfined, like goose/pi/bob) — + # --auto is opencode's unattended auto-approve flag ("Automatically accept + # all permissions", `opencode run --help`), the equivalent of agy's + # --dangerously-skip-permissions. See kubestellar/hive#4970. + opencode) echo "--auto" ;; + # Kilo --auto approves prompts; it is not an OS sandbox. + kilo) echo "--auto" ;; # Same flags as claude — litellm launches the claude binary litellm) claude_family_perm_flag ;; *) echo "" ;; diff --git a/config/contributor.env.example b/config/contributor.env.example index a2f2cd1db..21376320f 100644 --- a/config/contributor.env.example +++ b/config/contributor.env.example @@ -8,7 +8,7 @@ HIVE_REGISTRATION_TOKEN= # Hub WebSocket URL HIVE_HUB=wss://hive.kubestellar.io:3001/contribute -# Preferred CLI backend (claude, copilot, gemini, goose, bob) +# Preferred CLI backend (claude, copilot, goose, codex, pi, bob, agy, litellm, opencode) AGENT_BACKEND=claude # Optional model/effort overrides. AGENT_REASONING_EFFORT is currently @@ -16,6 +16,14 @@ AGENT_BACKEND=claude # AGENT_MODEL=gpt-5.6-luna # AGENT_REASONING_EFFORT=low +# Pi contract: set one canonical provider-qualified model. Provider/model is a +# contributor preference only; Hive remains the task-assignment authority and +# never routes work from this value. Keep credentials in Pi's official +# provider-specific variable (for example OPENAI_API_KEY, ANTHROPIC_API_KEY, or +# GEMINI_API_KEY) or ~/.pi/agent/auth.json — there is no generic PI_API_KEY. +# AGENT_BACKEND=pi +# AGENT_MODEL=openai/gpt-5 + # Codex approval/sandbox posture. Defaults are explicit and non-bypass. Hive # also routes eligible boundary requests through Codex automatic review and # grants only HIVE_WORKSPACE_DIR as an additional writable root: @@ -37,6 +45,6 @@ AGENT_BACKEND=claude # (claude -p, copilot -p, codex exec) and reports the exit # status back to the hub. No tmux, no attaching to type # `/login` — suitable for a future unattended / Kubernetes -# contributor (#2549). Only claude/litellm/copilot/codex are -# supported headlessly today; other backends refuse loudly. +# contributor (#2549). Pi uses --print --mode json; backends +# without a verified one-shot entry point refuse loudly. # CONTRIBUTOR_MODE=interactive diff --git a/dashboard/openapi.json b/dashboard/openapi.json index 376239548..8c3cfb098 100644 --- a/dashboard/openapi.json +++ b/dashboard/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "KubeStellar Hive API", - "description": "Read-only reference for the KubeStellar Hive Dashboard API. This spec covers all GET endpoints exposed by the dashboard server.", + "description": "Reference for the KubeStellar Hive Dashboard API. Covers the dashboard server's registered GET/POST/PUT/DELETE operations.", "version": "1.0.0", "contact": { "name": "KubeStellar", @@ -20,21 +20,69 @@ } ], "tags": [ - { "name": "Status", "description": "Live hive status and monitoring" }, - { "name": "History", "description": "Sparkline, trend, and timeline data" }, - { "name": "Tokens", "description": "Token usage and cost tracking" }, - { "name": "Audit", "description": "Recent dashboard and agent lifecycle audit entries" }, - { "name": "Governor", "description": "Governor mode and configuration" }, - { "name": "Agents", "description": "Agent configuration and state" }, - { "name": "Strategy Lab", "description": "Nous experiment engine" }, - { "name": "Contributors", "description": "Contributor pool management" }, - { "name": "Hives", "description": "Multi-instance hive registry" }, - { "name": "System", "description": "Version, config, and diagnostics" } + { + "name": "Status", + "description": "Live hive status and monitoring" + }, + { + "name": "History", + "description": "Sparkline, trend, and timeline data" + }, + { + "name": "Tokens", + "description": "Token usage and cost tracking" + }, + { + "name": "Audit", + "description": "Recent dashboard and agent lifecycle audit entries" + }, + { + "name": "Governor", + "description": "Governor mode and configuration" + }, + { + "name": "Agents", + "description": "Agent configuration and state" + }, + { + "name": "Strategy Lab", + "description": "Nous experiment engine" + }, + { + "name": "Contributors", + "description": "Contributor pool management" + }, + { + "name": "Hives", + "description": "Multi-instance hive registry" + }, + { + "name": "System", + "description": "Version, config, and diagnostics" + }, + { + "name": "Contribute", + "description": "Public contributor self-service: registration, activity, queue, fleet, and leaderboard discovery" + }, + { + "name": "Inception", + "description": "Greenfield project scaffolding and ideation workflow" + }, + { + "name": "Knowledge", + "description": "Knowledge base facts, vaults, git sources, documents, and channels" + }, + { + "name": "Plan", + "description": "Issue-to-epic planning and plan review" + } ], "paths": { "/api/audit": { "get": { - "tags": ["Audit"], + "tags": [ + "Audit" + ], "summary": "Recent audit log entries", "description": "Returns up to 200 newest audit entries from the dashboard audit ring. Requires read-write or higher access.", "responses": { @@ -50,69 +98,675 @@ "items": { "type": "object", "properties": { - "ts": { "type": "string", "format": "date-time" }, - "user": { "type": "string" }, - "action": { "type": "string" }, - "detail": { "type": "string" }, - "agent": { "type": "string" } + "ts": { + "type": "string", + "format": "date-time" + }, + "user": { + "type": "string", + "description": "Actor identity. May be an opaque OIDC identity key rather than a human-readable name \u2014 see user_name." + }, + "action": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "user_name": { + "type": "string", + "description": "Hub-delivered display name, stamped at SERVE time only when `user` is an opaque OIDC identity key. The audit ring and the on-disk log keep the raw key, so history survives name changes. Absent when there is no display name to add." + } + }, + "description": "dashboard.AuditEntry (src/pkg/dashboard/audit.go)." + } + } + } + } + } + } + }, + "403": { + "description": "Insufficient access" + } + } + } + }, + "/api/approvals": { + "get": { + "tags": [ + "Approvals" + ], + "summary": "List pending approvals", + "description": "Returns the operator-lane approval inbox: pending tool/agent requests awaiting an operator decision, with each row's matching rule re-evaluated against the current rule set. Requires the owner role. When the approval desk is not enabled on this hive, returns enabled=false with an empty list.", + "responses": { + "200": { + "description": "ApprovalsResponse (src/pkg/dashboard/api_approvals.go)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the approval desk is configured on this hive." + }, + "count": { + "type": "integer", + "description": "Pending total (the dashboard badge value)." + }, + "items": { + "type": "array", + "items": { + "type": "object", + "description": "ApprovalRow (src/pkg/dashboard/api_approvals.go).", + "properties": { + "id": { + "type": "string", + "description": "Idempotency key identifying the pending item." + }, + "kind": { + "type": "string", + "description": "Request kind (e.g. agent-tool)." + }, + "tool": { + "type": "string", + "description": "Tool the verdict queued for review." + }, + "agent": { + "type": "string", + "description": "Agent that made the request." + }, + "repo": { + "type": "string", + "description": "Repository, when the request is repo-scoped." + }, + "number": { + "type": "integer", + "description": "Issue/PR number, when applicable." + }, + "title": { + "type": "string", + "description": "Human title for the request." + }, + "rationale": { + "type": "string", + "description": "Verdict rationale explaining why this queued." + }, + "queued_at": { + "type": "string", + "format": "date-time", + "description": "When the item entered the inbox (UTC)." + }, + "acmm_level": { + "type": "integer", + "description": "Hive ACMM level at enqueue time." + }, + "rule": { + "type": "string", + "description": "Operator rule that would resolve this item, re-evaluated against the current rule set." + }, + "rule_action": { + "type": "string", + "description": "Action that rule asks for." + }, + "policy_bug": { + "type": "boolean", + "description": "True when the item queued on a hive at ACMM L6+ (misconfiguration signal)." + } + } + } + }, + "rule_chips": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Distinct rule names across pending items, for filters." + }, + "policy_bug_count": { + "type": "integer", + "description": "How many pending items are queued at ACMM L6+." + }, + "acmm_level": { + "type": "integer", + "description": "The hive's current ACMM level." + } + } + } + } + } + } + } + } + }, + "/api/approvals/resolve": { + "post": { + "tags": [ + "Approvals" + ], + "summary": "Resolve one pending approval", + "description": "Approves or denies a single pending approval by id. Requires the owner role. A replayed resolve returns 409 with the original outcome (the journal prevents re-execution). Returns 404 when the desk is disabled or the id is unknown.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "approved" + ], + "properties": { + "id": { + "type": "string", + "description": "Pending approval id." + }, + "approved": { + "type": "boolean", + "description": "true approves, false denies." + }, + "rationale": { + "type": "string", + "description": "Optional operator rationale." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Resolution outcome", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "approved": { + "type": "boolean" + }, + "resolved_at": { + "type": "string", + "description": "When the item was resolved." + }, + "pending": { + "type": "integer", + "description": "Remaining pending count." + } + } + } + } + } + }, + "400": { + "description": "Missing or invalid body / id" + }, + "404": { + "description": "Approval desk disabled or id unknown" + }, + "409": { + "description": "Already resolved - body carries the original outcome" + } + } + } + }, + "/api/approvals/bulk": { + "post": { + "tags": [ + "Approvals" + ], + "summary": "Resolve many approvals", + "description": "Resolves many pending approvals as N individual resolutions. Partial failure is the normal case (an item may have been resolved by another operator between the list and the click), so the response is a per-item result list. Requires the owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ids", + "approved" + ], + "properties": { + "ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Pending approval ids." + }, + "approved": { + "type": "boolean", + "description": "true approves, false denies." + }, + "rationale": { + "type": "string", + "description": "Optional operator rationale applied to each resolution." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Per-item results", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "error": { + "type": "string" + } } } + }, + "pending": { + "type": "integer", + "description": "Remaining pending count." } } } } } }, - "403": { "description": "Insufficient access" } + "400": { + "description": "Missing or invalid body / ids" + }, + "404": { + "description": "Approval desk disabled" + } } } }, "/api/status": { "get": { - "tags": ["Status"], + "tags": [ + "Status" + ], "summary": "Overall hive status", - "description": "Returns the full hive status including governor state, agent details, repos, queue depth, and all metrics used by the dashboard.", + "description": "Returns the full hive status snapshot the dashboard renders from: governor state, agent runtime details, configured-agent inventory, repos, token rollups, health, budget and ACMM level. The response is dashboard.StatusPayload (src/pkg/dashboard/server.go). Before the first status build completes the handler returns {\"status\": \"initializing\"} instead. Only the fields TUI/API clients consume are enumerated below; StatusPayload carries more.", "responses": { "200": { - "description": "Current hive status", + "description": "Current hive status, or {\"status\": \"initializing\"} before the first status build.", "content": { "application/json": { "schema": { "type": "object", + "description": "dashboard.StatusPayload.", "properties": { + "timestamp": { + "type": "string" + }, + "statusSeq": { + "type": "integer", + "format": "int64", + "description": "Monotonic publish sequence. Clients drop any payload whose seq is older than the last rendered one." + }, + "statusInstance": { + "type": "string", + "description": "Identifies the server process that produced statusSeq; seqs restart at 1 on spoke restart, so a changed instance means reset the guard rather than drop." + }, + "hiveId": { + "type": "string" + }, "governor": { "type": "object", + "description": "dashboard.FrontendGovernor (src/pkg/dashboard/server.go), built by buildGovernor (status_builder.go).", "properties": { - "mode": { "type": "string", "enum": ["surge", "busy", "quiet", "idle"], "description": "Current governor mode" }, - "queue": { "type": "integer", "description": "Actionable issue count" }, - "budgetPct": { "type": "number", "description": "Token budget usage percentage" } - } + "active": { + "type": "boolean", + "description": "Hardcoded true whenever buildGovernor ran; false means the payload carried no governor section, not a stopped governor." + }, + "mode": { + "type": "string", + "enum": [ + "idle", + "quiet", + "busy", + "surge" + ], + "description": "Laddered governor mode, lowercased by buildGovernor." + }, + "issues": { + "type": "integer", + "description": "Actionable issue count the governor laddered on (governor.State.QueueIssues)." + }, + "prs": { + "type": "integer", + "description": "Actionable PR count the governor laddered on (governor.State.QueuePRs)." + }, + "thresholds": { + "type": "object", + "description": "dashboard.FrontendThresholds: the EFFECTIVE ladder boundaries that produced mode, after per-repo scaling. Idle is absent by design \u2014 its threshold is always 0.", + "properties": { + "quiet": { + "type": "integer" + }, + "busy": { + "type": "integer" + }, + "surge": { + "type": "integer" + } + }, + "required": [ + "quiet", + "busy", + "surge" + ] + }, + "nextKick": { + "type": "string", + "description": "When the governor next evaluates, as a PRE-FORMATTED server-local display string (\"1/2 3:04 PM MST\"), NOT RFC 3339. Omitted when no evaluation interval is configured. The interval itself is only published by GET /api/config/governor as general_advanced.eval_interval_s." + } + }, + "required": [ + "active", + "mode", + "issues", + "prs", + "thresholds" + ] }, "agents": { "type": "array", "items": { "type": "object", + "description": "dashboard.FrontendAgent (src/pkg/dashboard/server.go). Only the commonly consumed fields are listed; the struct carries more.", "properties": { - "name": { "type": "string" }, - "status": { "type": "string" }, - "model": { "type": "string" }, - "cli": { "type": "string" }, - "paused": { "type": "boolean" }, - "restarts": { "type": "integer" }, - "pinned": { "type": "boolean" } + "name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "role": { + "type": "string" + }, + "emoji": { + "type": "string" + }, + "session": { + "type": "string", + "description": "tmux session name." + }, + "state": { + "type": "string", + "description": "Runtime state (e.g. \"running\", \"stopped\"). There is no `status` field on this object." + }, + "busy": { + "type": "string" + }, + "paused": { + "type": "boolean" + }, + "pausedReason": { + "type": "string" + }, + "enabled": { + "type": "boolean", + "description": "Mirrors the CONFIG flag, not runtime state \u2014 the only thing distinguishing \"switched off on purpose\" from \"stopped because something broke\". Never omitted: false is meaningful." + }, + "offByCadence": { + "type": "boolean" + }, + "needsLogin": { + "type": "boolean" + }, + "cli": { + "type": "string" + }, + "model": { + "type": "string" + }, + "cadence": { + "type": "string" + }, + "doing": { + "type": "string" + }, + "pinned": { + "type": "boolean" + }, + "pinnedCli": { + "type": "boolean" + }, + "pinnedModel": { + "type": "boolean" + }, + "restarts": { + "type": "integer" + }, + "lastKick": { + "type": "string", + "description": "Pre-formatted server-local display string, not RFC 3339." + }, + "nextKick": { + "type": "string", + "description": "Pre-formatted server-local display string, not RFC 3339." + }, + "mode": { + "type": "string" + }, + "sandboxed": { + "type": "boolean" + }, + "onDemand": { + "type": "boolean" + }, + "proxyViolations": { + "type": "integer" + }, + "lastError": { + "type": "string" + } } } }, + "configuredAgents": { + "type": "array", + "items": { + "type": "object" + }, + "description": "dashboard.FrontendConfiguredAgent \u2014 secret-free config inventory covering agents with no runtime process (notably enabled: false)." + }, "repos": { + "type": "array", + "items": { + "type": "object", + "description": "dashboard.FrontendRepo (src/pkg/dashboard/server.go).", + "properties": { + "name": { + "type": "string", + "description": "Short repo name." + }, + "full": { + "type": "string", + "description": "owner/name." + }, + "issues": { + "type": "integer" + }, + "prs": { + "type": "integer" + }, + "actionableIssues": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Untyped in the Go source ([]any) \u2014 populated from forge issue records whose shape is not fixed by a Go struct." + }, + "openPrs": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Untyped in the Go source ([]any) \u2014 populated from forge PR records whose shape is not fixed by a Go struct." + } + }, + "required": [ + "name", + "full", + "issues", + "prs" + ] + } + }, + "acmmLevel": { + "type": "integer", + "description": "Hive ACMM maturity level, 1-6. TOP-LEVEL, not nested under governor. 1 is also the fallback when nothing is configured \u2014 see acmmLevelConfigured to tell those apart." + }, + "acmmLevelConfigured": { + "type": "boolean", + "description": "False means nobody chose a level and acmmLevel is the fallback." + }, + "acmmPackAgents": { + "type": "array", + "items": { + "type": "string" + } + }, + "tokens": { + "type": "object", + "description": "dashboard.FrontendTokens \u2014 the status-embedded token rollup (lookbackHours, sessions, totals, byAgent, byModel, byAgentModel). This is a DIFFERENT shape from GET /api/tokens, which returns tokens.AggregateSummary with snake_case keys." + }, + "budget": { + "type": "object", + "description": "dashboard.FrontendBudget. Budget is top-level; there is no budgetPct under governor." + }, + "beads": { + "type": "object", + "description": "dashboard.FrontendBeads: {workers, supervisor}." + }, + "planning": { + "type": "object", + "description": "dashboard.FrontendPlanning." + }, + "hold": { + "type": "object", + "description": "dashboard.FrontendHold." + }, + "cadenceMatrix": { + "type": "array", + "items": { + "type": "object" + }, + "description": "dashboard.FrontendCadence entries." + }, + "health": { + "type": "object", + "description": "Untyped in the Go source (map[string]any): repo-workflow health map." + }, + "deepHealth": { + "type": "object", + "description": "Untyped in the Go source (map[string]any): the spoke's own deep health checks, the same ones reported hub-ward in the heartbeat." + }, + "ghRateLimits": { + "type": "object", + "description": "Untyped in the Go source (map[string]any)." + }, + "agentMetrics": { + "type": "object", + "description": "Untyped in the Go source (map[string]any)." + }, + "issueToMerge": { + "type": "object", + "description": "Untyped in the Go source (map[string]any)." + }, + "systemAlerts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + }, + "inferenceBackends": { "type": "array", "items": { "type": "object", "properties": { - "name": { "type": "string" }, - "issues": { "type": "integer" }, - "prs": { "type": "integer" } + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "inference": { + "type": "boolean" + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "models_fallback": { + "type": "boolean", + "description": "True when models is NOT an authoritative census (static aliases, partial sweep, or env list standing in). Consumers must not diff against a fallback list." + } } } + }, + "platform": { + "type": "object", + "description": "dashboard.FrontendPlatform: {forge, mint, skills}." + }, + "security": { + "type": "object", + "description": "dashboard.FrontendSecurity." + }, + "githubAppRequired": { + "type": "boolean" + }, + "githubAppInstallURL": { + "type": "string" + }, + "githubAppInstallMissing": { + "type": "boolean", + "description": "CONFIG TRUTH independent of any auth probe: a real App is named but installation_id is 0." + }, + "status": { + "type": "string", + "enum": [ + "initializing" + ], + "description": "Present ONLY in the pre-first-build response, which is the bare object {\"status\": \"initializing\"} with no other field set." } } } @@ -124,7 +778,9 @@ }, "/api/version": { "get": { - "tags": ["System"], + "tags": [ + "System" + ], "summary": "Git version info", "description": "Returns the current git commit hash and dirty state of the hive repo.", "responses": { @@ -135,10 +791,22 @@ "schema": { "type": "object", "properties": { - "short": { "type": "string", "description": "Short git commit hash" }, - "long": { "type": "string", "description": "Full git commit hash" }, - "dirty": { "type": "boolean", "description": "Whether the working tree has uncommitted changes" }, - "behind": { "type": "integer", "description": "Commits behind origin" } + "short": { + "type": "string", + "description": "Short git commit hash" + }, + "long": { + "type": "string", + "description": "Full git commit hash" + }, + "dirty": { + "type": "boolean", + "description": "Whether the working tree has uncommitted changes" + }, + "behind": { + "type": "integer", + "description": "Commits behind origin" + } } } } @@ -149,7 +817,9 @@ }, "/api/config": { "get": { - "tags": ["System"], + "tags": [ + "System" + ], "summary": "Project configuration", "description": "Returns the project name, org, primary repo, and dashboard title.", "responses": { @@ -160,10 +830,18 @@ "schema": { "type": "object", "properties": { - "projectName": { "type": "string" }, - "primaryRepo": { "type": "string" }, - "org": { "type": "string" }, - "dashboardTitle": { "type": "string" } + "projectName": { + "type": "string" + }, + "primaryRepo": { + "type": "string" + }, + "org": { + "type": "string" + }, + "dashboardTitle": { + "type": "string" + } } } } @@ -174,7 +852,9 @@ }, "/api/budget-ignore": { "get": { - "tags": ["Tokens"], + "tags": [ + "Tokens" + ], "summary": "Budget ignore flag", "description": "Returns whether the token budget is currently being ignored.", "responses": { @@ -185,20 +865,61 @@ "schema": { "type": "object", "properties": { - "ignored": { "type": "boolean" } + "ignored": { + "type": "boolean" + } + } + } + } + } + } + } + }, + "post": { + "tags": [ + "System" + ], + "summary": "Set budget-ignore (exemption) settings", + "description": "Sets either the global budget-ignore-all flag ({\"ignored\": bool}) or the per-agent exemption list ({\"ignored\": [names]}), based on the JSON type of `ignored`. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ignored": { + "description": "Either a boolean (global bypass) or an array of agent names (per-agent exemption list)." } } } } } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Invalid body" + } } } }, "/api/history": { "get": { - "tags": ["History"], + "tags": [ + "History" + ], "summary": "Sparkline history", - "description": "Returns ~120 downsampled status snapshots from the in-memory 12-hour rolling window (5s intervals). Used for sparkline charts.", + "description": "Returns the governor's evaluation history (governor.EvalSnapshot entries) for sparkline charts. When /data/sparkline-history.json exists its seeded entries are prepended to the live in-memory history.", "responses": { "200": { "description": "Array of status snapshots", @@ -208,12 +929,110 @@ "type": "array", "items": { "type": "object", + "description": "governor.EvalSnapshot (src/pkg/governor/governor.go).", "properties": { - "t": { "type": "integer", "description": "Unix timestamp (ms)" }, - "govMode": { "type": "string" }, - "queue": { "type": "integer" }, - "budgetPct": { "type": "number" } - } + "t": { + "type": "integer", + "format": "int64", + "description": "Unix timestamp (ms)." + }, + "govMode": { + "type": "string", + "enum": [ + "idle", + "quiet", + "busy", + "surge" + ], + "description": "Governor mode at this evaluation." + }, + "govIssues": { + "type": "integer", + "description": "Actionable issue count. There is no single `queue` field." + }, + "govPrs": { + "type": "integer", + "description": "Actionable PR count." + }, + "govTotal": { + "type": "integer", + "description": "Combined queue depth the governor laddered on." + }, + "govHold": { + "type": "integer", + "description": "Items held back from the queue." + }, + "govActive": { + "type": "integer", + "description": "Items actively being worked." + }, + "sla_violations": { + "type": "integer" + }, + "agents_kicked": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Agents the governor kicked at this evaluation." + }, + "actionableCount": { + "type": "integer" + }, + "openPrCount": { + "type": "integer" + }, + "mergeableCount": { + "type": "integer" + }, + "beadsWorkers": { + "type": "integer" + }, + "beadsSupervisor": { + "type": "integer" + }, + "repos": { + "type": "object", + "additionalProperties": { + "type": "object", + "description": "governor.RepoSnapshot.", + "properties": { + "issues": { + "type": "integer" + }, + "prs": { + "type": "integer" + } + }, + "required": [ + "issues", + "prs" + ] + }, + "description": "Per-repo queue split, keyed by owner/name." + }, + "agentStats": { + "type": "object", + "additionalProperties": { + "type": "object" + }, + "description": "Untyped in the Go source (map[string]map[string]any): per-agent stat bag whose inner keys are not fixed by a Go struct." + } + }, + "required": [ + "t", + "govMode", + "govIssues", + "govPrs", + "govTotal", + "govHold", + "govActive", + "actionableCount", + "openPrCount", + "mergeableCount", + "beadsWorkers", + "beadsSupervisor" + ] } } } @@ -224,15 +1043,34 @@ }, "/api/trends": { "get": { - "tags": ["History"], + "tags": [ + "History" + ], "summary": "Persistent trend data", - "description": "Returns downsampled persistent history for the given range. Used for trend sparklines and governor mode distribution.", + "description": "Returns the governor's evaluation history (governor.EvalSnapshot entries) filtered to a time window. `range=day|week` or `hours=N` selects the window; hours is clamped to 720 (30 days) and defaults to 24.", "parameters": [ { "name": "range", "in": "query", - "schema": { "type": "string", "enum": ["day", "week", "month"], "default": "week" }, - "description": "Time range to query" + "schema": { + "type": "string", + "enum": [ + "day", + "week" + ] + }, + "description": "Window preset. \"day\" is 24h, \"week\" is 168h. Any other value (including an absent one) falls through to `hours`, defaulting to 24h. Takes precedence over `hours`." + }, + { + "name": "hours", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 720, + "default": 24 + }, + "description": "Window length in hours, used only when `range` is not \"day\" or \"week\". Values above 720 (30 days) are clamped; values at or below 0 become 24." } ], "responses": { @@ -244,12 +1082,110 @@ "type": "array", "items": { "type": "object", + "description": "governor.EvalSnapshot (src/pkg/governor/governor.go).", "properties": { - "t": { "type": "integer", "description": "Unix timestamp (ms)" }, - "govMode": { "type": "string" }, - "queue": { "type": "integer" }, - "budgetPct": { "type": "number" } - } + "t": { + "type": "integer", + "format": "int64", + "description": "Unix timestamp (ms)." + }, + "govMode": { + "type": "string", + "enum": [ + "idle", + "quiet", + "busy", + "surge" + ], + "description": "Governor mode at this evaluation." + }, + "govIssues": { + "type": "integer", + "description": "Actionable issue count. There is no single `queue` field." + }, + "govPrs": { + "type": "integer", + "description": "Actionable PR count." + }, + "govTotal": { + "type": "integer", + "description": "Combined queue depth the governor laddered on." + }, + "govHold": { + "type": "integer", + "description": "Items held back from the queue." + }, + "govActive": { + "type": "integer", + "description": "Items actively being worked." + }, + "sla_violations": { + "type": "integer" + }, + "agents_kicked": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Agents the governor kicked at this evaluation." + }, + "actionableCount": { + "type": "integer" + }, + "openPrCount": { + "type": "integer" + }, + "mergeableCount": { + "type": "integer" + }, + "beadsWorkers": { + "type": "integer" + }, + "beadsSupervisor": { + "type": "integer" + }, + "repos": { + "type": "object", + "additionalProperties": { + "type": "object", + "description": "governor.RepoSnapshot.", + "properties": { + "issues": { + "type": "integer" + }, + "prs": { + "type": "integer" + } + }, + "required": [ + "issues", + "prs" + ] + }, + "description": "Per-repo queue split, keyed by owner/name." + }, + "agentStats": { + "type": "object", + "additionalProperties": { + "type": "object" + }, + "description": "Untyped in the Go source (map[string]map[string]any): per-agent stat bag whose inner keys are not fixed by a Go struct." + } + }, + "required": [ + "t", + "govMode", + "govIssues", + "govPrs", + "govTotal", + "govHold", + "govActive", + "actionableCount", + "openPrCount", + "mergeableCount", + "beadsWorkers", + "beadsSupervisor" + ] } } } @@ -260,7 +1196,9 @@ }, "/api/timeline": { "get": { - "tags": ["History"], + "tags": [ + "History" + ], "summary": "24-hour governor timeline", "description": "Returns ~200 mode snapshots over the last 24 hours for the governor timeline strip.", "responses": { @@ -273,8 +1211,20 @@ "items": { "type": "object", "properties": { - "t": { "type": "integer", "description": "Unix timestamp (ms)" }, - "mode": { "type": "string", "enum": ["surge", "busy", "quiet", "idle", "unknown"] } + "t": { + "type": "integer", + "description": "Unix timestamp (ms)" + }, + "mode": { + "type": "string", + "enum": [ + "surge", + "busy", + "quiet", + "idle", + "unknown" + ] + } } } } @@ -286,7 +1236,9 @@ }, "/api/pane/{agent}": { "get": { - "tags": ["Agents"], + "tags": [ + "Agents" + ], "summary": "Tmux pane preview", "description": "Returns the last 30 lines of the agent's tmux session output.", "parameters": [ @@ -294,7 +1246,9 @@ "name": "agent", "in": "path", "required": true, - "schema": { "type": "string" }, + "schema": { + "type": "string" + }, "description": "Agent name (e.g. scanner, architect)" } ], @@ -306,21 +1260,34 @@ "schema": { "type": "object", "properties": { - "agent": { "type": "string" }, - "session": { "type": "string" }, - "lines": { "type": "array", "items": { "type": "string" } } + "agent": { + "type": "string" + }, + "session": { + "type": "string" + }, + "lines": { + "type": "array", + "items": { + "type": "string" + } + } } } } } }, - "400": { "description": "Unknown agent" } + "400": { + "description": "Unknown agent" + } } } }, "/api/events": { "get": { - "tags": ["Status"], + "tags": [ + "Status" + ], "summary": "SSE event stream", "description": "Server-Sent Events stream of real-time status updates. Each event is a JSON-encoded status snapshot.", "responses": { @@ -328,7 +1295,9 @@ "description": "SSE stream", "content": { "text/event-stream": { - "schema": { "type": "string" } + "schema": { + "type": "string" + } } } } @@ -337,42 +1306,263 @@ }, "/api/tokens": { "get": { - "tags": ["Tokens"], + "tags": [ + "Tokens" + ], "summary": "Token usage by agent and model", - "description": "Returns token consumption data broken down by agent and model.", + "description": "Returns tokens.AggregateSummary (src/pkg/tokens/collector.go) as served by dashboard.Server.handleTokens (src/pkg/dashboard/api.go). Keys are snake_case and differ from the camelCase `tokens` block embedded in GET /api/status. NO COST FIELD is returned \u2014 this endpoint reports token counts only; cost must be estimated by the caller or read from a cost endpoint. The handler has two degenerate responses: {\"status\": \"no_collector\"} when no token collector is wired, and {\"total_tokens\": 0, \"sessions\": []} when the collector has no summary yet.", "responses": { "200": { - "description": "Token usage data", + "description": "Aggregate token usage, or one of the two degenerate shapes (no_collector / empty summary).", "content": { "application/json": { "schema": { "type": "object", - "description": "Token usage keyed by agent name, with model-level breakdowns" - } - } - } - } - } - } - }, - "/api/issue-costs": { - "get": { - "tags": ["Tokens"], - "summary": "Per-issue token costs", - "description": "Returns token cost data per GitHub issue, produced by the token-collector.", - "responses": { - "200": { - "description": "Array of issue cost entries", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "issue": { "type": "string" }, - "tokens": { "type": "integer" }, - "cost": { "type": "number" } + "description": "tokens.AggregateSummary, or one of the two degenerate handler responses described above.", + "properties": { + "total_tokens": { + "type": "integer", + "format": "int64" + }, + "total_input": { + "type": "integer", + "format": "int64" + }, + "total_output": { + "type": "integer", + "format": "int64" + }, + "total_cache_read": { + "type": "integer", + "format": "int64" + }, + "total_cache_create": { + "type": "integer", + "format": "int64" + }, + "total_messages": { + "type": "integer" + }, + "by_agent": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + }, + "description": "Agent name to total token count." + }, + "by_model": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + }, + "description": "Model id to total token count." + }, + "by_agent_detail": { + "type": "object", + "additionalProperties": { + "type": "object", + "description": "tokens.AgentModelBucket.", + "properties": { + "input": { + "type": "integer", + "format": "int64" + }, + "output": { + "type": "integer", + "format": "int64" + }, + "cache_read": { + "type": "integer", + "format": "int64" + }, + "cache_create": { + "type": "integer", + "format": "int64" + }, + "messages": { + "type": "integer" + }, + "sessions": { + "type": "integer" + } + }, + "required": [ + "input", + "output", + "cache_read", + "cache_create", + "messages", + "sessions" + ] + }, + "description": "Agent name to its full breakdown." + }, + "by_model_detail": { + "type": "object", + "additionalProperties": { + "type": "object", + "description": "tokens.AgentModelBucket.", + "properties": { + "input": { + "type": "integer", + "format": "int64" + }, + "output": { + "type": "integer", + "format": "int64" + }, + "cache_read": { + "type": "integer", + "format": "int64" + }, + "cache_create": { + "type": "integer", + "format": "int64" + }, + "messages": { + "type": "integer" + }, + "sessions": { + "type": "integer" + } + }, + "required": [ + "input", + "output", + "cache_read", + "cache_create", + "messages", + "sessions" + ] + }, + "description": "Model id to its full breakdown." + }, + "sessions": { + "type": "array", + "items": { + "type": "object", + "description": "tokens.SessionSummary.", + "properties": { + "session_id": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "string" + }, + "input_tokens": { + "type": "integer", + "format": "int64" + }, + "output_tokens": { + "type": "integer", + "format": "int64" + }, + "cache_read": { + "type": "integer", + "format": "int64" + }, + "cache_create": { + "type": "integer", + "format": "int64" + }, + "total_tokens": { + "type": "integer", + "format": "int64" + }, + "messages": { + "type": "integer" + }, + "first_active": { + "type": "integer", + "format": "int64", + "description": "Unix milliseconds of the earliest event seen; omitted when undeterminable." + }, + "last_active": { + "type": "integer", + "format": "int64", + "description": "Unix milliseconds of the latest event seen; omitted when undeterminable." + }, + "backend": { + "type": "string", + "description": "Tool that produced the session (\"claude\", \"copilot\", \"bob\", \"inference\"). Empty/absent means an older flat-format session of unknown provenance, which must be treated as NOT time-resolved." + }, + "usage": { + "type": "array", + "items": { + "type": "object", + "description": "tokens.UsageEvent \u2014 one timestamped slice of a session's usage timeline.", + "properties": { + "ts_ms": { + "type": "integer", + "format": "int64", + "description": "Unix milliseconds. 0 means the source line carried no parseable timestamp; consumers must treat 0 as unknown time rather than sorting it first." + }, + "model": { + "type": "string" + }, + "coalesced": { + "type": "integer", + "description": "How many raw per-message events this entry represents. Omitted/absent for an untouched event; >1 for a coalesced bucket." + }, + "input": { + "type": "integer", + "format": "int64" + }, + "output": { + "type": "integer", + "format": "int64" + }, + "cache_read": { + "type": "integer", + "format": "int64" + }, + "cache_create": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "ts_ms", + "input", + "output", + "cache_read", + "cache_create" + ] + }, + "description": "Per-message usage timeline, populated only by scanners that can observe the grain (currently claude). ADDITIVE: when present its sums equal the summed session fields exactly \u2014 use either, never add both. Absent on sessions restored from the persisted snapshot, which strips timelines." + }, + "usage_coalesced": { + "type": "integer", + "description": "How many raw events were folded to keep usage bounded. Absent/0 means full per-message fidelity." + } + }, + "required": [ + "session_id", + "agent", + "model", + "input_tokens", + "output_tokens", + "cache_read", + "cache_create", + "total_tokens", + "messages" + ] + } + }, + "session_count": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": [ + "no_collector" + ], + "description": "Present ONLY in the degenerate no-collector response, which is the bare object {\"status\": \"no_collector\"} with no other field set." } } } @@ -384,7 +1574,9 @@ }, "/api/model-advisor": { "get": { - "tags": ["Governor"], + "tags": [ + "Governor" + ], "summary": "Governor model assignments", "description": "Returns the current governor mode, budget state, and per-agent model assignments including cost weights and cadences.", "responses": { @@ -395,13 +1587,22 @@ "schema": { "type": "object", "properties": { - "mode": { "type": "string", "description": "Current governor mode" }, + "mode": { + "type": "string", + "description": "Current governor mode" + }, "budget": { "type": "object", "properties": { - "TOTAL": { "type": "number" }, - "USED": { "type": "number" }, - "REMAINING": { "type": "number" } + "TOTAL": { + "type": "number" + }, + "USED": { + "type": "number" + }, + "REMAINING": { + "type": "number" + } } }, "agents": { @@ -409,16 +1610,36 @@ "items": { "type": "object", "properties": { - "name": { "type": "string" }, - "backend": { "type": "string" }, - "model": { "type": "string" }, - "costWeight": { "type": "number" }, - "reason": { "type": "string" }, - "cadence": { "type": "string" }, - "paused": { "type": "boolean" }, - "changed": { "type": "boolean" }, - "prevBackend": { "type": "string" }, - "prevModel": { "type": "string" } + "name": { + "type": "string" + }, + "backend": { + "type": "string" + }, + "model": { + "type": "string" + }, + "costWeight": { + "type": "number" + }, + "reason": { + "type": "string" + }, + "cadence": { + "type": "string" + }, + "paused": { + "type": "boolean" + }, + "changed": { + "type": "boolean" + }, + "prevBackend": { + "type": "string" + }, + "prevModel": { + "type": "string" + } } } } @@ -432,7 +1653,9 @@ }, "/api/gh-auth": { "get": { - "tags": ["System"], + "tags": [ + "System" + ], "summary": "GitHub auth health", "description": "Returns whether GitHub authentication is working.", "responses": { @@ -443,8 +1666,14 @@ "schema": { "type": "object", "properties": { - "ok": { "type": "boolean" }, - "lastChecked": { "type": "string", "format": "date-time", "nullable": true } + "ok": { + "type": "boolean" + }, + "lastChecked": { + "type": "string", + "format": "date-time", + "nullable": true + } } } } @@ -455,7 +1684,9 @@ }, "/api/gh-rate-limits": { "get": { - "tags": ["System"], + "tags": [ + "System" + ], "summary": "GitHub rate limit alerts", "description": "Returns any active GitHub API rate limit warnings.", "responses": { @@ -471,9 +1702,15 @@ "items": { "type": "object", "properties": { - "agent": { "type": "string" }, - "message": { "type": "string" }, - "detectedAt": { "type": "integer" } + "agent": { + "type": "string" + }, + "message": { + "type": "string" + }, + "detectedAt": { + "type": "integer" + } } } } @@ -487,7 +1724,9 @@ }, "/api/summaries": { "get": { - "tags": ["Agents"], + "tags": [ + "Agents" + ], "summary": "Agent task summaries", "description": "Returns comprehensive exec summaries (task, progress, results) for all agents.", "responses": { @@ -507,7 +1746,9 @@ }, "/api/config/agent/{name}": { "get": { - "tags": ["Agents"], + "tags": [ + "Agents" + ], "summary": "Agent configuration", "description": "Returns the full configuration for a specific agent including cadences, models, pipeline settings, and restrictions.", "parameters": [ @@ -515,7 +1756,9 @@ "name": "name", "in": "path", "required": true, - "schema": { "type": "string" }, + "schema": { + "type": "string" + }, "description": "Agent name" } ], @@ -530,39 +1773,71 @@ "general": { "type": "object", "properties": { - "launchCmd": { "type": "string" }, - "displayName": { "type": "string" }, - "cliPinned": { "type": "boolean" }, - "cliPinValue": { "type": "string" }, - "staleTimeout": { "type": "integer" }, - "restartStrategy": { "type": "string" }, - "model": { "type": "string" } + "launchCmd": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "cliPinned": { + "type": "boolean" + }, + "cliPinValue": { + "type": "string" + }, + "staleTimeout": { + "type": "integer" + }, + "restartStrategy": { + "type": "string" + }, + "model": { + "type": "string" + } } }, "cadences": { "type": "object", "properties": { - "surge": { "type": "integer" }, - "busy": { "type": "integer" }, - "quiet": { "type": "integer" }, - "idle": { "type": "integer" } + "surge": { + "type": "integer" + }, + "busy": { + "type": "integer" + }, + "quiet": { + "type": "integer" + }, + "idle": { + "type": "integer" + } } }, - "models": { "type": "object" }, - "pipeline": { "type": "object" }, - "restrictions": { "type": "object" } + "models": { + "type": "object" + }, + "pipeline": { + "type": "object" + }, + "restrictions": { + "type": "object" + } } } } } }, - "404": { "description": "Unknown agent" } + "404": { + "description": "Unknown agent" + } } } }, "/api/config/agent/{name}/prompt": { "get": { - "tags": ["Agents"], + "tags": [ + "Agents" + ], "summary": "Agent prompt template", "description": "Returns the CLAUDE.md prompt file for the given agent.", "parameters": [ @@ -570,7 +1845,9 @@ "name": "name", "in": "path", "required": true, - "schema": { "type": "string" } + "schema": { + "type": "string" + } } ], "responses": { @@ -581,19 +1858,124 @@ "schema": { "type": "object", "properties": { - "content": { "type": "string" }, - "path": { "type": "string" } + "content": { + "type": "string" + }, + "path": { + "type": "string" + } + } + } + } + } + } + } + }, + "put": { + "tags": [ + "Agents" + ], + "summary": "Save or import an agent's kick prompt template", + "description": "Owner-only. With template set (and promptSource omitted), saves the inline text as the agent's kick template file. With promptSource set to {owner,repo,path,ref}, imports the prompt from an allowlisted GitHub repo: keepLinked=true persists prompt_source for live re-resolution at kick time; keepLinked=false fetches once, bakes the content into the template file, and clears any existing prompt_source.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "template": { + "type": "string", + "description": "Inline prompt text, used when promptSource is not set." + }, + "promptSource": { + "type": "object", + "nullable": true, + "properties": { + "owner": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "path": { + "type": "string" + }, + "ref": { + "type": "string" + } + } + }, + "keepLinked": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Template saved or imported", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "saved", + "imported" + ] + }, + "agent": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Present when status is 'saved'." + }, + "kickTemplate": { + "type": "string", + "description": "Present when status is 'imported'." + } } } } } + }, + "400": { + "description": "Invalid body, missing owner/repo/path, or prompt source not allowlisted/unreachable" + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Agent not found" + }, + "500": { + "description": "Failed to write the template file" } } } }, "/api/config/stat-sources": { "get": { - "tags": ["System"], + "tags": [ + "System" + ], "summary": "Available stat sources", "description": "Returns the list of configured stat data sources.", "responses": { @@ -603,7 +1985,9 @@ "application/json": { "schema": { "type": "array", - "items": { "type": "string" } + "items": { + "type": "string" + } } } } @@ -613,7 +1997,9 @@ }, "/api/config/governor": { "get": { - "tags": ["Governor"], + "tags": [ + "Governor" + ], "summary": "Full governor configuration", "description": "Returns the complete governor configuration including thresholds, labels, budget, notifications, health settings, sensing parameters, and monitored repos.", "responses": { @@ -624,50 +2010,104 @@ "schema": { "type": "object", "properties": { - "agents": { "type": "array", "items": { "type": "string" } }, + "agents": { + "type": "array", + "items": { + "type": "string" + } + }, "thresholds": { "type": "object", "properties": { - "surge": { "type": "integer" }, - "busy": { "type": "integer" }, - "quiet": { "type": "integer" } + "surge": { + "type": "integer" + }, + "busy": { + "type": "integer" + }, + "quiet": { + "type": "integer" + } } }, - "labels": { "type": "array", "items": { "type": "string" }, "description": "Exempt/hold labels" }, + "labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Exempt/hold labels" + }, "budget": { "type": "object", "properties": { - "totalTokens": { "type": "integer" }, - "periodDays": { "type": "integer" }, - "criticalPct": { "type": "integer" } + "totalTokens": { + "type": "integer" + }, + "periodDays": { + "type": "integer" + }, + "criticalPct": { + "type": "integer" + } } }, "notifications": { "type": "object", "properties": { - "ntfyServer": { "type": "string" }, - "ntfyTopic": { "type": "string" }, - "discordWebhook": { "type": "string" } + "ntfyServer": { + "type": "string" + }, + "ntfyTopic": { + "type": "string" + }, + "discordWebhook": { + "type": "string" + } } }, "health": { "type": "object", "properties": { - "healthcheckInterval": { "type": "integer" }, - "restartCooldown": { "type": "integer" }, - "modelLock": { "type": "boolean" } + "healthcheckInterval": { + "type": "integer" + }, + "restartCooldown": { + "type": "integer" + }, + "modelLock": { + "type": "boolean" + } } }, "sensing": { "type": "object", "properties": { - "ghRatePatterns": { "type": "array", "items": { "type": "string" } }, - "cliExcludePatterns": { "type": "array", "items": { "type": "string" } }, - "ttlSeconds": { "type": "integer" }, - "pullbackSeconds": { "type": "integer" } + "ghRatePatterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "cliExcludePatterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "ttlSeconds": { + "type": "integer" + }, + "pullbackSeconds": { + "type": "integer" + } } }, - "repos": { "type": "array", "items": { "type": "string" } } + "repos": { + "type": "array", + "items": { + "type": "string" + } + } } } } @@ -678,7 +2118,9 @@ }, "/api/config/sidebar": { "get": { - "tags": ["System"], + "tags": [ + "System" + ], "summary": "Sidebar layout configuration", "description": "Returns the sidebar navigation layout configuration.", "responses": { @@ -686,16 +2128,57 @@ "description": "Sidebar config", "content": { "application/json": { - "schema": { "type": "object" } + "schema": { + "type": "object" + } + } + } + } + } + }, + "put": { + "tags": [ + "Agents" + ], + "summary": "Update dashboard sidebar layout", + "description": "Persists the caller-supplied sidebar layout as-is (an arbitrary JSON value defined and interpreted entirely by the frontend) to /data/sidebar.json and in-memory state. The server does not validate or interpret its shape.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "description": "Arbitrary sidebar layout value; shape is frontend-defined and not validated server-side." + } + } + } + }, + "responses": { + "200": { + "description": "Sidebar layout saved", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } } } + }, + "400": { + "description": "Invalid JSON body" } } } }, "/api/config/backends": { "get": { - "tags": ["System"], + "tags": [ + "System" + ], "summary": "Available CLI backends and models", "description": "Returns the list of available CLI backends (claude, aider, codex, etc.) and their supported models.", "responses": { @@ -703,7 +2186,9 @@ "description": "Backends config", "content": { "application/json": { - "schema": { "type": "object" } + "schema": { + "type": "object" + } } } } @@ -712,7 +2197,9 @@ }, "/api/nous/status": { "get": { - "tags": ["Strategy Lab"], + "tags": [ + "Strategy Lab" + ], "summary": "Strategy Lab status", "description": "Returns the current Nous experiment engine state including mode, active experiment, pending proposals, snapshot progress, and recommendations.", "responses": { @@ -723,42 +2210,92 @@ "schema": { "type": "object", "properties": { - "mode": { "type": "string", "enum": ["observe", "suggest", "auto"] }, - "scope": { "type": "string", "enum": ["governor", "repo"] }, - "campaign": { "type": "object" }, + "mode": { + "type": "string", + "enum": [ + "observe", + "suggest", + "auto" + ] + }, + "scope": { + "type": "string", + "enum": [ + "governor", + "repo" + ] + }, + "campaign": { + "type": "object" + }, "activeExperiment": { "type": "object", "nullable": true, "properties": { - "id": { "type": "string" }, - "start": { "type": "integer" }, - "ttlSec": { "type": "integer" }, - "elapsed": { "type": "integer" }, - "progressPct": { "type": "integer" } - } - }, - "pending": { "type": "object", "nullable": true }, - "principleCount": { "type": "integer" }, - "snapshotCount": { "type": "integer" }, - "snapshotTarget": { "type": "integer" }, - "snapshotSummary": { "type": "object", "nullable": true }, - "hasRecommendations": { "type": "boolean" }, - "recommendations": { "type": "object", "nullable": true }, + "id": { + "type": "string" + }, + "start": { + "type": "integer" + }, + "ttlSec": { + "type": "integer" + }, + "elapsed": { + "type": "integer" + }, + "progressPct": { + "type": "integer" + } + } + }, + "pending": { + "type": "object", + "nullable": true + }, + "principleCount": { + "type": "integer" + }, + "snapshotCount": { + "type": "integer" + }, + "snapshotTarget": { + "type": "integer" + }, + "snapshotSummary": { + "type": "object", + "nullable": true + }, + "hasRecommendations": { + "type": "boolean" + }, + "recommendations": { + "type": "object", + "nullable": true + }, "phases": { "type": "object", "properties": { "governor": { "type": "object", "properties": { - "phase": { "type": "string" }, - "iteration": { "type": "integer" } + "phase": { + "type": "string" + }, + "iteration": { + "type": "integer" + } } }, "repo": { "type": "object", "properties": { - "phase": { "type": "string" }, - "iteration": { "type": "integer" } + "phase": { + "type": "string" + }, + "iteration": { + "type": "integer" + } } } } @@ -773,7 +2310,9 @@ }, "/api/nous/ledger": { "get": { - "tags": ["Strategy Lab"], + "tags": [ + "Strategy Lab" + ], "summary": "Experiment history", "description": "Returns the last 200 experiment ledger entries (JSONL).", "responses": { @@ -786,10 +2325,18 @@ "items": { "type": "object", "properties": { - "id": { "type": "string" }, - "ts": { "type": "string" }, - "action": { "type": "string" }, - "details": { "type": "object" } + "id": { + "type": "string" + }, + "ts": { + "type": "string" + }, + "action": { + "type": "string" + }, + "details": { + "type": "object" + } } } } @@ -801,7 +2348,9 @@ }, "/api/nous/principles": { "get": { - "tags": ["Strategy Lab"], + "tags": [ + "Strategy Lab" + ], "summary": "Accumulated principles", "description": "Returns the accumulated knowledge base of principles learned from experiments.", "responses": { @@ -814,10 +2363,18 @@ "items": { "type": "object", "properties": { - "id": { "type": "string" }, - "text": { "type": "string" }, - "source": { "type": "string" }, - "confidence": { "type": "number" } + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "source": { + "type": "string" + }, + "confidence": { + "type": "number" + } } } } @@ -829,7 +2386,9 @@ }, "/api/nous/phase": { "get": { - "tags": ["Strategy Lab"], + "tags": [ + "Strategy Lab" + ], "summary": "Experiment phase state", "description": "Returns the current phase and iteration for governor and repo experiments.", "responses": { @@ -843,15 +2402,23 @@ "governor": { "type": "object", "properties": { - "phase": { "type": "string" }, - "iteration": { "type": "integer" } + "phase": { + "type": "string" + }, + "iteration": { + "type": "integer" + } } }, "repo": { "type": "object", "properties": { - "phase": { "type": "string" }, - "iteration": { "type": "integer" } + "phase": { + "type": "string" + }, + "iteration": { + "type": "integer" + } } } } @@ -864,7 +2431,9 @@ }, "/api/nous/gate-pending": { "get": { - "tags": ["Strategy Lab"], + "tags": [ + "Strategy Lab" + ], "summary": "Pending gate decisions", "description": "Returns any pending experiment gate decisions waiting for approval.", "responses": { @@ -884,14 +2453,18 @@ }, "/api/nous/gate-response": { "get": { - "tags": ["Strategy Lab"], + "tags": [ + "Strategy Lab" + ], "summary": "Gate response", "description": "Returns the current gate response state for a given experiment.", "parameters": [ { "name": "experiment_id", "in": "query", - "schema": { "type": "string" } + "schema": { + "type": "string" + } } ], "responses": { @@ -899,7 +2472,9 @@ "description": "Gate response", "content": { "application/json": { - "schema": { "type": "object" } + "schema": { + "type": "object" + } } } } @@ -908,7 +2483,9 @@ }, "/api/contributors": { "get": { - "tags": ["Contributors"], + "tags": [ + "Contributors" + ], "summary": "List contributors", "description": "Returns all registered contributors with their trust tier and active status.", "responses": { @@ -924,10 +2501,25 @@ "items": { "type": "object", "properties": { - "contributor_id": { "type": "string" }, - "github_username": { "type": "string" }, - "trust_tier": { "type": "string", "enum": ["newcomer", "contributor", "trusted", "advisor", "revoked"] }, - "active": { "type": "boolean" } + "contributor_id": { + "type": "string" + }, + "github_username": { + "type": "string" + }, + "trust_tier": { + "type": "string", + "enum": [ + "newcomer", + "contributor", + "trusted", + "advisor", + "revoked" + ] + }, + "active": { + "type": "boolean" + } } } } @@ -941,7 +2533,9 @@ }, "/api/contributors/{id}": { "get": { - "tags": ["Contributors"], + "tags": [ + "Contributors" + ], "summary": "Contributor profile", "description": "Returns the full profile for a specific contributor including active status and current task.", "parameters": [ @@ -949,7 +2543,9 @@ "name": "id", "in": "path", "required": true, - "schema": { "type": "string" }, + "schema": { + "type": "string" + }, "description": "Contributor ID or GitHub username" } ], @@ -961,32 +2557,102 @@ "schema": { "type": "object", "properties": { - "contributor_id": { "type": "string" }, - "github_username": { "type": "string" }, - "trust_tier": { "type": "string" }, - "active": { "type": "boolean" }, - "currentTask": { "type": "object", "nullable": true }, - "lastTmuxOutput": { "type": "array", "items": { "type": "string" } } + "contributor_id": { + "type": "string" + }, + "github_username": { + "type": "string" + }, + "trust_tier": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "currentTask": { + "type": "object", + "nullable": true + }, + "lastTmuxOutput": { + "type": "array", + "items": { + "type": "string" + } + } } } } } }, - "404": { "description": "Contributor not found" } + "404": { + "description": "Contributor not found" + } } - } - }, - "/api/contribute/status": { - "get": { - "tags": ["Contributors"], - "summary": "Contributor hub status", - "description": "Returns the contributor hub status including registration state.", + }, + "delete": { + "tags": [ + "Contributors" + ], + "summary": "Delete a contributor", + "description": "Permanently removes a contributor's stored profile file. Requires the HIVE_DASHBOARD_TOKEN dashboard auth AND the owner role (X-Hive-Role: owner, verified) \u2014 enforced in-handler via requireOwnerRole; any other role gets 403.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Contributor ID or GitHub username" + } + ], "responses": { "200": { - "description": "Hub status", + "description": "Deleted", "content": { "application/json": { - "schema": { "type": "object" } + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "deleted": { + "type": "string", + "description": "The deleted contributor's GitHub username" + } + } + } + } + } + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Contributor not found" + }, + "500": { + "description": "Failed to delete the profile file" + } + } + } + }, + "/api/contribute/status": { + "get": { + "tags": [ + "Contributors" + ], + "summary": "Contributor hub status", + "description": "Returns the contributor hub status including registration state.", + "responses": { + "200": { + "description": "Hub status", + "content": { + "application/json": { + "schema": { + "type": "object" + } } } } @@ -995,7 +2661,9 @@ }, "/api/hives": { "get": { - "tags": ["Hives"], + "tags": [ + "Hives" + ], "summary": "List hive instances", "description": "Returns all registered hive instances including the local instance.", "responses": { @@ -1011,17 +2679,43 @@ "items": { "type": "object", "properties": { - "id": { "type": "string" }, - "project_name": { "type": "string" }, - "org": { "type": "string" }, - "primary_repo": { "type": "string" }, - "hub_url": { "type": "string" }, - "dashboard_url": { "type": "string" }, - "active_contributors": { "type": "integer" }, - "active_agents": { "type": "integer" }, - "actionable_items": { "type": "integer" }, - "registered_at": { "type": "string", "format": "date-time", "nullable": true }, - "last_heartbeat": { "type": "string", "format": "date-time", "nullable": true } + "id": { + "type": "string" + }, + "project_name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "primary_repo": { + "type": "string" + }, + "hub_url": { + "type": "string" + }, + "dashboard_url": { + "type": "string" + }, + "active_contributors": { + "type": "integer" + }, + "active_agents": { + "type": "integer" + }, + "actionable_items": { + "type": "integer" + }, + "registered_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "last_heartbeat": { + "type": "string", + "format": "date-time", + "nullable": true + } } } } @@ -1032,6 +2726,14807 @@ } } } + }, + "/api/agents": { + "get": { + "tags": [ + "Agents" + ], + "summary": "List agents", + "description": "Returns every configured agent (managed and pack-defined) with its core identity and current backend/model.", + "responses": { + "200": { + "description": "Agent list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "managed": { + "type": "boolean", + "description": "True for CRUD-created agents; false for base/pack-defined agents, which cannot be deleted." + }, + "backend": { + "type": "string" + }, + "model": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Agents" + ], + "summary": "Create an agent", + "description": "Owner-only. Creates a new managed agent from an inline agent config, writes its agent file, lifts any prior deletion tombstone for the same name, and starts its process. Rejects invalid names (must be alphanumeric/hyphen/underscore, max 64 chars), invalid explain_mode/caveman_mode values, and a name that already exists (409).", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "New agent's name; alphanumeric, hyphens, underscores only, max 64 chars." + }, + "agent": { + "type": "object", + "description": "Agent config fields (backend, model, display_name, mode, kick_template, etc.) \u2014 see the full AgentConfig shape returned by GET /api/config/agent/{name}." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Agent created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "created" + }, + "agent": { + "type": "string" + }, + "id": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid name, missing name, invalid explain_mode/caveman_mode, or downstream validation failure" + }, + "403": { + "description": "Owner role required" + }, + "409": { + "description": "Agent already exists" + } + } + } + }, + "/api/agents/import": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Import an agent definition", + "description": "Owner-only. Creates an agent from a portable AgentDefinition YAML document, either fetched from an allowlisted GitHub/Gist URL or pasted inline. Set preview=true to parse and return the definition without creating the agent. keepLinked (source=url only) persists a definition_source so the agent's operator-safe fields re-sync from the repo on reload; gist URLs are rejected when keepLinked is true. URL fetches are restricted to github.com, raw.githubusercontent.com, gist.github.com, and gist.githubusercontent.com, with SSRF guards against private/internal addresses.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "source" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "url", + "paste" + ] + }, + "url": { + "type": "string", + "description": "Required when source is 'url'. Max 2048 chars." + }, + "content": { + "type": "string", + "description": "Required when source is 'paste'. The raw AgentDefinition YAML." + }, + "preview": { + "type": "boolean", + "description": "When true, parse and return the definition without creating anything." + }, + "keepLinked": { + "type": "boolean", + "description": "source=url only. Persists a definition_source for live re-fetch on reload." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Preview result (preview=true) or import result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "imported" + }, + "name": { + "type": "string" + }, + "ok": { + "type": "boolean", + "description": "Present only on a preview response." + }, + "parsed": { + "type": "object", + "description": "Present only on a preview response: the parsed AgentDefinition." + } + } + } + } + } + }, + "400": { + "description": "Invalid body, disallowed/unreachable/private URL, invalid or duplicate agent name, or parse failure" + }, + "403": { + "description": "Owner role required" + }, + "409": { + "description": "Agent already exists" + }, + "502": { + "description": "URL fetch failed or returned a non-200 status" + } + } + } + }, + "/api/agents/{name}": { + "delete": { + "tags": [ + "Agents" + ], + "summary": "Delete an agent", + "description": "Owner-only. Deletes a managed (CRUD-created) agent's overlay file and removes it from the roster, then durably tombstones the name so a later ACMM pack apply does not silently recreate it. Base/pack-defined agents (managed=false) cannot be deleted (403). If the deleted name replicates another agent (ReplicaOf), the base agent is resolved and deleted instead.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "responses": { + "200": { + "description": "Agent deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "status": { + "type": "string", + "example": "deleted" + }, + "agent": { + "type": "string" + }, + "tombstoned": { + "type": "boolean" + }, + "note": { + "type": "string", + "description": "Present when the agent is not part of any ACMM pack." + }, + "packLevels": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Present when the agent is defined by one or more ACMM pack levels \u2014 those levels will not recreate it after this tombstone." + } + } + } + } + } + }, + "403": { + "description": "Owner role required, or agent is not a managed/CRUD-created agent" + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/agent-state/{agent}": { + "get": { + "tags": [ + "Agents" + ], + "summary": "Agent pause-state probe", + "description": "Lightweight, authoritative read of an agent's pause state, used by the dashboard immediately before a pause/resume toggle so the action derives from server state rather than a stale UI.", + "parameters": [ + { + "name": "agent", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "responses": { + "200": { + "description": "Agent state", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "agent": { + "type": "string" + }, + "paused": { + "type": "boolean" + }, + "state": { + "type": "string", + "enum": [ + "paused", + "running" + ] + }, + "procState": { + "type": "string", + "description": "Underlying process state (e.g. running, starting, crashed)." + } + } + } + } + } + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/agents/{name}/log": { + "get": { + "tags": [ + "Agents" + ], + "summary": "Full agent run log", + "description": "Read-only; any authenticated role. Returns the full retained tmux scrollback of the agent's current/latest run as plain text (tokens and device-auth codes redacted). ?download=1 sets Content-Disposition to save a .log file. ?explain=only returns just EXPLAIN-mode reasoning lines; ?explain=hide returns the log with those lines removed; any other value returns the log unfiltered.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + }, + { + "name": "download", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Any non-empty value triggers an attachment download." + }, + { + "name": "explain", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "only", + "hide" + ] + }, + "description": "Filter to (only) or exclude (hide) EXPLAIN-mode lines." + } + ], + "responses": { + "200": { + "description": "Plain-text log content", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Agent not found or capture failed" + } + } + } + }, + "/api/agents/{name}/terminal-urls": { + "get": { + "tags": [ + "Agents" + ], + "summary": "URLs visible in the agent's terminal", + "description": "Read-only; any authenticated role. Returns the distinct http(s) URLs present in the agent's retained tmux scrollback, most recent first, captured with `capture-pane -J` so terminal-wrapped URLs are rejoined and copy whole. Backs the dashboard's click-to-copy control (#5188), which exists because the embedded ttyd terminal cannot complete a clipboard write. `authUrls` narrows the list to sign-in links, which is all the dashboard control offers (#5327). The capture is redacted before extraction and any URL still carrying a redaction marker is dropped, so device-flow login URLs (whose line carries the one-time code) do not appear. An agent with no session, or with no URL on screen, returns an empty list rather than an error.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "responses": { + "200": { + "description": "Distinct URLs from the agent's pane, newest first (possibly empty)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "urls": { + "type": "array", + "description": "All distinct URLs on the pane, newest first.", + "items": { + "type": "string" + } + }, + "authUrls": { + "type": "array", + "description": "The subset of `urls` that looks like a sign-in link. This is the only list the dashboard offers to copy (#5327): a control labelled \"Copy login URL\" must hand back a login URL or nothing, never whatever else the agent happened to print.", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "503": { + "description": "Agent manager unavailable" + } + } + } + }, + "/api/agents/{name}/kicks": { + "get": { + "tags": [ + "Agents" + ], + "summary": "List archived kick logs", + "description": "Read-only; any authenticated role. Returns the agent's archived per-kick run logs, newest first. An agent with no history returns an empty array, never an error.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "responses": { + "200": { + "description": "Archived kick log list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Archive filename; the handle used by GET .../kicks/{id}." + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "size_bytes": { + "type": "integer" + }, + "trigger": { + "type": "string", + "description": "What triggered the archive: kick, restart, or shutdown." + } + } + } + } + } + } + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/agents/{name}/kicks/{id}": { + "get": { + "tags": [ + "Agents" + ], + "summary": "Read one archived kick log", + "description": "Read-only; any authenticated role. Returns one archived kick log as plain text (tokens and device-auth codes redacted). ?download=1 sets Content-Disposition to save a .log file.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Kick log archive id, from GET .../kicks" + }, + { + "name": "download", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Any non-empty value triggers an attachment download." + } + ], + "responses": { + "200": { + "description": "Plain-text archived log content", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Agent or archive not found" + } + } + } + }, + "/api/kick/{agent}": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Kick (message) an agent", + "description": "Queues a message/prompt for delivery to the agent's running session and returns immediately. If no prompt/message is given, an auto-generated message is built from the agent's last actionable item.\n\nASYNCHRONOUS (kubestellar/hive#5325). Delivery waits for the CLI's input prompt, which can take up to 120s and therefore used to outlive a typical 60s ingress idle timeout, making a proxy answer 504 for a kick that actually succeeded. Fast, deterministic preconditions (unknown agent, paused, stopped, missing tmux session) are still evaluated inline and still return 400. Everything slower runs in the background; poll GET /api/kick/{agent}/status for the delivery outcome.\n\nDelivery is exactly-once per agent: a second POST while a delivery is in flight is deduplicated and answers status \"in-flight\" without typing the prompt again.", + "parameters": [ + { + "name": "agent", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "Preferred field; max 10000 chars." + }, + "message": { + "type": "string", + "description": "Used if prompt is empty." + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Kick queued for delivery. Not a delivery confirmation.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "queued", + "in-flight" + ], + "description": "queued: this call started a delivery. in-flight: a delivery for this agent was already running and this call was deduplicated." + }, + "agent": { + "type": "string" + }, + "message": { + "type": "string", + "description": "Present on the in-flight path, explaining the deduplication." + } + } + } + } + } + }, + "400": { + "description": "Prompt too long, or the kick failed its preconditions (unknown agent, paused, stopped, no tmux session)" + } + } + } + }, + "/api/model/{agent}/{model}": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Set an agent's model", + "description": "Sets and persists the agent's model override, claims operator ownership of the field (so a pack re-apply on restart cannot revert it), and restarts the agent's session so the change takes effect immediately. Rejects a model the agent's effective backend does not serve.", + "parameters": [ + { + "name": "agent", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + }, + { + "name": "model", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Model id" + } + ], + "responses": { + "200": { + "description": "Model set", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "model_set" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Model not available for the agent's backend, or SetModelOverride failed" + } + } + } + }, + "/api/pause/{agent}": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Pause an agent", + "description": "Owner-only. Pauses the agent, recording the acting user. A no-op if the agent is already paused: returns changed=false with the current state rather than clobbering the original pause reason/trigger.", + "parameters": [ + { + "name": "agent", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "responses": { + "200": { + "description": "Pause result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "status": { + "type": "string", + "example": "paused" + }, + "agent": { + "type": "string" + }, + "changed": { + "type": "boolean", + "description": "False when the agent was already paused." + }, + "state": { + "type": "string", + "enum": [ + "paused", + "running" + ] + } + } + } + } + } + }, + "400": { + "description": "Pause failed (e.g. unknown agent)" + }, + "403": { + "description": "Owner role required" + } + } + } + }, + "/api/resume/{agent}": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Resume a paused agent", + "description": "Owner-only. Resumes the agent. A no-op if the agent is not currently paused: returns changed=false with the current state.", + "parameters": [ + { + "name": "agent", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "responses": { + "200": { + "description": "Resume result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "status": { + "type": "string", + "example": "resumed" + }, + "agent": { + "type": "string" + }, + "changed": { + "type": "boolean", + "description": "False when the agent was not paused." + }, + "state": { + "type": "string", + "enum": [ + "paused", + "running" + ] + } + } + } + } + } + }, + "400": { + "description": "Resume failed (e.g. unknown agent)" + }, + "403": { + "description": "Owner role required" + } + } + } + }, + "/api/restart/{agent}": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Restart an agent", + "description": "Restarts the agent's session. Restart operations are serialized per-server to prevent concurrent pause/resume/restart cycles from interfering through shared tmux/config state.", + "parameters": [ + { + "name": "agent", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "responses": { + "200": { + "description": "Agent restarted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "restarted" + }, + "agent": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Restart failed (e.g. unknown agent)" + } + } + } + }, + "/api/reset-restarts/{agent}": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Reset an agent's restart counter", + "description": "Zeroes the agent's tracked restart count. Returns minStatusSeq so the dashboard can drop any in-flight status snapshot built before the reset, preventing the counter from flickering back to its stale value.", + "parameters": [ + { + "name": "agent", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "responses": { + "200": { + "description": "Restart count reset", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "status": { + "type": "string", + "example": "reset" + }, + "agent": { + "type": "string" + }, + "minStatusSeq": { + "type": "integer", + "description": "Status snapshots with a lower sequence number predate this reset and should be discarded by the client." + } + } + } + } + } + }, + "400": { + "description": "ResetRestartCount failed (e.g. unknown agent)" + } + } + } + }, + "/api/switch/{agent}/{backend}": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Switch an agent's backend", + "description": "Sets and persists the agent's backend override, claims operator ownership of the field, and restarts the agent's session so the new backend takes effect immediately.", + "parameters": [ + { + "name": "agent", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + }, + { + "name": "backend", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Backend/CLI identifier (e.g. copilot, claude)" + } + ], + "responses": { + "200": { + "description": "Backend switched", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "switched" + }, + "agent": { + "type": "string" + }, + "backend": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "SetBackendOverride failed (e.g. unsupported backend)" + } + } + } + }, + "/api/pin/{agent}/{dimension}": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Pin an agent's model or CLI backend", + "description": "Pins the given dimension ('cli' or 'model') to a value, claiming operator ownership so the pin survives the pack re-apply that runs on every restart. If the request body omits value, the agent's current effective value for that dimension is used.", + "parameters": [ + { + "name": "agent", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + }, + { + "name": "dimension", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "cli", + "model" + ] + }, + "description": "Dimension to pin" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "Value to pin; if omitted, the agent's current value for the dimension is used." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Dimension pinned", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "pinned" + }, + "agent": { + "type": "string" + }, + "dimension": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid dimension, agent not found, or Pin call failed" + } + } + } + }, + "/api/unpin/{agent}/{dimension}": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Unpin an agent's model or CLI backend", + "description": "Removes an existing pin on the given dimension ('cli' or 'model').", + "parameters": [ + { + "name": "agent", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + }, + { + "name": "dimension", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "cli", + "model" + ] + }, + "description": "Dimension to unpin" + } + ], + "responses": { + "200": { + "description": "Dimension unpinned", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "unpinned" + }, + "agent": { + "type": "string" + }, + "dimension": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid dimension or Unpin call failed" + } + } + } + }, + "/api/breaker": { + "get": { + "tags": [ + "Agents" + ], + "summary": "Fleet breaker state", + "description": "Read-only; any authenticated role. Returns whether the fleet breaker is engaged and which agents it is currently holding paused.", + "responses": { + "200": { + "description": "Breaker state", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "engaged": { + "type": "boolean" + }, + "agents": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "503": { + "description": "Agent manager unavailable" + } + } + } + }, + "/api/breaker/engage": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Engage the fleet breaker", + "description": "Owner-only. Pauses every running, non-on-demand agent fleet-wide and records exactly that set so release only resumes agents the breaker itself paused. Already-paused and on-demand agents are left untouched. Persisted so an engaged breaker survives a crash.", + "responses": { + "200": { + "description": "Breaker engaged", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "engaged": { + "type": "boolean", + "example": true + }, + "agents": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Agents the breaker paused." + } + } + } + } + } + }, + "403": { + "description": "Owner role required" + }, + "503": { + "description": "Agent manager unavailable" + } + } + } + }, + "/api/breaker/release": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Release the fleet breaker", + "description": "Owner-only. Resumes only the agents the breaker paused and still owns (unchanged PausedTrigger). On-demand, pre-existing, and operator-re-paused agents are never resumed.", + "responses": { + "200": { + "description": "Breaker released", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "engaged": { + "type": "boolean", + "example": false + }, + "agents": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Agents resumed by the release." + } + } + } + } + } + }, + "403": { + "description": "Owner role required" + }, + "503": { + "description": "Agent manager unavailable" + } + } + } + }, + "/api/beads": { + "get": { + "tags": [ + "Agents" + ], + "summary": "List all beads across agents", + "description": "Returns every agent's bead list, keyed by agent name. Same handler as GET /api/beads/{agent} with the path parameter omitted.", + "responses": { + "200": { + "description": "Beads by agent", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Map of agent name to that agent's bead list (shape defined by the beads package's List(); left untyped here as a documentation gap).", + "additionalProperties": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + }, + "503": { + "description": "Bead stores not initialized" + } + } + } + }, + "/api/beads/{agent}": { + "get": { + "tags": [ + "Agents" + ], + "summary": "List one agent's beads", + "description": "Returns the given agent's bead list.", + "parameters": [ + { + "name": "agent", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "responses": { + "200": { + "description": "Beads for the agent, keyed by agent name", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Single-key map of agent name to that agent's bead list (shape defined by the beads package's List(); left untyped here as a documentation gap).", + "additionalProperties": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + }, + "404": { + "description": "No bead store for this agent" + }, + "503": { + "description": "Bead stores not initialized" + } + } + }, + "post": { + "tags": [ + "Agents" + ], + "summary": "Create a bead for an agent", + "description": "Owner-only. Creates an advisory (or typed) bead in the given agent's bead store.", + "parameters": [ + { + "name": "agent", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "description": "Required; max 500 chars." + }, + "type": { + "type": "string", + "description": "Defaults to 'advisory' if omitted." + }, + "priority": { + "type": "integer" + }, + "external_ref": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Bead created", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Invalid body, missing/too-long title, or other validation failure" + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "No bead store for this agent" + }, + "503": { + "description": "Bead stores not initialized" + } + } + } + }, + "/api/beads/reset": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Reset beads for all agents", + "description": "Owner-only. Closes all beads across every agent's bead store, recording the given (or default) reason.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": "Defaults to 'manual reset via API' if omitted." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Beads reset", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "reset" + }, + "closed": { + "type": "object", + "additionalProperties": { + "type": "integer" + }, + "description": "Count of beads closed per agent." + }, + "reason": { + "type": "string" + } + } + } + } + } + }, + "403": { + "description": "Owner role required" + }, + "503": { + "description": "Bead stores not initialized" + } + } + } + }, + "/api/beads/reset/{agent}": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Reset beads for one agent", + "description": "Owner-only. Closes all beads in the given agent's bead store, recording the given (or default) reason.", + "parameters": [ + { + "name": "agent", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": "Defaults to 'manual reset via API' if omitted." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Agent beads reset", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "reset" + }, + "agent": { + "type": "string" + }, + "closed": { + "type": "integer" + }, + "reason": { + "type": "string" + } + } + } + } + } + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "No bead store for this agent" + }, + "500": { + "description": "CloseAll failed" + }, + "503": { + "description": "Bead stores not initialized" + } + } + } + }, + "/api/config/agent/{name}/general": { + "put": { + "tags": [ + "Agents" + ], + "summary": "Update an agent's general config", + "description": "Owner-only. Merges any of the listed fields present in the body into the agent's config (fields absent from the body are left untouched). Setting model or cliPinValue claims operator ownership of that field and \u2014 after saving \u2014 applies it live and restarts the agent. promptSource/definitionSource are nested {owner,repo,path,ref[,url]} objects; passing null clears the field, and setting promptSource re-bakes the kick template. Values are validated (mode enum, explain_mode, caveman_mode, backend availability) before being persisted.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "All fields optional; only keys present are applied.", + "properties": { + "enabled": { + "type": "boolean" + }, + "clearOnKick": { + "type": "boolean" + }, + "displayName": { + "type": "string" + }, + "description": { + "type": "string" + }, + "launchCmd": { + "type": "string" + }, + "staleTimeout": { + "type": "integer" + }, + "replicas": { + "type": "integer" + }, + "restartStrategy": { + "type": "string" + }, + "cliPinned": { + "type": "boolean" + }, + "model": { + "type": "string", + "description": "Claims operator ownership and is applied live." + }, + "cliPinValue": { + "type": "string", + "description": "Backend id; claims operator ownership and is applied live." + }, + "emoji": { + "type": "string" + }, + "color": { + "type": "string" + }, + "sortOrder": { + "type": "integer" + }, + "beadRole": { + "type": "string" + }, + "role": { + "type": "string" + }, + "kickTemplate": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "ADVISORY", + "ISSUES_ONLY", + "ISSUES_AND_PRS", + "ISSUES_PRS_MERGE", + "NO_GITHUB" + ] + }, + "includeRepos": { + "type": "boolean" + }, + "laneKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "detectKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "sandboxEnabled": { + "type": "boolean" + }, + "cavemanMode": { + "type": "string", + "enum": [ + "lite", + "full", + "ultra", + "wenyan", + "" + ] + }, + "explainMode": { + "type": "string", + "enum": [ + "off", + "brief", + "full", + "" + ] + }, + "promptSource": { + "type": "object", + "nullable": true, + "properties": { + "owner": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "path": { + "type": "string" + }, + "ref": { + "type": "string" + } + } + }, + "definitionSource": { + "type": "object", + "nullable": true, + "properties": { + "owner": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "path": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Config updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "agent": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body, invalid mode/explain_mode/caveman_mode/backend, prompt source rejected, or definition source repo not allowlisted" + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/config/agent/{name}/cadences": { + "put": { + "tags": [ + "Agents" + ], + "summary": "Update an agent's per-mode governor cadences", + "description": "Owner-only. Body is a map of governor mode name to a cadence value, which may be a number of seconds (<=0 means 'pause'), an interval string, or a schedule object. Unknown mode names are silently skipped; invalid cadence values return 400.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Map of governor mode name to a cadence (seconds as a number, an interval string, or a schedule object).", + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "description": "Cadences updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "agent": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body or invalid cadence value for a mode" + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/config/agent/{name}/models": { + "put": { + "tags": [ + "Agents" + ], + "summary": "Update an agent's backend/model", + "description": "Owner-only. Sets backend and/or model (either may be omitted/empty to leave unchanged), claiming operator ownership of whichever field is set so a pack re-apply cannot revert it. Does not restart the agent itself; syncs the change into the live process config.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "backend": { + "type": "string" + }, + "model": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Backend/model updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "agent": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body or unsupported backend" + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/config/agent/{name}/pipeline": { + "put": { + "tags": [ + "Agents" + ], + "summary": "Update an agent's pipeline toggles", + "description": "Owner-only. Stores a map of pipeline-step name to enabled/disabled boolean in-memory (not persisted to hive.yaml); read by the dashboard's pipeline view.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Map of pipeline step name to boolean enabled/disabled.", + "additionalProperties": { + "type": "boolean" + } + } + } + } + }, + "responses": { + "200": { + "description": "Pipeline updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "agent": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body" + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/config/agent/{name}/hooks": { + "put": { + "tags": [ + "Agents" + ], + "summary": "Update an agent's hooks config", + "description": "Owner-only. Stores a map of hook-category name to an arbitrary list of hook entries in-memory (not persisted to hive.yaml).", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Map of hook category name to an array of hook entries (entry shape is caller-defined/opaque).", + "additionalProperties": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Hooks updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "agent": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body" + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/config/agent/{name}/restrictions": { + "put": { + "tags": [ + "Agents" + ], + "summary": "Update an agent's command restrictions", + "description": "Owner-only. Replaces the agent's restrictions.conf file (under /data/agents/{name}/) with the given pattern/reason pairs, one per line.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "agent": { + "type": "array", + "items": { + "type": "object", + "properties": { + "pattern": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "source": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Restrictions updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "agent": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body" + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/config/agent/{name}/stats": { + "put": { + "tags": [ + "Agents" + ], + "summary": "Update an agent's displayed stats config", + "description": "Owner-only. Writes the given stats array to a stats.json file under /data/agents/{name}/ (shape of each stats entry is caller-defined/opaque).", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stats": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Stats config updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "agent": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body" + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/config/agent/{name}/export": { + "get": { + "tags": [ + "Agents" + ], + "summary": "Export an agent as a portable definition", + "description": "Builds a portable AgentDefinition YAML document for the agent (including its raw kick template and governor cadences). Responds as JSON {name, yaml} by default; if the Accept header contains text/yaml or application/yaml, responds with the raw YAML as a downloadable attachment instead.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + }, + { + "name": "Accept", + "in": "header", + "required": false, + "schema": { + "type": "string" + }, + "description": "Set to text/yaml or application/yaml to receive the raw YAML file instead of JSON." + } + ], + "responses": { + "200": { + "description": "Exported definition", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "yaml": { + "type": "string" + } + } + } + }, + "text/yaml": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/config/agent/{name}/channels": { + "put": { + "tags": [ + "Agents" + ], + "summary": "Update an agent's notification channels", + "description": "Owner-only. Replaces the agent's channels list (shape defined by config.ChannelConfig).", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "object" + }, + "description": "config.ChannelConfig entries; left untyped here as a documentation gap." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Channels updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "agent": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body" + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/config/agent/{name}/tools": { + "put": { + "tags": [ + "Agents" + ], + "summary": "Update an agent's tools config", + "description": "Owner-only. Replaces the agent's tools configuration (shape defined by config.ToolsConfig).", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "config.ToolsConfig; left untyped here as a documentation gap." + } + } + } + }, + "responses": { + "200": { + "description": "Tools config updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "agent": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body" + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/config/agent/{name}/connections": { + "put": { + "tags": [ + "Agents" + ], + "summary": "Update an agent's connections config", + "description": "Owner-only. Replaces the agent's connections list (shape defined by config.ConnectionConfig; auth secrets are masked when the list is read back).", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connections": { + "type": "array", + "items": { + "type": "object" + }, + "description": "config.ConnectionConfig entries; left untyped here as a documentation gap." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Connections updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "agent": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body" + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/packs": { + "get": { + "tags": [ + "Agents" + ], + "summary": "List ACMM packs", + "description": "Returns every defined ACMM maturity-level pack with its agent roster and governor settings, flagging which level is currently active.", + "responses": { + "200": { + "description": "Pack list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agentCount": { + "type": "integer" + }, + "governor": { + "type": "object", + "properties": { + "modes": { + "type": "string" + }, + "mergePolicy": { + "type": "string" + }, + "evalIntervalS": { + "type": "integer" + }, + "cadences": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "thresholds": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "planAutoApprove": { + "type": "boolean" + } + } + }, + "current": { + "type": "boolean" + }, + "agents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "role": { + "type": "string" + }, + "description": { + "type": "string" + }, + "emoji": { + "type": "string" + }, + "color": { + "type": "string" + }, + "sortOrder": { + "type": "integer" + }, + "backend": { + "type": "string" + }, + "model": { + "type": "string" + }, + "beadRole": { + "type": "string" + }, + "kickTemplate": { + "type": "string" + }, + "includeRepos": { + "type": "boolean" + }, + "laneKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "interactions": { + "type": "string" + }, + "knowledgeUse": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "staleTimeout": { + "type": "integer" + }, + "mode": { + "type": "string" + }, + "onDemand": { + "type": "boolean" + }, + "cavemanMode": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/api/packs/{level}/apply": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Apply an ACMM pack at a level", + "description": "Owner-only. Force-applies the named ACMM level's pack: creates/updates/pauses/resumes agents to match the pack roster, and forces the governor's cadences to the pack's cadences even if a level switch adds no new agent.", + "parameters": [ + { + "name": "level", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "ACMM pack level" + } + ], + "responses": { + "200": { + "description": "Pack applied", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "status": { + "type": "string", + "example": "applied" + }, + "level": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "created": { + "type": "array", + "items": { + "type": "string" + } + }, + "updated": { + "type": "array", + "items": { + "type": "string" + } + }, + "skipped": { + "type": "array", + "items": { + "type": "string" + } + }, + "paused": { + "type": "array", + "items": { + "type": "string" + } + }, + "resumed": { + "type": "array", + "items": { + "type": "string" + } + }, + "governor_changes": { + "type": "object", + "description": "Before/after governor eval interval and cadence changes, when any occurred." + }, + "tombstoned": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Pack agents the operator previously deleted and were therefore NOT re-created." + } + } + } + } + } + }, + "400": { + "description": "Invalid level" + }, + "403": { + "description": "Owner role required" + }, + "500": { + "description": "Pack apply failed" + } + } + } + }, + "/api/packs/level": { + "put": { + "tags": [ + "Agents" + ], + "summary": "Set the active ACMM level", + "description": "Owner-only. Sets the hive's ACMM maturity level (1-6), clears per-agent mode overrides so pack modes re-apply cleanly, force-applies that level's pack to reconcile the agent roster and governor cadences/thresholds, and pauses/resumes agents to match the new level's roster. If pack reconciliation fails after the level is already persisted, returns 500 so the operator sees the drift.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "level" + ], + "properties": { + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Level set", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "level": { + "type": "integer" + }, + "packAgents": { + "type": "array", + "items": { + "type": "string" + } + }, + "packUpdated": { + "type": "array", + "items": { + "type": "string" + } + }, + "governor_changes": { + "type": "object" + }, + "paused": { + "type": "array", + "items": { + "type": "string" + } + }, + "resumed": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid body or level out of range 1-6" + }, + "403": { + "description": "Owner role required" + }, + "500": { + "description": "Roster reconciliation failed after the level was already persisted" + } + } + } + }, + "/api/config/governor/budget": { + "put": { + "tags": [ + "Governor" + ], + "summary": "Update token budget settings", + "description": "Updates the governor token budget (total tokens per period, period length, critical-percent alert threshold). Pointer-typed fields: an absent field keeps its stored value; validation runs against the effective post-update values. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "totalTokens": { + "type": "integer", + "description": "0 disables budget tracking" + }, + "periodDays": { + "type": "integer" + }, + "criticalPct": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Validation error on the effective budget values" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/features": { + "put": { + "tags": [ + "Governor" + ], + "summary": "Update opt-in feature/observability settings", + "description": "Updates ioscan, OTel/tracing exporter, retro analysis, mint issuer/enabled, and plan-from-label settings. Every field is pointer-typed; absent fields are unchanged. The mint signing key path is never accepted or returned here. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ioscanEnabled": { + "type": "boolean" + }, + "tracingEnabled": { + "type": "boolean" + }, + "tracingEndpoint": { + "type": "string" + }, + "tracingSampleRatio": { + "type": "number", + "description": "0.0-1.0" + }, + "otelEnabled": { + "type": "boolean" + }, + "otelEndpoint": { + "type": "string" + }, + "otelServiceName": { + "type": "string" + }, + "otelInsecure": { + "type": "boolean" + }, + "otelSampleRatio": { + "type": "number", + "description": "0.0-1.0" + }, + "otelHeaders": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "retroEnabled": { + "type": "boolean" + }, + "retroAnalysisModel": { + "type": "string" + }, + "mintEnabled": { + "type": "boolean" + }, + "mintIssuer": { + "type": "string" + }, + "planFromLabel": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid OTel endpoint or out-of-range sample ratio" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/logging": { + "put": { + "tags": [ + "Governor" + ], + "summary": "Update logging settings", + "description": "Updates log rotation (max size, max age, max backups, compression) and log level. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "maxSizeMB": { + "type": "integer" + }, + "maxAgeDays": { + "type": "integer" + }, + "maxBackups": { + "type": "integer" + }, + "compress": { + "type": "boolean" + }, + "level": { + "type": "string", + "enum": [ + "debug", + "info", + "warn", + "error" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid level or out-of-range size/age" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/notifications": { + "put": { + "tags": [ + "Governor" + ], + "summary": "Update notification settings", + "description": "Updates ntfy server/topic and the Discord webhook. A value beginning with the masked-secret bullet character is treated as \"unchanged\" (the UI echoes masked values back), not a real update. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ntfyServer": { + "type": "string" + }, + "ntfyTopic": { + "type": "string" + }, + "discordWebhook": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid ntfyServer or discordWebhook URL" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/thresholds": { + "put": { + "tags": [ + "Governor" + ], + "summary": "Update mode thresholds", + "description": "Sets the per-mode kick thresholds (e.g. quiet/busy/surge) as a flat map of mode name to integer threshold. Editing any threshold clears threshold-scaling's base-value source for ALL modes, so a hand-tuned value is never subsequently multiplied by repo count. Triggers immediate governor re-evaluation. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer" + }, + "description": "Map of mode name (e.g. \"quiet\", \"busy\", \"surge\") to threshold value" + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid threshold value(s)" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/trajectory": { + "put": { + "tags": [ + "Governor" + ], + "summary": "Update trajectory-review lane settings", + "description": "Updates the trajectory-drift review lane: enabled, eval interval, reviewer model/endpoint, transcript line count, and on-divergence action (pause/alert). Takes effect on the next hive restart. Clears the legacy \"not configured\" system alert. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "intervalS": { + "type": "integer" + }, + "model": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "transcriptLines": { + "type": "integer" + }, + "onDivergence": { + "type": "string", + "enum": [ + "pause", + "alert" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/watchdog": { + "put": { + "tags": [ + "Governor" + ], + "summary": "Update agent self-healing watchdog settings", + "description": "Updates the watchdog mode (off/observe/heal), probe interval, crash-loop restart threshold, healthy-reset window, and auth-probe toggle. Setting mode clears the legacy Enabled flag so mode becomes the single source of truth. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "off", + "observe", + "heal" + ] + }, + "probeIntervalS": { + "type": "integer", + "description": "30-86400" + }, + "crashLoopAfter": { + "type": "integer", + "description": "1-50" + }, + "healthyReset": { + "type": "string", + "description": "Go duration string, e.g. \"30m\"; 1m-168h" + }, + "authProbe": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid mode, or out-of-range interval/crash-loop/healthy-reset" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/advisory": { + "get": { + "tags": [ + "Governor" + ], + "summary": "Advisory digest settings", + "description": "Returns the advisory digest configuration (max findings, staleness window, autoclose, target). Requires owner role.", + "responses": { + "200": { + "description": "Advisory config", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "max_findings": { + "type": "integer" + }, + "show_all": { + "type": "boolean" + }, + "staleness_days": { + "type": "integer" + }, + "pr_autoclose": { + "type": "boolean" + }, + "update_interval_s": { + "type": "integer" + }, + "target": { + "type": "string", + "enum": [ + "github", + "linear" + ] + }, + "linear_issue": { + "type": "string" + } + } + } + } + } + }, + "403": { + "description": "Requires owner role" + } + } + }, + "put": { + "tags": [ + "Governor" + ], + "summary": "Update advisory digest settings", + "description": "Updates the advisory digest configuration. Every field is optional/pointer-typed; an absent field leaves the stored value unchanged. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "max_findings": { + "type": "integer", + "description": "Minimum 1" + }, + "show_all": { + "type": "boolean" + }, + "staleness_days": { + "type": "integer", + "description": "Minimum 1" + }, + "pr_autoclose": { + "type": "boolean" + }, + "update_interval_s": { + "type": "integer", + "description": "0 = default cadence, otherwise bounded" + }, + "target": { + "type": "string", + "enum": [ + "github", + "linear" + ] + }, + "linear_issue": { + "type": "string", + "description": "Required when target is linear" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated advisory config (same shape as GET)", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Validation error (out-of-range value, missing linear_issue for linear target)" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/agents/{name}": { + "delete": { + "tags": [ + "Governor" + ], + "summary": "Remove an agent", + "description": "Deletes an agent from the roster and records a deletion tombstone so an ACMM pack apply or config reload will not re-create it. Removes any per-agent overlay file. Requires owner role.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "responses": { + "200": { + "description": "Agent removed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "tombstoned": { + "type": "boolean" + }, + "packLevels": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "ACMM pack levels that define this agent, if any" + }, + "note": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Agent not found" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/agents": { + "post": { + "tags": [ + "Governor" + ], + "summary": "Add an agent", + "description": "Creates a new agent with the given backend and model, enabled by default. Lifts any deletion tombstone for the same name. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Alphanumeric, hyphens and underscores only; max 64 chars" + }, + "backend": { + "type": "string", + "description": "Defaults to \"claude\" when omitted" + }, + "model": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Agent added", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "agent": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid name or unsupported backend" + }, + "409": { + "description": "Agent already exists" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/attribution": { + "put": { + "tags": [ + "Governor" + ], + "summary": "Update the attribution-trailer toggle", + "description": "Sets the hive-wide toggle for appending a visible \"\u2014 hive: \u2026\" trailer to hive-created PRs and issues. Does not affect the audit log, which always records the invocation. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "attributionTrailer": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/backup": { + "delete": { + "tags": [ + "Governor" + ], + "summary": "Clear the backup encryption key", + "description": "Removes the self-service backup encryption key file(s) from the PVC and clears governor.backup.key_file/key_name. An env-provided HIVE_BACKUP_KEY, if configured, remains as fallback. Requires owner role.", + "responses": { + "200": { + "description": "Cleared", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "configured": { + "type": "boolean", + "description": "True if an env-fallback key still resolves" + }, + "source": { + "type": "string" + } + } + } + } + } + }, + "403": { + "description": "Requires owner role" + } + } + }, + "get": { + "tags": [ + "Governor" + ], + "summary": "Backup encryption key status", + "description": "Reports whether a backup encryption key is configured and usable, and its safe source label \u2014 never the key value itself. Requires owner role.", + "responses": { + "200": { + "description": "Backup key status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "configured": { + "type": "boolean" + }, + "usable": { + "type": "boolean", + "description": "False if the configured key is truncated/non-hex" + }, + "source": { + "type": "string" + }, + "keyName": { + "type": "string" + }, + "algorithm": { + "type": "string" + }, + "reason": { + "type": "string", + "description": "Error detail when unusable" + } + } + } + } + } + }, + "403": { + "description": "Requires owner role" + } + } + }, + "put": { + "tags": [ + "Governor" + ], + "summary": "Set the backup encryption key", + "description": "Stores a 64-character hex AES-256 backup encryption key VALUE to an owner-only PVC file; only the resulting file path is recorded in hive.yaml. The key value is never returned, logged, or written to hive.yaml. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "encryptionKey" + ], + "properties": { + "encryptionKey": { + "type": "string", + "description": "64 hex characters (openssl rand -hex 32)" + }, + "keyName": { + "type": "string", + "description": "Optional human label, max 128 chars" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Key stored", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "configured": { + "type": "boolean" + }, + "source": { + "type": "string" + }, + "keyName": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Missing/empty/wrong-length/non-hex key" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/bob": { + "delete": { + "tags": [ + "Governor" + ], + "summary": "Clear the bob API key", + "description": "Removes the stored bob API key file and drops the api_key_file pointer from hive.yaml (only if it names the file just removed). Reports whether an admin-managed key source still resolves. Requires owner role.", + "responses": { + "200": { + "description": "Cleared", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "configured": { + "type": "boolean" + }, + "source": { + "type": "string" + } + } + } + } + } + }, + "403": { + "description": "Requires owner role" + } + } + }, + "get": { + "tags": [ + "Governor" + ], + "summary": "Bob API key status", + "description": "Reports whether a bob API key is configured and its safe source label \u2014 never the key value.", + "responses": { + "200": { + "description": "Bob key status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "configured": { + "type": "boolean" + }, + "source": { + "type": "string" + }, + "keyName": { + "type": "string" + } + } + } + } + } + } + } + }, + "put": { + "tags": [ + "Governor" + ], + "summary": "Set the bob API key", + "description": "Stores a bob API key VALUE to an owner-only PVC file (and best-effort into the hive-secrets Secret); hive.yaml records only the file path. Relaunches any bob agents that were parked awaiting a key. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "apiKey" + ], + "properties": { + "apiKey": { + "type": "string" + }, + "keyName": { + "type": "string", + "description": "Optional human label" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Key stored", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "configured": { + "type": "boolean" + }, + "source": { + "type": "string" + }, + "restartNeeded": { + "type": "boolean" + }, + "relaunched": { + "type": "integer", + "description": "Count of parked bob agents relaunched by this save" + }, + "keyName": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Missing/empty/too-long key, or key contains interior whitespace" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/bob/test": { + "post": { + "tags": [ + "Governor" + ], + "summary": "Test a bob API key", + "description": "Live-validates a bob API key. A non-empty apiKey in the body tests that value directly (never persisted, logged, or echoed); an empty/absent body tests the currently saved key. Always returns HTTP 200 with the verdict in the body.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "description": "Optional; omit to test the saved key" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Test result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "error" + ] + }, + "reason": { + "type": "string" + }, + "detail": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Key too long or malformed request body" + } + } + } + }, + "/api/config/governor/budget/reset": { + "post": { + "tags": [ + "Governor" + ], + "summary": "Reset the budget window", + "description": "Opens a fresh budget-tracking window immediately: spend re-anchors to zero and once-per-window alerts rearm. Budgeting stays enabled. Requires owner role.", + "responses": { + "200": { + "description": "Window reset", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "minStatusSeq": { + "type": "integer", + "description": "Sequence floor so the dashboard discards pre-reset status snapshots" + } + } + } + } + } + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/gateways/{name}": { + "delete": { + "tags": [ + "Governor" + ], + "summary": "Delete a model gateway", + "description": "Removes a named model gateway. Refuses with 409 if any agent currently references it as its backend, listing the offending agents. Requires owner role.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Gateway name" + } + ], + "responses": { + "200": { + "description": "Deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "gateway": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Gateway not found" + }, + "409": { + "description": "Gateway in use by one or more agents" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/gateways": { + "get": { + "tags": [ + "Governor" + ], + "summary": "List model gateways", + "description": "Returns the effective list of configured model gateways (OpenRouter, LiteLLM, vLLM, llm-d, watsonx, custom), including a legacy litellm: block synthesized as a gateway if present. Never returns key values, only presence and a masked hint.", + "responses": { + "200": { + "description": "Gateway list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "gateways": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "openrouter", + "litellm", + "vllm", + "llm-d", + "watsonx", + "custom" + ] + }, + "endpoint": { + "type": "string" + }, + "api_key_env": { + "type": "string" + }, + "api_key_file": { + "type": "string" + }, + "default_model": { + "type": "string" + }, + "ca_bundle": { + "type": "string" + }, + "project_id": { + "type": "string" + }, + "region": { + "type": "string" + }, + "hasKey": { + "type": "boolean" + }, + "keyHint": { + "type": "string" + }, + "keyName": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "put": { + "tags": [ + "Governor" + ], + "summary": "Create or replace a model gateway", + "description": "Upserts a gateway by name. An api_key VALUE, if sent, is written to a per-gateway secret file (never inlined into hive.yaml) and the gateway's legacy litellm: section is kept in sync if it points at the same endpoint. Runs a live save-time probe when a key is submitted. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name", + "endpoint" + ], + "properties": { + "name": { + "type": "string", + "description": "Max 64 chars, no spaces/dots/slashes" + }, + "kind": { + "type": "string", + "enum": [ + "openrouter", + "litellm", + "vllm", + "llm-d", + "watsonx", + "custom" + ] + }, + "endpoint": { + "type": "string", + "description": "Absolute http(s) URL; may be omitted for watsonx if region is set" + }, + "api_key": { + "type": "string", + "description": "Key VALUE; stored to a secret file, never echoed" + }, + "api_key_env": { + "type": "string" + }, + "api_key_file": { + "type": "string" + }, + "default_model": { + "type": "string" + }, + "ca_bundle": { + "type": "string" + }, + "project_id": { + "type": "string", + "description": "Required for watsonx" + }, + "region": { + "type": "string" + }, + "key_name": { + "type": "string", + "description": "Optional human label for the key" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Gateway saved, with a live connectivity probe result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "gateway": { + "type": "object", + "description": "Same shape as one item of GET /gateways" + }, + "probe": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "model_count": { + "type": "integer" + }, + "error": { + "type": "string" + }, + "skipped": { + "type": "string" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid name/kind/endpoint, or field looks like a pasted key value" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/gateways/discover": { + "post": { + "tags": [ + "Governor" + ], + "summary": "Discover models for an arbitrary gateway endpoint", + "description": "Probes a caller-supplied endpoint's /v1/models so the add/edit gateway form's model dropdown can populate before saving. The key is used transiently only. Falls back to a stored gateway's key ONLY when the caller-supplied endpoint matches that gateway's own persisted endpoint (SSRF/exfiltration guard). Blocks private/loopback/link-local resolution targets. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "endpoint" + ], + "properties": { + "name": { + "type": "string", + "description": "Existing gateway name to fall back to for kind/key/project_id" + }, + "endpoint": { + "type": "string" + }, + "api_key": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "project_id": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Discovery result (always HTTP 200; ok:false on failure)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallback": { + "type": "boolean", + "description": "True when the static watsonx Granite list was used" + }, + "error": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Missing/invalid endpoint, or endpoint resolves to a private/loopback address" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/gateways/{name}/test": { + "post": { + "tags": [ + "Governor" + ], + "summary": "Test a saved gateway", + "description": "Runs a live /v1/models probe against a named gateway's persisted endpoint and resolved key.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Gateway name" + } + ], + "responses": { + "200": { + "description": "Probe result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "probe": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "model_count": { + "type": "integer" + }, + "error": { + "type": "string" + } + } + } + } + } + } + } + }, + "400": { + "description": "Gateway has no endpoint configured" + }, + "404": { + "description": "Gateway not found" + } + } + } + }, + "/api/config/governor/general-advanced": { + "get": { + "tags": [ + "Governor" + ], + "summary": "General-tab advanced settings", + "description": "Returns the governor eval interval, attribution-trailer resolved state, and explain-mode configured/effective/source values. Requires owner role.", + "responses": { + "200": { + "description": "General advanced config", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "eval_interval_s": { + "type": "integer" + }, + "attribution_trailer": { + "type": "boolean" + }, + "explain_mode": { + "type": "string", + "description": "Configured value; empty means unset" + }, + "explain_mode_effective": { + "type": "string" + }, + "explain_mode_source": { + "type": "string" + } + } + } + } + } + }, + "403": { + "description": "Requires owner role" + } + } + }, + "put": { + "tags": [ + "Governor" + ], + "summary": "Update general-tab advanced settings", + "description": "Updates the governor eval interval, attribution-trailer toggle, and hive-wide explain-mode default. Fields are pointer-typed; absent fields are left unchanged. An explicit empty explain_mode clears the hive default. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "eval_interval_s": { + "type": "integer", + "description": "10-86400" + }, + "attribution_trailer": { + "type": "boolean" + }, + "explain_mode": { + "type": "string", + "enum": [ + "off", + "brief", + "full", + "" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated (same shape as GET)", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Out-of-range eval_interval_s or invalid explain_mode" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/health": { + "put": { + "tags": [ + "Governor" + ], + "summary": "Update health/restart settings", + "description": "Updates healthcheck interval, restart cooldown, and the model-lock toggle. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "healthcheckInterval": { + "type": "integer" + }, + "restartCooldown": { + "type": "integer" + }, + "modelLock": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid interval/cooldown values" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/hub": { + "put": { + "tags": [ + "Governor" + ], + "summary": "Update hub/contribute settings", + "description": "Updates hub connectivity and Contribute-mode filters (title/author/label modes, allow/deny lists, cooldown, delegatable roles, disabled repos/tiers, tier limits). Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "url": { + "type": "string" + }, + "dashboard_url": { + "type": "string" + }, + "snapshot_url": { + "type": "string" + }, + "is_public": { + "type": "boolean" + }, + "auto_snapshot": { + "type": "boolean" + }, + "snapshot_frame_ancestors": { + "type": "array", + "items": { + "type": "string" + } + }, + "auto_upgrade": { + "type": "boolean" + }, + "contribute_suspended": { + "type": "boolean" + }, + "contribute_titles_mode": { + "type": "string" + }, + "contribute_authors_mode": { + "type": "string" + }, + "contribute_labels_mode": { + "type": "string" + }, + "contribute_allow_labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "contribute_deny_labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "contribute_deny_titles": { + "type": "array", + "items": { + "type": "string" + } + }, + "contribute_deny_authors": { + "type": "array", + "items": { + "type": "string" + } + }, + "contribute_allow_models": { + "type": "array", + "items": { + "type": "string" + } + }, + "contribute_reject_unknown_models": { + "type": "boolean" + }, + "contribute_skip_assigned_to_others": { + "type": "boolean" + }, + "contribute_cooldown_enabled": { + "type": "boolean" + }, + "contribute_cooldown_hours": { + "type": "integer" + }, + "contribute_delegatable_roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "disabled_repos": { + "type": "array", + "items": { + "type": "string" + } + }, + "disabled_tiers": { + "type": "array", + "items": { + "type": "string" + } + }, + "tier_limits": { + "type": "object", + "description": "Map of tier name to rate limit object" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid snapshot_frame_ancestors" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/inference-auth": { + "get": { + "tags": [ + "Governor" + ], + "summary": "vLLM/llm-d inference auth settings", + "description": "Returns the discovery-auth configuration for the self-hosted vllm and llm-d backends. References only (header name, env var name, file path, endpoint override) \u2014 never a key value. Requires owner role.", + "responses": { + "200": { + "description": "Inference auth config", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "vllm": { + "type": "object", + "properties": { + "api_key_header": { + "type": "string" + }, + "api_key_env": { + "type": "string" + }, + "api_key_file": { + "type": "string" + }, + "endpoint": { + "type": "string" + } + } + }, + "llmd": { + "type": "object", + "properties": { + "api_key_header": { + "type": "string" + }, + "api_key_env": { + "type": "string" + }, + "api_key_file": { + "type": "string" + }, + "endpoint": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "Requires owner role" + } + } + }, + "put": { + "tags": [ + "Governor" + ], + "summary": "Update vLLM/llm-d inference auth settings", + "description": "Updates the vllm and/or llm-d discovery-auth sections. Each section is optional; within it, absent fields are unchanged. Rejects an env/file field that looks like a pasted key value, and confines api_key_file to the managed secrets dirs. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "vllm": { + "type": "object", + "properties": { + "api_key_header": { + "type": "string" + }, + "api_key_env": { + "type": "string" + }, + "api_key_file": { + "type": "string" + }, + "endpoint": { + "type": "string" + } + } + }, + "llmd": { + "type": "object", + "properties": { + "api_key_header": { + "type": "string" + }, + "api_key_env": { + "type": "string" + }, + "api_key_file": { + "type": "string" + }, + "endpoint": { + "type": "string" + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated (same shape as GET, plus ok/status)", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Field looks like a key value, or api_key_file outside managed secrets dirs, or invalid endpoint" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/labels": { + "put": { + "tags": [ + "Governor" + ], + "summary": "Update exempt and required issue labels", + "description": "Updates the exempt-labels list (governor.labels) and/or the require-labels gate (project.issue_filter.require_labels). Pointer-typed fields: an absent key leaves that list unchanged. Permanent hold/exempt labels cannot be removed via the exempt list. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Exempt labels" + }, + "require_labels": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid label(s)" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/litellm/test": { + "post": { + "tags": [ + "Governor" + ], + "summary": "Test the LiteLLM connection", + "description": "Runs a live /v1/models probe against the currently effective LiteLLM endpoint and key. Never falls back to the static model alias list.", + "responses": { + "200": { + "description": "Probe result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "probe": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "model_count": { + "type": "integer" + }, + "error": { + "type": "string" + } + } + } + } + } + } + } + }, + "400": { + "description": "No litellm endpoint configured" + } + } + } + }, + "/api/config/governor/litellm": { + "put": { + "tags": [ + "Governor" + ], + "summary": "Update the LiteLLM gateway config", + "description": "Updates governor.litellm (legacy single-gateway config). An apiKey VALUE, if sent, is stored via a PVC file (and best-effort into the hive-secrets Secret); hive.yaml records only the file path. Runs a live save-time probe and re-applies inference routes to running agents. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "endpoint": { + "type": "string" + }, + "apiKey": { + "type": "string", + "description": "Key VALUE; stored to a file, never echoed" + }, + "apiKeyEnv": { + "type": "string" + }, + "apiKeyFile": { + "type": "string" + }, + "defaultModel": { + "type": "string" + }, + "caBundle": { + "type": "string" + }, + "localProxy": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated, with a live connectivity probe result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "probe": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "model_count": { + "type": "integer" + }, + "error": { + "type": "string" + } + } + } + } + } + } + } + }, + "400": { + "description": "Field looks like a key value, api_key_file outside managed secrets dirs, or config validation failure" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/project-observability": { + "get": { + "tags": [ + "Governor" + ], + "summary": "Project observability platform selection", + "description": "Returns the operator-selected open-source/kube-native/commercial observability platforms, backend references, whether the telemetry/operations agents are enabled, the supported platform catalog, and detected suggestions from the telemetry agent's advisory findings. Requires owner role.", + "responses": { + "200": { + "description": "Project observability config", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "open_source": { + "type": "array", + "items": { + "type": "string" + } + }, + "kube_native": { + "type": "array", + "items": { + "type": "string" + } + }, + "commercial": { + "type": "array", + "items": { + "type": "string" + } + }, + "references": { + "type": "object", + "description": "Map of platform name to {endpoint_env, credential_secret}" + }, + "telemetry_enabled": { + "type": "boolean" + }, + "operations_enabled": { + "type": "boolean" + }, + "supported": { + "type": "object", + "description": "Map of family to allowed platform slugs" + }, + "detected": { + "type": "object", + "description": "Map of family to platform slugs suggested from telemetry advisory text; may be null" + } + } + } + } + } + }, + "403": { + "description": "Requires owner role" + } + } + }, + "put": { + "tags": [ + "Governor" + ], + "summary": "Update project observability platform selection", + "description": "Updates the selected observability platforms and backend references, and toggles the telemetry/operations agent cadences accordingly. Enabling telemetry or operations requires at least one platform already selected. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "open_source": { + "type": "array", + "items": { + "type": "string" + } + }, + "kube_native": { + "type": "array", + "items": { + "type": "string" + } + }, + "commercial": { + "type": "array", + "items": { + "type": "string" + } + }, + "references": { + "type": "object", + "description": "Map of platform name to {endpoint_env, credential_secret}; values must be env-var names / secret-name/key refs, never literals" + }, + "telemetry_enabled": { + "type": "boolean" + }, + "operations_enabled": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated (same shape as GET)", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Unsupported platform, invalid reference format, or enabling with no platform selected" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/replan": { + "get": { + "tags": [ + "Governor" + ], + "summary": "Stall-replan lane settings", + "description": "Returns the stall-replan configuration (enabled, interval, stall threshold, max replans) with defaults resolved. Requires owner role.", + "responses": { + "200": { + "description": "Replan config", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "interval_s": { + "type": "integer" + }, + "stall_threshold_s": { + "type": "integer" + }, + "max_replans": { + "type": "integer" + } + } + } + } + } + }, + "403": { + "description": "Requires owner role" + } + } + }, + "put": { + "tags": [ + "Governor" + ], + "summary": "Update stall-replan lane settings", + "description": "Updates the stall-replan configuration. Fields are pointer-typed; absent fields are unchanged. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "interval_s": { + "type": "integer", + "description": "0 = default; must be >= 0" + }, + "stall_threshold_s": { + "type": "integer", + "description": "0 = default; must be >= 0" + }, + "max_replans": { + "type": "integer", + "description": "0 = default; must be >= 0" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated (same shape as GET)", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Negative value" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/repos": { + "put": { + "tags": [ + "Governor" + ], + "summary": "Update monitored repos and default repo", + "description": "Updates the list of monitored repos and/or the primary (default) repo. Enforces single-host-per-spoke (all repos must be on the hive's own GitHub host) and requires exactly one default repo among the monitored set. Triggers re-enumeration on success. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "description": "org/repo, bare repo name, or full URL" + }, + "primaryRepo": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Empty repo list, invalid repo name, cross-host repo, or no default repo set" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/repos/check-access": { + "post": { + "tags": [ + "Governor" + ], + "summary": "Check GitHub App access to a repo", + "description": "Verifies the hive's GitHub App can access a repo's org before it is added, probing at org install granularity. Returns an install/authorize URL when access is missing. A hive with no App key configured (advisory-only) always reports no-app.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "repo" + ], + "properties": { + "repo": { + "type": "string", + "description": "org/repo, bare repo name, or full URL" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Access check result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "e.g. \"no-app\", \"ok\", or an install-needed status" + } + } + } + } + } + }, + "400": { + "description": "Missing repo, cannot determine org, or repo on a different GitHub host" + } + } + } + }, + "/api/config/governor/security": { + "put": { + "tags": [ + "Governor" + ], + "summary": "Update security posture settings", + "description": "Updates ioscan enable/fail-mode/canaries, intent-alignment enforcement, PR review-gate settings, and the agent sandbox toggle. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ioscanEnabled": { + "type": "boolean" + }, + "ioscanFailMode": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "ioscanCanaries": { + "type": "boolean" + }, + "intentEnforce": { + "type": "boolean" + }, + "intentAlignmentModel": { + "type": "string" + }, + "reviewRequireApproval": { + "type": "boolean" + }, + "reviewFanOut": { + "type": "boolean" + }, + "reviewMaxParallelReviews": { + "type": "integer", + "description": "0-64" + }, + "reviewReviewerAgents": { + "type": "array", + "items": { + "type": "string" + } + }, + "reviewFixerAgent": { + "type": "string" + }, + "agentSandboxEnabled": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid ioscanFailMode or out-of-range reviewMaxParallelReviews" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/sensing": { + "put": { + "tags": [ + "Governor" + ], + "summary": "Update sensing/rate-limit detection settings", + "description": "Updates the governor eval interval, GitHub/CLI rate-limit detection regex patterns, login-prompt patterns, and TTL/pullback timings used by rate-limit sensing. Each regex is compiled and rejected if invalid. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "eval_interval_s": { + "type": "integer", + "description": "0 = unchanged; else 10-86400" + }, + "ghRatePatterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Regex patterns" + }, + "cliExcludePatterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Regex patterns" + }, + "loginPatterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Regex patterns" + }, + "ttlSeconds": { + "type": "integer", + "description": "0 = unchanged; else 1-86400" + }, + "pullbackSeconds": { + "type": "integer", + "description": "0 = unchanged; else 1-86400" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid regex or out-of-range value" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/threshold-scaling": { + "get": { + "tags": [ + "Governor" + ], + "summary": "Threshold-scaling curve", + "description": "Returns the configured threshold-scaling curve (linear/sqrt/none) with its default applied. Requires owner role.", + "responses": { + "200": { + "description": "Threshold scaling mode", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "threshold_scaling": { + "type": "string", + "enum": [ + "linear", + "sqrt", + "none" + ] + } + } + } + } + } + }, + "403": { + "description": "Requires owner role" + } + } + }, + "put": { + "tags": [ + "Governor" + ], + "summary": "Set the threshold-scaling curve", + "description": "Sets how the default mode thresholds scale with the hive's repo count. Triggers immediate governor re-evaluation. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "thresholdScaling": { + "type": "string", + "enum": [ + "linear", + "sqrt", + "none", + "" + ] + }, + "threshold_scaling": { + "type": "string", + "description": "Snake-case alias for thresholdScaling" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid scaling value" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/config/governor/work-source": { + "get": { + "tags": [ + "Governor" + ], + "summary": "Work-source configuration", + "description": "Returns the configured work source (github, github_projects, linear, or jira) and its per-source settings. Linear's api_key is reported only as api_key_set (never the value). Requires owner role.", + "responses": { + "200": { + "description": "Work source config", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "", + "github", + "github_projects", + "linear", + "jira" + ] + }, + "github_projects": { + "type": "object", + "properties": { + "org": { + "type": "string" + }, + "project_number": { + "type": "integer" + }, + "states": { + "type": "array", + "items": { + "type": "string" + } + }, + "priority_field": { + "type": "string" + }, + "iteration_field": { + "type": "string" + }, + "default_repo": { + "type": "string" + } + } + }, + "linear": { + "type": "object", + "properties": { + "api_key_set": { + "type": "boolean" + }, + "hold_labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "session_agent": { + "type": "string" + }, + "assigned_only": { + "type": "boolean" + }, + "teams": { + "type": "array", + "items": { + "type": "object" + } + } + } + }, + "jira": { + "type": "object", + "properties": { + "base_url": { + "type": "string" + }, + "email": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "project_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "jql": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "hold_labels": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "403": { + "description": "Requires owner role" + } + } + }, + "put": { + "tags": [ + "Governor" + ], + "summary": "Update work-source configuration", + "description": "Updates the work source type and/or its per-source settings (github_projects, linear, jira). List-valued fields (hold_labels, teams, project_keys, states) replace the stored list when present. Validates Linear team/session-agent/assigned-only settings before applying. Requires owner role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "", + "github", + "github_projects", + "linear", + "jira" + ] + }, + "github_projects": { + "type": "object", + "properties": { + "org": { + "type": "string" + }, + "project_number": { + "type": "integer" + }, + "states": { + "type": "array", + "items": { + "type": "string" + } + }, + "priority_field": { + "type": "string" + }, + "iteration_field": { + "type": "string" + }, + "default_repo": { + "type": "string" + } + } + }, + "linear": { + "type": "object", + "properties": { + "api_key": { + "type": "string" + }, + "hold_labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "session_agent": { + "type": "string" + }, + "assigned_only": { + "type": "boolean" + }, + "teams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "states": { + "type": "array", + "items": { + "type": "string" + } + }, + "cycles": { + "type": "string" + }, + "projects": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + } + }, + "jira": { + "type": "object", + "properties": { + "base_url": { + "type": "string" + }, + "email": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "project_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "jql": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "hold_labels": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated (same shape as GET)", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Invalid type, invalid Linear team, unknown session_agent, or assigned_only without a connected Linear agent" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, + "/api/knowledge": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "List all knowledge facts", + "description": "Returns every fact aggregated across all configured layers, vaults, and connected git sources. Returns `{\"enabled\": false, \"facts\": []}` if the knowledge base is not enabled.", + "parameters": [ + { + "name": "type", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Filter facts by fact type (e.g. pattern, gotcha, decision)" + } + ], + "responses": { + "200": { + "description": "All facts, or a disabled placeholder", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "count": { + "type": "integer" + }, + "facts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "body": { + "type": "string" + }, + "confidence": { + "type": "number" + }, + "status": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "layer": { + "type": "string" + }, + "usage_count": { + "type": "integer" + } + } + } + } + } + } + } + } + } + } + } + }, + "/api/knowledge/bead-synthesizer": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "Bead synthesizer status", + "description": "Returns the current configuration and running state of the background bead synthesizer, which mines closed beads into knowledge facts.", + "responses": { + "200": { + "description": "Bead synthesizer status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "running": { + "type": "boolean" + }, + "schedule": { + "type": "string" + }, + "min_confidence": { + "type": "number" + }, + "target_layer": { + "type": "string" + }, + "max_facts_per_cycle": { + "type": "integer" + }, + "vault_path": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/api/knowledge/bead-synthesizer/enabled": { + "put": { + "tags": [ + "Knowledge" + ], + "summary": "Enable or disable the bead synthesizer", + "description": "Starts or stops the background bead synthesizer and persists the setting. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Bead synthesizer toggled", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "bead_synthesizer_enabled": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid request body" + }, + "403": { + "description": "Owner role required" + } + } + } + }, + "/api/knowledge/enabled": { + "put": { + "tags": [ + "Knowledge" + ], + "summary": "Enable or disable the knowledge base", + "description": "Enables or disables the whole knowledge subsystem, (re)constructing the knowledge API from configured layers when turning on, and starting/stopping the bead synthesizer accordingly. Persists the setting. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Knowledge base toggled", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "enabled": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid request body" + }, + "403": { + "description": "Owner role required" + } + } + } + }, + "/api/knowledge/channels": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "List writable knowledge channels", + "description": "Returns user-writable channels (vaults) that facts can be imported into. The reserved automation vault used by the bead synthesizer is excluded. Returns `[]` if the knowledge base is not enabled.", + "responses": { + "200": { + "description": "Writable vaults", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "root_dir": { + "type": "string" + }, + "pages": { + "type": "integer" + }, + "last_indexed": { + "type": "string", + "format": "date-time" + }, + "tag_counts": { + "type": "object" + } + } + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Knowledge" + ], + "summary": "Create a knowledge channel", + "description": "Creates a new local channel (vault) that facts can subsequently be imported into.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Channel created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "channel": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "root_dir": { + "type": "string" + }, + "pages": { + "type": "integer" + }, + "last_indexed": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid request body, or channel creation failed" + }, + "503": { + "description": "Knowledge not enabled" + } + } + } + }, + "/api/knowledge/context7/search": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "Search Context7 libraries", + "description": "Proxies a library search to the Context7 documentation service, for selecting a library to import as a document source.", + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Search query" + } + ], + "responses": { + "200": { + "description": "Matching Context7 libraries", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "totalSnippets": { + "type": "integer" + }, + "trustScore": { + "type": "number" + }, + "benchmarkScore": { + "type": "number" + }, + "stars": { + "type": "integer" + } + } + } + } + } + } + }, + "400": { + "description": "Missing q parameter" + }, + "502": { + "description": "Context7 request failed" + }, + "503": { + "description": "Knowledge not configured" + } + } + } + }, + "/api/knowledge/documents": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "List imported documents", + "description": "Returns metadata for all documents (PDFs, URLs, files, or Context7 libraries) imported into the knowledge base.", + "responses": { + "200": { + "description": "Imported document metadata", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "author": { + "type": "string" + }, + "source_url": { + "type": "string" + }, + "source_file": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "fetched_at": { + "type": "string", + "format": "date-time" + }, + "page_count": { + "type": "integer" + }, + "fact_count": { + "type": "integer" + }, + "fact_slugs": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "503": { + "description": "Knowledge not configured" + } + } + }, + "post": { + "tags": [ + "Knowledge" + ], + "summary": "Import a document", + "description": "Imports a PDF, URL, local file, or Context7 library as a knowledge document, extracting facts from its content. Exactly one of url, file_path, or context7_id must be given. A URL must use http/https and must not resolve to a private/internal address.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "file_path": { + "type": "string" + }, + "context7_id": { + "type": "string" + }, + "layer": { + "type": "string", + "description": "Defaults to \"project\" if omitted" + } + }, + "required": [ + "name" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Imported document metadata", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "author": { + "type": "string" + }, + "source_url": { + "type": "string" + }, + "source_file": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "fetched_at": { + "type": "string", + "format": "date-time" + }, + "page_count": { + "type": "integer" + }, + "fact_count": { + "type": "integer" + }, + "fact_slugs": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid body, missing name/source, or disallowed URL" + }, + "500": { + "description": "Import failed" + }, + "503": { + "description": "Knowledge not configured" + } + } + } + }, + "/api/knowledge/documents/{slug}": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "Get an imported document", + "description": "Returns metadata for a single imported document by slug.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Document metadata", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "author": { + "type": "string" + }, + "source_url": { + "type": "string" + }, + "source_file": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "fetched_at": { + "type": "string", + "format": "date-time" + }, + "page_count": { + "type": "integer" + }, + "fact_count": { + "type": "integer" + }, + "fact_slugs": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "404": { + "description": "Document not found" + }, + "503": { + "description": "Knowledge not configured" + } + } + }, + "delete": { + "tags": [ + "Knowledge" + ], + "summary": "Delete an imported document", + "description": "Deletes an imported document and its associated facts by slug.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Document deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "slug": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Document not found" + }, + "503": { + "description": "Knowledge not configured" + } + } + } + }, + "/api/knowledge/documents/{slug}/reimport": { + "post": { + "tags": [ + "Knowledge" + ], + "summary": "Re-import a document", + "description": "Re-fetches and re-parses an already-imported document, refreshing its extracted facts.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Refreshed document metadata", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "author": { + "type": "string" + }, + "source_url": { + "type": "string" + }, + "source_file": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "fetched_at": { + "type": "string", + "format": "date-time" + }, + "page_count": { + "type": "integer" + }, + "fact_count": { + "type": "integer" + }, + "fact_slugs": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "500": { + "description": "Reimport failed" + }, + "503": { + "description": "Knowledge not configured" + } + } + } + }, + "/api/knowledge/export": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "Export knowledge base as markdown", + "description": "Renders all facts across layers/vaults/git sources as a single grouped markdown document, sorted stably by slug so unchanged content re-exports byte-for-byte identically. Response includes an ETag header computed over the rendered body.", + "responses": { + "200": { + "description": "Markdown export of the knowledge base", + "content": { + "text/markdown": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/api/knowledge/fact-history": { + "get": { + "tags": [ + "History" + ], + "summary": "Fact-count sparkline history", + "description": "Returns the recorded time series of total knowledge fact counts, sampled roughly every 5 minutes (up to ~30 days retained), for the dashboard's fact sparkline.", + "responses": { + "200": { + "description": "Fact count history points", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "t": { + "type": "integer", + "description": "Unix timestamp (ms)" + }, + "count": { + "type": "integer" + } + } + } + } + } + } + } + } + } + }, + "/api/knowledge/git-sources": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "List connected git knowledge sources", + "description": "Returns all git repositories (or subdirectories) connected as knowledge sources. Returns `[]` if the knowledge base is not enabled.", + "responses": { + "200": { + "description": "Connected git sources", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "subpath": { + "type": "string" + }, + "layer": { + "type": "string" + }, + "clone_dir": { + "type": "string" + }, + "ready": { + "type": "boolean" + }, + "pages": { + "type": "integer" + } + } + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Knowledge" + ], + "summary": "Connect a git knowledge source", + "description": "Clones (or re-uses) a git repository and indexes it (or a subpath within it) as a knowledge source, persisting the connection to config. Requires owner role. The URL is validated against SSRF (private/internal targets rejected); name must not contain path separators or '..'; branch/subpath must not start with '-' and subpath must not contain '..'.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "subpath": { + "type": "string" + }, + "layer": { + "type": "string", + "description": "Defaults to \"project\" if omitted" + } + }, + "required": [ + "name", + "url" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Git source connected", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body, missing name/url, disallowed URL, or invalid name/branch/subpath" + }, + "403": { + "description": "Owner role required" + }, + "500": { + "description": "Clone/connect failed" + }, + "503": { + "description": "Knowledge not available" + } + } + }, + "delete": { + "tags": [ + "Knowledge" + ], + "summary": "Disconnect a git knowledge source", + "description": "Removes a connected git source identified by URL (and optional subpath) and drops it from config. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "subpath": { + "type": "string" + } + }, + "required": [ + "url" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Git source disconnected", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "removed": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body or missing url" + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Git source not found" + }, + "503": { + "description": "Knowledge not enabled" + } + } + } + }, + "/api/knowledge/graph": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "Knowledge fact relationship graph", + "description": "Returns a graph of facts and their relationships (nodes and edges), optionally rooted at a given fact slug and limited to a traversal depth. Returns empty nodes/edges if knowledge or the graph store is unavailable.", + "parameters": [ + { + "name": "root", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Slug of the fact to root the graph traversal at; omit for the full graph" + }, + { + "name": "depth", + "in": "query", + "schema": { + "type": "integer", + "default": 2 + }, + "description": "Traversal depth from the root" + } + ], + "responses": { + "200": { + "description": "Graph nodes and edges", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "nodes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "layer": { + "type": "string" + }, + "confidence": { + "type": "number" + }, + "usage_count": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "body": { + "type": "string" + } + } + } + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "predicate": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "/api/knowledge/health": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "Knowledge layer health", + "description": "Checks each configured wiki layer's reachability and returns its status. Returns `{\"enabled\": false}` if the knowledge base is not configured.", + "responses": { + "200": { + "description": "Per-layer health", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "url": { + "type": "string" + }, + "healthy": { + "type": "boolean" + }, + "pages": { + "type": "integer" + } + } + } + } + } + } + } + } + } + }, + "/api/knowledge/search": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "Search knowledge facts", + "description": "Searches across all layers and vaults by free-text query or tag. Either `q` or `tag` is required. Returns `{\"results\": []}` if the knowledge base is not enabled.", + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Free-text search query (required if tag is omitted)" + }, + { + "name": "tag", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Filter by exact tag match, case-insensitive (required if q is omitted)" + }, + { + "name": "type", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Filter results by fact type" + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "Maximum number of results" + } + ], + "responses": { + "200": { + "description": "Search results", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "body": { + "type": "string" + }, + "confidence": { + "type": "number" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "layer": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Neither q nor tag provided" + } + } + } + }, + "/api/knowledge/stats": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "Aggregate knowledge base stats", + "description": "Returns aggregate stats across all layers, vaults, and git sources (fact counts by type/status, staleness, orphan counts). Also updates the fact-count and estimated-cost history sparklines as a side effect (throttled to ~5-min intervals). Returns `{\"enabled\": false}` if the knowledge base is not enabled.", + "responses": { + "200": { + "description": "Aggregate stats", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "engine": { + "type": "string" + }, + "layers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "url": { + "type": "string" + }, + "healthy": { + "type": "boolean" + }, + "total_pages": { + "type": "integer" + }, + "by_type": { + "type": "object" + }, + "by_status": { + "type": "object" + }, + "stale": { + "type": "integer" + }, + "orphaned": { + "type": "integer" + }, + "layer": { + "type": "string" + }, + "subpath": { + "type": "string" + } + } + } + }, + "layers_count": { + "type": "integer" + }, + "vaults": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "root_dir": { + "type": "string" + }, + "pages": { + "type": "integer" + }, + "last_indexed": { + "type": "string", + "format": "date-time" + } + } + } + }, + "git_sources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "subpath": { + "type": "string" + }, + "layer": { + "type": "string" + }, + "ready": { + "type": "boolean" + }, + "pages": { + "type": "integer" + } + } + } + } + } + } + } + } + } + } + } + }, + "/api/knowledge/subscriptions": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "List wiki subscriptions", + "description": "Returns user-added remote wiki endpoint subscriptions. Returns `[]` if the knowledge base is not enabled.", + "responses": { + "200": { + "description": "Subscriptions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "layer": { + "type": "string" + }, + "name": { + "type": "string" + }, + "added": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Knowledge" + ], + "summary": "Add a wiki subscription", + "description": "Adds a remote wiki endpoint as a subscribed layer source. URL must use http/https and must not point to a private/internal address.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "layer": { + "type": "string", + "description": "Defaults to \"org\" if omitted" + }, + "name": { + "type": "string" + } + }, + "required": [ + "url" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Subscription added", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "subscription": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "layer": { + "type": "string" + }, + "name": { + "type": "string" + }, + "added": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid body, missing url, disallowed scheme, or private/internal URL" + }, + "409": { + "description": "Subscription already exists" + }, + "503": { + "description": "Knowledge not enabled" + } + } + }, + "delete": { + "tags": [ + "Knowledge" + ], + "summary": "Remove a wiki subscription", + "description": "Removes a previously added wiki subscription by URL.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Subscription removed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "removed": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body or missing url" + }, + "503": { + "description": "Knowledge not enabled" + } + } + } + }, + "/api/knowledge/vaults": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "List connected vaults", + "description": "Returns all connected file-based (Obsidian or plain markdown directory) vaults. Returns `[]` if the knowledge base is not enabled.", + "responses": { + "200": { + "description": "Connected vaults", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "root_dir": { + "type": "string" + }, + "pages": { + "type": "integer" + }, + "last_indexed": { + "type": "string", + "format": "date-time" + }, + "tag_counts": { + "type": "object" + } + } + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Knowledge" + ], + "summary": "Connect a vault", + "description": "Indexes a directory tree as a knowledge vault. Requires owner role, since it makes the entire directory's contents queryable via search. Path must be absolute, must not contain '..', and must not be under a restricted system prefix (/etc, /proc, /sys, /run, /var/run, /dev, /root, /boot).", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "name": { + "type": "string", + "description": "Defaults to the path's base name if omitted" + } + }, + "required": [ + "path" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Vault connected", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body, missing path, relative/traversal path, restricted path, or connect failed" + }, + "403": { + "description": "Owner role required" + }, + "503": { + "description": "Knowledge not enabled" + } + } + }, + "delete": { + "tags": [ + "Knowledge" + ], + "summary": "Disconnect a vault", + "description": "Removes a connected vault by its root path.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Vault disconnected", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "removed": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body, missing path, relative, or traversal path" + }, + "404": { + "description": "Vault not found" + }, + "503": { + "description": "Knowledge not enabled" + } + } + } + }, + "/api/knowledge/vaults/reindex": { + "post": { + "tags": [ + "Knowledge" + ], + "summary": "Reindex a vault", + "description": "Re-scans a connected vault's directory tree to refresh its indexed facts.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Vault reindexed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "reindexed": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body, missing path, relative, or traversal path" + }, + "404": { + "description": "Vault not found" + }, + "503": { + "description": "Knowledge not enabled" + } + } + } + }, + "/api/knowledge/vaults/{name}/facts": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "List facts in a vault", + "description": "Returns all facts indexed from a single connected vault by name. Returns `[]` if the knowledge base is not enabled or the vault has no facts.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Vault name" + } + ], + "responses": { + "200": { + "description": "Facts in the vault", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "body": { + "type": "string" + }, + "confidence": { + "type": "number" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "layer": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "/api/knowledge/{layer}": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "List facts in a layer", + "description": "Returns facts for a single layer (personal, project, org, community), or for a git source when `{layer}` is prefixed `git_source:`. Returns `{\"enabled\": false, \"facts\": []}` if the knowledge base is not enabled, or an empty facts array if the named git source is unknown.", + "parameters": [ + { + "name": "layer", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Layer name (personal, project, org, community) or \"git_source:\"" + }, + { + "name": "type", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Filter facts by fact type" + } + ], + "responses": { + "200": { + "description": "Facts in the layer", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "layer": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "facts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "body": { + "type": "string" + }, + "confidence": { + "type": "number" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "layer": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "/api/knowledge/{layer}/{slug}": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "Get a single fact", + "description": "Returns one fact by slug. Note the handler resolves the fact by slug alone (via ReadFact, which also checks vaults) \u2014 the `{layer}` path segment identifies which layer/collection UI context the request came from but is not otherwise used to scope the lookup.", + "parameters": [ + { + "name": "layer", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The fact", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "body": { + "type": "string" + }, + "confidence": { + "type": "number" + }, + "status": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "layer": { + "type": "string" + }, + "usage_count": { + "type": "integer" + }, + "last_used": { + "type": "string", + "format": "date-time" + }, + "supersedes": { + "type": "string" + }, + "phase": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Knowledge not enabled, or fact not found" + } + } + }, + "put": { + "tags": [ + "Knowledge" + ], + "summary": "Update a fact", + "description": "Updates an existing fact's fields in the given layer.", + "parameters": [ + { + "name": "layer", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "type": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "confidence": { + "type": "number" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Fact updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "slug": { + "type": "string" + }, + "layer": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid request body" + }, + "500": { + "description": "Update failed (e.g. layer has no configured endpoint)" + }, + "503": { + "description": "Knowledge not enabled" + } + } + }, + "delete": { + "tags": [ + "Knowledge" + ], + "summary": "Delete a fact", + "description": "Deletes a fact by slug from the given layer.", + "parameters": [ + { + "name": "layer", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Fact deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "deleted": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Delete failed (e.g. layer has no configured endpoint)" + }, + "503": { + "description": "Knowledge not enabled" + } + } + } + }, + "/api/knowledge/cleanup-orphans": { + "post": { + "tags": [ + "Knowledge" + ], + "summary": "Clean up orphaned document facts", + "description": "Removes facts that reference deleted documents, returning the number of facts removed.", + "responses": { + "200": { + "description": "Cleanup result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "removed": { + "type": "integer" + } + } + } + } + } + }, + "500": { + "description": "Cleanup failed" + }, + "503": { + "description": "Knowledge not configured" + } + } + } + }, + "/api/knowledge/create": { + "post": { + "tags": [ + "Knowledge" + ], + "summary": "Create a fact", + "description": "Creates a new fact directly (as opposed to extracting one from a PR/import). Title and body are required; layer defaults to \"project\", type defaults to \"pattern\", confidence defaults to 0.7 if not positive.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "type": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "layer": { + "type": "string" + }, + "confidence": { + "type": "number" + } + }, + "required": [ + "title", + "body" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Fact created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "layer": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body, or missing title/body" + }, + "500": { + "description": "Creation failed (e.g. layer has no configured endpoint)" + }, + "503": { + "description": "Knowledge not enabled" + } + } + } + }, + "/api/knowledge/import": { + "post": { + "tags": [ + "Knowledge" + ], + "summary": "Import raw fact content", + "description": "Bulk-imports facts by parsing raw content (markdown or other supported format) into a layer, returning the count of facts imported.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "format": { + "type": "string", + "description": "Defaults to \"markdown\" if omitted" + }, + "layer": { + "type": "string", + "description": "Defaults to \"project\" if omitted" + } + }, + "required": [ + "content" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Import result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "imported": { + "type": "integer" + }, + "layer": { + "type": "string" + }, + "format": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body, or missing content" + }, + "500": { + "description": "Import failed" + }, + "503": { + "description": "Knowledge not enabled" + } + } + } + }, + "/api/knowledge/obsidian/sync": { + "post": { + "tags": [ + "Knowledge" + ], + "summary": "Sync a note from Obsidian", + "description": "Webhook target for the Obsidian Post Webhook plugin: upserts a note (with flattened frontmatter) as a knowledge fact, creating or updating it by filename-derived slug. Filename must not contain path traversal sequences.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "filepath": { + "type": "string" + }, + "content": { + "type": "string", + "description": "Defaults to the frontmatter title, or \"(no body)\", if empty" + }, + "frontmatter": { + "type": "object", + "description": "Arbitrary frontmatter fields (title, type, layer, confidence, tags, etc.); the plugin's top-level extra fields are folded in here too" + } + }, + "required": [ + "filename" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Sync result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "slug": { + "type": "string" + }, + "action": { + "type": "string", + "description": "\"created\" or \"updated\"" + }, + "fact": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "body": { + "type": "string" + }, + "confidence": { + "type": "number" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "layer": { + "type": "string" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid body, missing filename, or path traversal in filename" + }, + "500": { + "description": "Sync failed" + }, + "503": { + "description": "Server not initialized" + } + } + } + }, + "/api/knowledge/promote": { + "post": { + "tags": [ + "Knowledge" + ], + "summary": "Promote a fact between layers", + "description": "Copies a fact from a lower-precedence layer to a higher-precedence one (e.g. project to org) with provenance metadata. If the source fact lives only in a vault (not a wiki layer), falls back to syncing it into the target layer via the Obsidian sync path. slug, from_layer, and to_layer are required; promoter defaults to \"dashboard\".", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "from_layer": { + "type": "string" + }, + "to_layer": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "promoter": { + "type": "string" + } + }, + "required": [ + "slug", + "from_layer", + "to_layer" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Promotion result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "from_layer": { + "type": "string" + }, + "to_layer": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body, missing required fields, or promotion failed with no vault fallback available" + }, + "500": { + "description": "Vault-fallback sync failed" + }, + "503": { + "description": "Knowledge not enabled" + } + } + } + }, + "/api/hives/{id}": { + "delete": { + "tags": [ + "Hives" + ], + "summary": "Remove a hive from the federation registry", + "description": "Unregisters a peer hive from the discovery list. Destructive administrative action on shared state \u2014 requires the HIVE_DASHBOARD_TOKEN dashboard auth AND the owner role (requireOwnerRole); any other role gets 403. The hive can re-register itself later via POST /api/hives/register.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Hive ID (format hive-{org}-{project_name}, lowercased)" + } + ], + "responses": { + "200": { + "description": "Removed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + } + } + } + } + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Hive not found" + } + } + } + }, + "/api/contribute/activity": { + "get": { + "tags": [ + "Contribute" + ], + "summary": "Recent contributor activity", + "description": "Returns the recent contributor activity feed held by the contribute hub. Public (no auth) \u2014 lives under the /api/contribute prefix which isPublicPath treats as public, read-only, no side effects. Returns an empty activity list if the contribute hub is not initialized.", + "responses": { + "200": { + "description": "Recent activity", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "activity": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Recent ActivityEntry records (untyped here \u2014 see contribute_sse.go ActivityEntry for the full shape)" + } + } + } + } + } + } + } + } + }, + "/api/contribute/dossier": { + "get": { + "tags": [ + "Contribute" + ], + "summary": "Get the caller's own dossier fields", + "description": "Returns the caller's own self-service dossier fields (archetype, specializations, testimony, equipped title, Credly name, emblem seed). Identity is resolved server-side (session / X-Hive-User header / Authorization: Bearer ) via resolveContributeCaller \u2014 never from the request \u2014 so a caller only ever sees their own dossier. Public path, but requires the caller to be identifiable and already have a contributor profile.", + "responses": { + "200": { + "description": "Caller's dossier fields", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "archetype": { + "type": "string" + }, + "specializations": { + "type": "array", + "items": { + "type": "string" + } + }, + "testimony": { + "type": "string" + }, + "equipped_title": { + "type": "string" + }, + "credly_name": { + "type": "string" + }, + "emblem_seed": { + "type": "string" + } + } + } + } + } + }, + "401": { + "description": "Not signed in (identity could not be resolved)" + }, + "403": { + "description": "No contributor profile exists yet for the caller" + } + } + }, + "post": { + "tags": [ + "Contribute" + ], + "summary": "Update the caller's own dossier fields", + "description": "Partially updates the caller's self-service dossier fields (archetype, specializations, testimony, equipped title, Credly name, emblem seed). Every field is a pointer: an absent field leaves the stored value untouched, an explicit empty value clears it. All values are sanitized/bounded server-side (never rejected for length \u2014 truncated instead). Identity resolved server-side via resolveContributeCaller, never from the body. Same handler function as GET /api/contribute/dossier, dispatching on HTTP method.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "archetype": { + "type": "string", + "nullable": true + }, + "specializations": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "testimony": { + "type": "string", + "nullable": true + }, + "equipped_title": { + "type": "string", + "nullable": true + }, + "credly_name": { + "type": "string", + "nullable": true + }, + "emblem_seed": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated dossier fields", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "archetype": { + "type": "string" + }, + "specializations": { + "type": "array", + "items": { + "type": "string" + } + }, + "testimony": { + "type": "string" + }, + "equipped_title": { + "type": "string" + }, + "credly_name": { + "type": "string" + }, + "emblem_seed": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid JSON body" + }, + "401": { + "description": "Not signed in (identity could not be resolved)" + }, + "403": { + "description": "No contributor profile exists yet for the caller" + }, + "500": { + "description": "Failed to persist the dossier" + } + } + } + }, + "/api/contribute/events": { + "get": { + "tags": [ + "Contribute" + ], + "summary": "Live contributor activity event stream (SSE)", + "description": "Server-Sent Events stream (Content-Type: text/event-stream), NOT a single JSON response. Public, read-only \u2014 anonymous browsers may subscribe. On connect it sends one 'hello' frame carrying a bounded replay of recent activity plus a ready-work queue snapshot (and, when the convergence-diagnostics shadow-mode toggle is on, additive 'withheld'/'admission_coverage' fields), then forwards each subsequent activity event as an 'activity' frame. An idle connection receives a ': ping' comment roughly every 25s to keep intermediaries from timing out the stream. Each event line is JSON-encoded per the sseEvent shape ({type, activity?, replay?, queue?, withheld?, admission_coverage?}).", + "responses": { + "200": { + "description": "text/event-stream of sseEvent frames (hello, then activity, with periodic heartbeat comments)", + "content": { + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Streaming unsupported by the response writer" + }, + "503": { + "description": "Event stream unavailable (contribute hub not initialized)" + } + } + } + }, + "/api/contribute/fleet": { + "get": { + "tags": [ + "Contribute" + ], + "summary": "Fleet, work, and admission-policy snapshot", + "description": "Read-only snapshot for the Management & Operations tab: connected clankers, in-flight work items, the hub's configured admission policy, plus cooldown/in-flight/held issue counts. Public, GET only, no side effects.", + "responses": { + "200": { + "description": "Fleet snapshot", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "clankers": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Connected FleetClanker entries" + }, + "work": { + "type": "array", + "items": { + "type": "object" + }, + "description": "In-flight FleetWorkItem entries" + }, + "policy": { + "type": "object", + "description": "ContributeAdmissionPolicy \u2014 the configured merge/automation posture and admission filters", + "properties": { + "suspended": { + "type": "boolean" + }, + "titles_mode": { + "type": "string" + }, + "authors_mode": { + "type": "string" + }, + "labels_mode": { + "type": "string" + }, + "deny_titles": { + "type": "array", + "items": { + "type": "string" + } + }, + "deny_authors": { + "type": "array", + "items": { + "type": "string" + } + }, + "deny_labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "allow_labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "allow_models": { + "type": "array", + "items": { + "type": "string" + } + }, + "reject_unknown_models": { + "type": "boolean" + }, + "skip_assigned_to_others": { + "type": "boolean" + }, + "disabled_tiers": { + "type": "array", + "items": { + "type": "string" + } + }, + "disabled_repos": { + "type": "array", + "items": { + "type": "string" + } + }, + "agent_role_grantable_roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "agent_role_assignable_roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "auto_promote_at": { + "type": "integer" + }, + "trusted_at": { + "type": "integer" + } + } + }, + "cooldown_count": { + "type": "integer" + }, + "in_flight_count": { + "type": "integer" + }, + "held_count": { + "type": "integer" + } + } + } + } + } + } + } + } + }, + "/api/contribute/interests": { + "get": { + "tags": [ + "Contribute" + ], + "summary": "Get the caller's own label interests", + "description": "Returns the caller's own opt-in list of GitHub labels used to prioritise matching issues for them. Self-service \u2014 identity resolved server-side via resolveContributeCaller, never from the request; a caller only ever reads their own interests.", + "responses": { + "200": { + "description": "Caller's label interests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "interests": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "401": { + "description": "Not signed in (identity could not be resolved)" + }, + "403": { + "description": "No contributor profile exists yet for the caller" + } + } + }, + "put": { + "tags": [ + "Contribute" + ], + "summary": "Replace the caller's own label interests", + "description": "Replaces the caller's opt-in list of GitHub labels with the submitted set. Entries are trimmed, lower-cased, de-duplicated, capped at 64 entries of up to 128 characters each; a hostile payload is cleaned rather than rejected. Self-service \u2014 identity resolved server-side via resolveContributeCaller, never from the body \u2014 so a caller only ever writes their own interests. Deliberately does NOT require write-tier (any registered contributor may set this preference).", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "interests": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated interests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "interests": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid JSON body" + }, + "401": { + "description": "Not signed in (identity could not be resolved)" + }, + "403": { + "description": "No contributor profile exists yet for the caller" + }, + "500": { + "description": "Failed to persist the interests" + } + } + } + }, + "/api/contribute/limits": { + "get": { + "tags": [ + "Contribute" + ], + "summary": "Per-tier rate limits and the caller's own usage", + "description": "Returns the hive's configured per-tier managed-queue rate limits (from Config.Hub.TierLimits), plus a 'you' block with the caller's own current hour/day usage when their identity can be resolved server-side (session / X-Hive-User header). Public, GET only. An anonymous caller gets the tier table with no 'you' block.", + "responses": { + "200": { + "description": "Tier limits and optional caller usage", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tiers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tier": { + "type": "string" + }, + "max_per_hour": { + "type": "integer" + }, + "max_per_day": { + "type": "integer" + }, + "max_concurrent": { + "type": "integer" + } + } + } + }, + "you": { + "type": "object", + "description": "Present only when the caller's identity is resolvable", + "properties": { + "username": { + "type": "string" + }, + "tier": { + "type": "string" + }, + "used_hour": { + "type": "integer" + }, + "used_day": { + "type": "integer" + }, + "max_per_hour": { + "type": "integer" + }, + "max_per_day": { + "type": "integer" + }, + "max_concurrent": { + "type": "integer" + } + } + } + } + } + } + } + } + } + } + }, + "/api/contribute/metrics": { + "get": { + "tags": [ + "Contribute" + ], + "summary": "Persisted hourly metrics series", + "description": "Returns up to 168 hourly buckets (7 days) of persisted series feeding the Operations/Leaderboard sparklines: queue depth, task completions, fleet size, and per-contributor completions. Public, GET only, no side effects \u2014 only counts and already-public github_usernames, no tokens or PII.", + "responses": { + "200": { + "description": "Hourly metrics series", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "queue_depth": { + "type": "array", + "items": { + "type": "integer" + } + }, + "tasks_done": { + "type": "array", + "items": { + "type": "integer" + } + }, + "fleet_size": { + "type": "array", + "items": { + "type": "integer" + } + }, + "per_user_done": { + "type": "object", + "description": "Map of github_username -> array of per-hour completion counts", + "additionalProperties": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "bucket": { + "type": "string", + "description": "Always \"hour\"" + }, + "collected_at": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/api/contribute/opportunistic": { + "get": { + "tags": [ + "Contribute" + ], + "summary": "Opportunistic work list", + "description": "Returns a small, curated set of admissible issues not already at the front of the ready queue, ranked by a light recency heat proxy. Public, GET only, read-only discovery list.", + "responses": { + "200": { + "description": "Opportunistic work items", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "opportunistic": { + "type": "array", + "items": { + "type": "object" + }, + "description": "OpportunisticItem entries" + } + } + } + } + } + } + } + } + }, + "/api/contribute/queue": { + "get": { + "tags": [ + "Contribute" + ], + "summary": "Ready-work queue snapshot", + "description": "Returns the admissible issues waiting to be picked off (the same set selectTask offers from), used both as a JSON fallback for browsers without EventSource and as the same payload the SSE 'hello' frame carries. Public, GET only. When the caller's identity is resolvable and they have declared label interests, the response is personalised (matching issues floated to the front, plus an echoed 'interests' field) without filtering anything out for other viewers.", + "responses": { + "200": { + "description": "Ready queue", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "queue": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repo": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + } + }, + "withheld": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Present only when the convergence-diagnostics shadow-mode toggle is enabled" + }, + "admission_coverage": { + "type": "object", + "description": "Present only when the convergence-diagnostics shadow-mode toggle is enabled" + }, + "interests": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Present only when the caller's identity is resolvable" + } + } + } + } + } + } + } + } + }, + "/api/contribute/triage": { + "get": { + "tags": [ + "Contribute" + ], + "summary": "Triage lifecycle ladder", + "description": "Returns a live-derived, Warp-style grouping of contribute issues into a lifecycle ladder (Triaging -> Ready to implement -> Implementing -> Reviewing -> Closed), computed per request from the ready queue, fleet snapshot, and PR-issue link resolver. No persistent lifecycle store. Public, GET only, read-only.", + "responses": { + "200": { + "description": "Triage snapshot", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "triaging", + "ready", + "implementing", + "reviewing", + "closed" + ] + }, + "label": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repo": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "level": { + "type": "string" + }, + "pr": { + "type": "object", + "nullable": true, + "properties": { + "number": { + "type": "integer" + }, + "url": { + "type": "string" + }, + "state": { + "type": "string", + "description": "open or merged" + } + } + } + } + } + } + } + } + }, + "total": { + "type": "integer" + } + } + } + } + } + } + } + } + }, + "/api/leaderboard": { + "get": { + "tags": [ + "Contribute" + ], + "summary": "Public leaderboard", + "description": "Returns the ranked contributor leaderboard (task counts, trust tier) plus agent leaderboard entries. Public, GET only, read-only.", + "responses": { + "200": { + "description": "Leaderboard", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "leaderboard": { + "type": "array", + "items": { + "type": "object", + "properties": { + "rank": { + "type": "integer" + }, + "github_username": { + "type": "string" + }, + "avatar_url": { + "type": "string" + }, + "trust_tier": { + "type": "string" + }, + "tasks_completed": { + "type": "integer" + }, + "tasks_failed": { + "type": "integer" + }, + "findings": { + "type": "integer" + }, + "registered_at": { + "type": "string" + }, + "equipped_title": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "current_task": { + "type": "string" + }, + "is_agent": { + "type": "boolean" + }, + "emoji": { + "type": "string" + } + } + } + }, + "agents": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Agent leaderboard entries, same LeaderboardEntry shape with is_agent=true" + } + } + } + } + } + } + } + } + }, + "/api/leaderboard/contributor/{username}": { + "get": { + "tags": [ + "Contribute" + ], + "summary": "Central cross-hive contributor profile (\"Me\" card)", + "description": "Returns one contributor's central profile aggregated from the contributor store, the ranked leaderboard, and the federation registry: identity, trust tier, task stats, rank, milestones, related hives, collaborators, and self-service dossier fields. Public, GET only, read-only \u2014 every field here is already visible elsewhere on the public leaderboard/dossier surfaces. Never errors for an unknown username; instead returns found:false.", + "parameters": [ + { + "name": "username", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "GitHub username (alphanumerics and hyphens, max 39 chars)" + } + ], + "responses": { + "200": { + "description": "Contributor profile (found:false with only github_username set when no profile exists)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "found": { + "type": "boolean" + }, + "github_username": { + "type": "string" + }, + "avatar_url": { + "type": "string" + }, + "trust_tier": { + "type": "string" + }, + "tasks_completed": { + "type": "integer" + }, + "tasks_with_pr": { + "type": "integer" + }, + "tasks_failed": { + "type": "integer" + }, + "rank": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "milestones": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "attained": { + "type": "boolean" + }, + "value": { + "type": "integer" + }, + "icon": { + "type": "string" + } + } + } + }, + "next_milestone": { + "type": "object", + "nullable": true + }, + "hives": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "project_name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "relationship": { + "type": "string", + "enum": [ + "contributor", + "owner", + "member" + ] + } + } + } + }, + "collaborators": { + "type": "array", + "items": { + "type": "object" + } + }, + "registered_at": { + "type": "string" + }, + "founding_position": { + "type": "integer" + }, + "service_years": { + "type": "integer", + "nullable": true + }, + "renown": { + "type": "integer", + "nullable": true + }, + "cli_backend": { + "type": "string" + }, + "model": { + "type": "string" + }, + "invited_by": { + "type": "string" + }, + "sessions": { + "type": "integer" + }, + "current_task": { + "type": "object", + "nullable": true, + "properties": { + "title": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "repo": { + "type": "string" + } + } + }, + "last_completed_task": { + "type": "object", + "nullable": true + }, + "last_active": { + "type": "string", + "description": "Included only when within the last 14 days" + }, + "archetype": { + "type": "string" + }, + "specializations": { + "type": "array", + "items": { + "type": "string" + } + }, + "testimony": { + "type": "string" + }, + "equipped_title": { + "type": "string" + }, + "credly_name": { + "type": "string" + }, + "emblem_seed": { + "type": "string" + }, + "scope": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid username" + } + } + } + }, + "/api/leaderboard/contributor/{username}/heraldry": { + "get": { + "tags": [ + "Contribute" + ], + "summary": "Contributor's public Credly badges", + "description": "Returns the trimmed public Credly badges for one contributor, mirrored through a 6h server-side cache (5min negative cache on fetch failure). Public, GET only, read-only \u2014 Credly badges are already public at credly.com. A contributor with no linked Credly name (or a failed fetch) returns an empty, unlinked response.", + "parameters": [ + { + "name": "username", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "GitHub username (alphanumerics and hyphens, max 39 chars)" + } + ], + "responses": { + "200": { + "description": "Heraldry badges", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "badges": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "issuer_summary": { + "type": "string" + }, + "issued_at": { + "type": "string" + }, + "image_url": { + "type": "string" + }, + "public_url": { + "type": "string" + } + } + } + }, + "linked": { + "type": "boolean" + }, + "credly_name": { + "type": "string", + "description": "Present only when linked=true" + } + } + } + } + } + }, + "400": { + "description": "Invalid username" + } + } + } + }, + "/api/leaderboard/style": { + "get": { + "tags": [ + "Contribute" + ], + "summary": "Sanitized custom leaderboard CSS", + "description": "Fetches, sanitizes, and returns a caller-supplied stylesheet (?src=... \u2014 a raw.githubusercontent.com or same-origin/data-URL-excluded source, scope forced to \"leaderboard\") for the leaderboard's optional custom-style feature. Public, GET only. Response is normally raw CSS (text/css) with a Cache-Control of 5 minutes and an X-Hive-Style-Dropped header when unsafe rules were stripped; pass ?report=1 to get a JSON diagnostic report instead of raw CSS. Returns 404 if the source cannot be found, 422 on any other validation/sanitization failure.", + "parameters": [ + { + "name": "src", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "The style source identifier/URL to fetch and sanitize" + }, + { + "name": "report", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Set to \"1\" to receive a JSON sanitize report instead of raw CSS" + } + ], + "responses": { + "200": { + "description": "Sanitized CSS (text/css) or, with ?report=1, a JSON report", + "content": { + "text/css": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "css": { + "type": "string" + }, + "report": { + "type": "object", + "properties": { + "dropped": { + "type": "integer" + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Style source not found" + }, + "422": { + "description": "Source or sanitization validation failed" + } + } + } + }, + "/api/contribute/invite": { + "post": { + "tags": [ + "Contribute" + ], + "summary": "Mint a trusted, attributed invite link", + "description": "Mints a shareable /contribute onboarding link carrying a signed invite token that attributes whoever registers via it to the caller. Requires the caller be signed in AND hold trusted/merger/advisor trust tier (checked in-handler, since /api/contribute is exempt from the dashboard's read-only role middleware) \u2014 an anonymous or insufficiently-trusted caller gets 401/403. The invite never elevates the invitee's tier; they still join as newcomer.", + "responses": { + "200": { + "description": "Minted invite", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "invite_url": { + "type": "string" + }, + "invite": { + "type": "string", + "description": "The raw invite token" + }, + "inviter": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "description": "Seconds until the token expires" + } + } + } + } + } + }, + "401": { + "description": "Sign in with GitHub to invite someone" + }, + "403": { + "description": "No contributor profile, or trust tier below trusted/merger/advisor" + } + } + } + }, + "/api/contribute/queue/hold": { + "post": { + "tags": [ + "Contribute" + ], + "summary": "Hold or resume one queue issue", + "description": "Toggles the operator hold on one ready-work issue, parking it out of the offer pool until resumed (distinct from the self-clearing time-based cooldown). Owner/read-write only, enforced in-handler via requireContributorWrite (read/anonymous caller gets 403). Persists into Config.Hub.ContributeQueueHold via refreshAndPersist.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Canonical \"owner/repo#number\" issue key" + }, + "held": { + "type": "boolean" + }, + "reason": { + "type": "string", + "description": "Optional short note explaining why the issue is parked; ignored when held=false" + } + }, + "required": [ + "key", + "held" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Updated hold state", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "held": { + "type": "boolean" + }, + "hold": { + "type": "array", + "items": { + "type": "string" + } + }, + "reason": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid request body or invalid/malformed issue key, or hold set at capacity" + }, + "403": { + "description": "Read-only caller (owner/read-write role required)" + } + } + } + }, + "/api/contribute/queue/hold/clear": { + "post": { + "tags": [ + "Contribute" + ], + "summary": "Resume all held queue issues", + "description": "Bulk companion to POST /api/contribute/queue/hold: drops the entire operator hold set (and its reason map) in one call. Same owner/read-write gate (requireContributorWrite) and persistence path. Idempotent.", + "responses": { + "200": { + "description": "Cleared", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "cleared": { + "type": "integer", + "description": "Number of held issues that were cleared" + }, + "hold": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Always empty after clearing" + } + } + } + } + } + }, + "403": { + "description": "Read-only caller (owner/read-write role required)" + } + } + } + }, + "/api/contribute/register": { + "post": { + "tags": [ + "Contribute" + ], + "summary": "Register a new contributor", + "description": "Self-service, unauthenticated registration: creates a new contributor profile for the given GitHub username and returns a one-time plaintext registration token (never recoverable \u2014 only its hash is stored). If the username is already registered, returns its contributor_id and a message pointing to POST /api/contribute/reissue-token instead of reissuing a token here (SECURITY: reissuing here would let anyone take over a known username's token). An optional invite token attributes (but never elevates) the new contributor to their inviter.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "github_username": { + "type": "string" + }, + "force": { + "type": "boolean", + "description": "Legacy flag, now ignored for security reasons" + }, + "invite": { + "type": "string", + "description": "Optional trusted invite token" + } + }, + "required": [ + "github_username" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Registered, or already-registered notice", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "contributor_id": { + "type": "string" + }, + "registration_token": { + "type": "string", + "description": "Present only on first registration \u2014 save it, it cannot be recovered" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid or missing github_username" + }, + "403": { + "description": "Account is revoked" + }, + "503": { + "description": "Contributor registration is full (max 500)" + } + } + } + }, + "/api/contribute/reissue-token": { + "post": { + "tags": [ + "Contribute" + ], + "summary": "Reissue a contributor's registration token", + "description": "Lets a contributor recover access by proving ownership of their GitHub identity via Authorization: Bearer . Invalidates the previous token and returns a new one.", + "responses": { + "200": { + "description": "Reissued", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "contributor_id": { + "type": "string" + }, + "registration_token": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "401": { + "description": "Invalid or missing GitHub token" + }, + "403": { + "description": "Account is revoked" + }, + "404": { + "description": "Not registered as a contributor" + } + } + } + }, + "/api/contributors/{id}/requeue": { + "post": { + "tags": [ + "Contributors" + ], + "summary": "Yank a contributor's in-flight task and reassign", + "description": "Operator action: releases a clanker's current in-flight task (with the same release+cooldown machinery as automatic disconnect/abandon handling) and immediately attempts to hand it its next-priority item so it keeps working. Requires the HIVE_DASHBOARD_TOKEN dashboard auth AND owner/read-write role, enforced in-handler via requireContributorWrite (read/anonymous caller gets 403). An optional reason may be supplied via query param or JSON body.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Contributor ID or GitHub username" + }, + { + "name": "reason", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Optional operator reason (alternatively supplied in the JSON body)" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reason": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Task released (and possibly reassigned)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "released": { + "type": "integer", + "description": "Number of sessions released" + }, + "reassigned": { + "type": "boolean" + }, + "assigned_repo": { + "type": "string" + }, + "assigned_number": { + "type": "integer" + }, + "assigned_title": { + "type": "string" + } + } + } + } + } + }, + "403": { + "description": "Read-only caller (owner/read-write role required)" + }, + "404": { + "description": "Contributor not found, or has no in-flight task to yank" + }, + "503": { + "description": "Contributor relay (contribute hub) not available" + } + } + } + }, + "/api/contributors/{id}/revoke": { + "post": { + "tags": [ + "Contributors" + ], + "summary": "Revoke a contributor's access", + "description": "Sets the contributor's trust tier to \"revoked\" (a terminal, admin-authoritative state via a CAS write) and disconnects any live WebSocket sessions they hold. Requires the HIVE_DASHBOARD_TOKEN dashboard auth AND the owner role (requireOwnerRole); any other role gets 403.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Contributor ID or GitHub username" + } + ], + "responses": { + "200": { + "description": "Revoked", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + } + } + } + } + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Contributor not found" + }, + "500": { + "description": "Failed to save" + } + } + } + }, + "/api/hives/onboard": { + "post": { + "tags": [ + "Hives" + ], + "summary": "Get onboarding next-steps for a new hive", + "description": "Validates the given project_name/org/repos and returns a fixed list of manual next-step instructions for installing the Hive GitHub App and registering the hive. Does not create or persist anything itself.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "project_name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "repos": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "project_name", + "org", + "repos" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Onboarding steps", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "next_steps": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "description": "Missing project_name, org, or repos[]" + } + } + } + }, + "/api/hives/register": { + "post": { + "tags": [ + "Hives" + ], + "summary": "Register or update a hive in the federation registry", + "description": "Registers a new peer hive (or updates hub_url/dashboard_url if a hive with the same derived ID already exists). hub_url and dashboard_url must use http(s)/ws(s) scheme and must not resolve to a private/internal address (SSRF guard).", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "project_name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "hub_url": { + "type": "string" + }, + "dashboard_url": { + "type": "string" + } + }, + "required": [ + "project_name", + "org", + "hub_url" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Registered or updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "updated": { + "type": "boolean", + "description": "True when an existing entry with the same ID was updated instead of created" + } + } + } + } + } + }, + "400": { + "description": "Missing required fields, invalid URL scheme, or a URL targets a private/internal address" + }, + "503": { + "description": "Federation registry full (max 100 hives)" + } + } + } + }, + "/api/hives/{id}/heartbeat": { + "post": { + "tags": [ + "Hives" + ], + "summary": "Report a hive's live counts", + "description": "Updates a registered hive's active_contributors, active_contributor_names, active_agents, and actionable_items counts, and bumps last_heartbeat to now. All fields are optional in the body and individually bounds-checked/sanitized; an invalid field is simply left unchanged rather than rejecting the whole request.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Hive ID" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "active_contributors": { + "type": "integer" + }, + "active_contributor_names": { + "type": "array", + "items": { + "type": "string" + } + }, + "active_agents": { + "type": "integer" + }, + "actionable_items": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Heartbeat recorded", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + } + } + } + } + }, + "404": { + "description": "Hive not found" + } + } + } + }, + "/api/contribute/queue/order": { + "put": { + "tags": [ + "Contribute" + ], + "summary": "Set the operator priority override for the ready queue", + "description": "Persists an ordered list of \"owner/repo#number\" keys as the operator's priority override for the ready-work queue's offer order. Owner/read-write only, enforced in-handler via requireContributorWrite (read/anonymous caller gets 403). Malformed or duplicate keys are dropped rather than rejected. The override only changes offer priority \u2014 it is applied AFTER admission/cooldown/disabled/in-flight exclusions, so a pinned-but-filtered issue is never resurrected. Persists into Config.Hub.ContributeQueueOrder via refreshAndPersist.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "order": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Ordered \"owner/repo#number\" keys, capped at 512" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated order", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "order": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid request body, or too many queue-order keys" + }, + "403": { + "description": "Read-only caller (owner/read-write role required)" + } + } + } + }, + "/api/contributors/{id}/agent-role": { + "put": { + "tags": [ + "Contributors" + ], + "summary": "Assign a contributor's agent role", + "description": "Assigns (or clears, with agent_role=\"none\") a contributor's assigned agent role. Owner-only, enforced in-handler via requireOwnerRole (issue #3011 \u2014 previously under-gated to read-write). The requested role must be on the server-side assignable allowlist; a role requiring an operator grant (roleClaimNeedsGrant) is refused unless the contributor already holds that grant (grants are issued separately via PUT .../agent-role-grants and are never auto-granted here).", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Contributor ID or GitHub username" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "agent_role": { + "type": "string" + }, + "role": { + "type": "string", + "description": "Fallback field name if agent_role is empty" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated role", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "assigned_agent_role": { + "type": "string" + }, + "effective_role": { + "type": "string" + }, + "agent_role_grants": { + "type": "array", + "items": { + "type": "string" + } + }, + "assignable_roles": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid request body, role not assignable, role claim disallowed, or role requires an ungranted operator grant" + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Contributor not found" + }, + "500": { + "description": "Failed to save" + } + } + } + }, + "/api/contributors/{id}/agent-role-grants": { + "put": { + "tags": [ + "Contributors" + ], + "summary": "Set a contributor's grantable agent-role delegations", + "description": "Sets the set of privileged agent roles the contributor has been operator-granted (a prerequisite for later being assigned one of those roles via PUT .../agent-role). Owner-only, enforced in-handler via requireOwnerRole. Each submitted role must be on the server-side grantable allowlist or the whole request is rejected with 400.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Contributor ID or GitHub username" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "agent_role_grants": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated grants", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "agent_role_grants": { + "type": "array", + "items": { + "type": "string" + } + }, + "grantable_roles": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid request body, or a submitted role is not a grantable delegated privileged role" + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Contributor not found" + }, + "500": { + "description": "Failed to save" + } + } + } + }, + "/api/contributors/{id}/trust": { + "put": { + "tags": [ + "Contributors" + ], + "summary": "Set a contributor's trust tier", + "description": "Sets the contributor's trust tier directly (admin override \u2014 able to change a terminal \"revoked\" tier, via a CAS write that wins against any concurrent stale WebSocket save). Owner-only, enforced in-handler via requireOwnerRole. If the new tier is \"revoked\", any live WebSocket sessions the contributor holds are disconnected.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Contributor ID or GitHub username" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tier": { + "type": "string", + "enum": [ + "newcomer", + "contributor", + "trusted", + "merger", + "advisor", + "revoked" + ] + } + }, + "required": [ + "tier" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Updated tier", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "trust_tier": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid request body or invalid tier" + }, + "403": { + "description": "Owner role required" + }, + "404": { + "description": "Contributor not found" + }, + "500": { + "description": "Failed to save" + } + } + } + }, + "/api/nous/principles/{id}": { + "delete": { + "tags": [ + "Strategy Lab" + ], + "summary": "Delete a Nous principle", + "description": "Removes an accumulated principle by id from the Nous knowledge base. Requires owner role.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Principle id" + } + ], + "responses": { + "200": { + "description": "Deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "deleted" + }, + "id": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Nous not configured" + } + } + } + }, + "/api/nous/config": { + "get": { + "tags": [ + "Strategy Lab" + ], + "summary": "Get Nous configuration", + "description": "Returns the raw Nous config map (sections such as goals, repos, output, fast_fail, schedule, controllables, principles). Shape is dynamic/section-keyed and not owner-gated for reads.", + "responses": { + "200": { + "description": "Nous config", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Map of config section name to its stored value; empty object when Nous is not configured." + } + } + } + } + } + } + }, + "/api/nous/abort": { + "post": { + "tags": [ + "Strategy Lab" + ], + "summary": "Abort the active Nous experiment", + "description": "Marks the current experiment as aborted. Requires owner role.", + "responses": { + "200": { + "description": "Aborted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "aborted" + } + } + } + } + } + } + } + } + }, + "/api/nous/approve": { + "post": { + "tags": [ + "Strategy Lab" + ], + "summary": "Approve the pending Nous proposal", + "description": "Approves the current Nous proposal/experiment. Requires owner role.", + "responses": { + "200": { + "description": "Approved", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "approved" + } + } + } + } + } + } + } + } + }, + "/api/nous/gate-respond": { + "post": { + "tags": [ + "Strategy Lab" + ], + "summary": "Respond to a Nous gate prompt", + "description": "Records a free-form response to the currently pending Nous gate (approval checkpoint). Requires owner role. Request body is stored verbatim as the gate response.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Arbitrary JSON object; stored as-is as the gate response." + } + } + } + }, + "responses": { + "200": { + "description": "Recorded", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "responded" + } + } + } + } + } + } + } + } + }, + "/api/nous/config/controllables": { + "put": { + "tags": [ + "Strategy Lab" + ], + "summary": "Update Nous controllables config", + "description": "Replaces the 'controllables' section of the Nous config with the request body. Requires owner role. Body shape is caller-defined (stored as an opaque JSON value under this section).", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "section": { + "type": "string", + "example": "controllables" + } + } + } + } + } + }, + "404": { + "description": "Nous not configured" + } + } + } + }, + "/api/nous/config/fast-fail": { + "put": { + "tags": [ + "Strategy Lab" + ], + "summary": "Update Nous fast-fail config", + "description": "Replaces the 'fast_fail' section of the Nous config with the request body. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "section": { + "type": "string", + "example": "fast_fail" + } + } + } + } + } + }, + "404": { + "description": "Nous not configured" + } + } + } + }, + "/api/nous/config/goals": { + "put": { + "tags": [ + "Strategy Lab" + ], + "summary": "Update Nous goals config", + "description": "Replaces the 'goals' section of the Nous config with the request body. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "section": { + "type": "string", + "example": "goals" + } + } + } + } + } + }, + "404": { + "description": "Nous not configured" + } + } + } + }, + "/api/nous/config/output": { + "put": { + "tags": [ + "Strategy Lab" + ], + "summary": "Update Nous output config", + "description": "Replaces the 'output' section of the Nous config with the request body. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "section": { + "type": "string", + "example": "output" + } + } + } + } + } + }, + "404": { + "description": "Nous not configured" + } + } + } + }, + "/api/nous/config/principles": { + "put": { + "tags": [ + "Strategy Lab" + ], + "summary": "Update Nous principles config", + "description": "Replaces the 'principles' section of the Nous config with the request body. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "section": { + "type": "string", + "example": "principles" + } + } + } + } + } + }, + "404": { + "description": "Nous not configured" + } + } + } + }, + "/api/nous/config/repos": { + "put": { + "tags": [ + "Strategy Lab" + ], + "summary": "Update Nous repos config", + "description": "Replaces the 'repos' section of the Nous config with the request body. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "section": { + "type": "string", + "example": "repos" + } + } + } + } + } + }, + "404": { + "description": "Nous not configured" + } + } + } + }, + "/api/nous/config/schedule": { + "put": { + "tags": [ + "Strategy Lab" + ], + "summary": "Update Nous schedule config", + "description": "Replaces the 'schedule' section of the Nous config with the request body. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "section": { + "type": "string", + "example": "schedule" + } + } + } + } + } + }, + "404": { + "description": "Nous not configured" + } + } + } + }, + "/api/nous/gate-decision": { + "put": { + "tags": [ + "Strategy Lab" + ], + "summary": "Decide a pending Nous gate", + "description": "Records a decision (e.g. approve/reject) plus optional reason for the currently pending Nous gate. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "decision": { + "type": "string", + "description": "Required." + }, + "reason": { + "type": "string" + } + }, + "required": [ + "decision" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Decision recorded", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "decided" + }, + "decision": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "decision is required" + }, + "404": { + "description": "Nous not configured" + } + } + } + }, + "/api/nous/mode": { + "put": { + "tags": [ + "Strategy Lab" + ], + "summary": "Set Nous mode", + "description": "Sets the Nous engine mode (e.g. observe/suggest/auto). Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "mode": { + "type": "string" + } + }, + "required": [ + "mode" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "mode": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "mode is required" + } + } + } + }, + "/api/nous/scope": { + "put": { + "tags": [ + "Strategy Lab" + ], + "summary": "Set Nous scope", + "description": "Sets the Nous engine scope (e.g. governor/repo). Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "scope": { + "type": "string" + } + }, + "required": [ + "scope" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "scope": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "scope is required" + } + } + } + }, + "/api/inception/download": { + "get": { + "tags": [ + "Inception" + ], + "summary": "Download the produced scaffold as a zip", + "description": "Produces the current inception scaffold and streams it as a zip file attachment (application/zip). No JSON body on success.", + "responses": { + "200": { + "description": "Zip archive of scaffold files", + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Inception engine not initialized, no scaffold, or no files to download" + } + } + } + }, + "/api/inception/has-files": { + "get": { + "tags": [ + "Inception" + ], + "summary": "Check whether the inception wiki has files", + "description": "Reports whether the inception knowledge wiki currently has any files on disk.", + "responses": { + "200": { + "description": "Has-files flag", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "has_files": { + "type": "boolean" + } + } + } + } + } + } + } + } + }, + "/api/inception/ideation-facts": { + "get": { + "tags": [ + "Inception" + ], + "summary": "List ideation facts", + "description": "Returns the facts gathered so far during ideation, preferring the knowledge store's ideation facts and falling back to the inception engine's in-memory gathered facts.", + "responses": { + "200": { + "description": "Ideation facts", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "facts": { + "type": "array", + "items": { + "type": "object" + }, + "description": "knowledge.Fact entries" + } + } + } + } + } + }, + "503": { + "description": "Knowledge API not initialized" + } + } + } + }, + "/api/inception/scaffold": { + "get": { + "tags": [ + "Inception" + ], + "summary": "Produce (or fetch) the project scaffold", + "description": "Produces the scaffold result from the current inception state (file list/content). Not owner-gated.", + "responses": { + "200": { + "description": "Scaffold result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "scaffold": { + "type": "object", + "description": "Scaffold result including a Files list; shape not fully enumerated here \u2014 see inception.ProduceScaffold." + } + } + } + } + } + }, + "404": { + "description": "No scaffold available" + }, + "503": { + "description": "Inception engine not initialized" + } + } + } + }, + "/api/inception/state": { + "get": { + "tags": [ + "Inception" + ], + "summary": "Get current inception state", + "description": "Returns the current inception engine state (idea capture / question / structure phase, etc). State shape is dynamic (inception engine internal state struct).", + "responses": { + "200": { + "description": "Inception state", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "state": { + "type": "object", + "nullable": true, + "description": "Inception engine state; null when no session is active." + }, + "active": { + "type": "boolean" + } + } + } + } + } + }, + "503": { + "description": "Inception engine not initialized" + } + } + } + }, + "/api/inception/answer": { + "post": { + "tags": [ + "Inception" + ], + "summary": "Submit answers to inception questions", + "description": "Submits a map of question-id to answer text, advancing the inception session. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "answers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated state", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "state": { + "type": "object" + } + } + } + } + } + }, + "400": { + "description": "Invalid body or submission rejected by the engine" + }, + "503": { + "description": "Inception engine not initialized" + } + } + } + }, + "/api/inception/approve": { + "post": { + "tags": [ + "Inception" + ], + "summary": "Approve inception and complete it", + "description": "Advances the inception session to complete and re-pauses the brainstorm agent (on-demand only afterward). Requires owner role.", + "responses": { + "200": { + "description": "Approved", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Could not advance to complete" + }, + "503": { + "description": "Inception engine not initialized" + } + } + } + }, + "/api/inception/facts": { + "post": { + "tags": [ + "Inception" + ], + "summary": "Record ideation facts", + "description": "Records a batch of ideation facts against the knowledge base. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "facts": { + "type": "array", + "items": { + "type": "object" + }, + "description": "knowledge.IdeationFact entries" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Recorded", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Invalid body or record failure" + }, + "503": { + "description": "Inception engine not initialized" + } + } + } + }, + "/api/inception/import": { + "post": { + "tags": [ + "Inception" + ], + "summary": "Import a wiki zip into the inception wiki directory", + "description": "Accepts a multipart/form-data upload (field name 'file') of a zip archive (max 10 MiB) and extracts it into the inception wiki directory. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Import result", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Missing file, unreadable upload, or invalid zip" + }, + "500": { + "description": "Failed to create wiki directory" + }, + "503": { + "description": "Inception engine not initialized" + } + } + } + }, + "/api/inception/questions": { + "post": { + "tags": [ + "Inception" + ], + "summary": "Set inception clarifying questions", + "description": "Sets the list of clarifying questions for the active inception session. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "questions": { + "type": "array", + "items": { + "type": "object" + }, + "description": "knowledge.Question entries" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Set", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Invalid body or rejected by engine" + }, + "503": { + "description": "Inception engine not initialized" + } + } + } + }, + "/api/inception/reset": { + "post": { + "tags": [ + "Inception" + ], + "summary": "Reset the inception session", + "description": "Resets the inception engine state, clears any open brainstorm/inception beads, and re-pauses the brainstorm agent. Requires owner role.", + "responses": { + "200": { + "description": "Reset", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + } + } + } + } + }, + "500": { + "description": "Reset failed" + }, + "503": { + "description": "Inception engine not initialized" + } + } + } + }, + "/api/inception/scan": { + "post": { + "tags": [ + "Inception" + ], + "summary": "Start a brownfield repo scan", + "description": "Starts inception in brownfield mode against an existing repo URL. Optional force=true resets any existing session first (pausing brainstorm and clearing its beads). Kicks the brainstorm agent. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "repo_url": { + "type": "string" + }, + "force": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Started", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "state": { + "type": "object" + } + } + } + } + } + }, + "400": { + "description": "Invalid body or scan could not start" + }, + "503": { + "description": "Inception engine not initialized" + } + } + } + }, + "/api/inception/start": { + "post": { + "tags": [ + "Inception" + ], + "summary": "Start a greenfield inception session", + "description": "Starts a new inception session from a free-text idea. Optional force=true resets any existing session first (pausing brainstorm and clearing its beads). Kicks the brainstorm agent. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "idea": { + "type": "string" + }, + "force": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Started", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "state": { + "type": "object" + } + } + } + } + } + }, + "400": { + "description": "Invalid body or start failed" + }, + "503": { + "description": "Inception engine not initialized" + } + } + } + }, + "/api/inception/wiki-name": { + "put": { + "tags": [ + "Inception" + ], + "summary": "Rename the inception wiki", + "description": "Renames the inception knowledge vault (max 80 characters) and persists the name in inception state. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 80 + } + }, + "required": [ + "name" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Renamed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Missing/too-long name" + }, + "404": { + "description": "Inception wiki vault not found" + }, + "503": { + "description": "Knowledge or inception not initialized" + } + } + } + }, + "/api/plan/{epicID}": { + "get": { + "tags": [ + "Plan" + ], + "summary": "Get a plan (decomposed epic) tree", + "description": "Returns the review view of a decomposed epic: the epic plus its children with execution tags and dependency edges. Not owner-gated.", + "parameters": [ + { + "name": "epicID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Plan tree", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "plan": { + "type": "object", + "description": "planning.PlanTree: epic + children + dependency edges" + } + } + } + } + } + }, + "404": { + "description": "Epic not found in any bead store" + } + } + } + }, + "/api/plan/from-issue": { + "post": { + "tags": [ + "Plan" + ], + "summary": "Propose a plan from a GitHub issue", + "description": "Mints a DRAFT epic from an issue (idempotent) and requests decomposition by kicking the architect agent out-of-band (respecting its pause). Deliberately NOT owner-gated \u2014 mints a draft epic whose children are withheld from execution until approved. Rejected with 409 when planning is not allowed at the hive's current ACMM level.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "repo": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "url": { + "type": "string" + }, + "title": { + "type": "string", + "description": "If empty, the server attempts to resolve repo+number/url against the last enumerated actionable issues." + }, + "body": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Plan requested/queued", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "epic_id": { + "type": "string" + }, + "epic": { + "type": "object", + "description": "beads.Bead" + }, + "kicked": { + "type": "boolean" + }, + "state": { + "type": "string", + "description": "planning.DecomposeState" + }, + "architectPaused": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "poll_url": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid body or missing resolvable issue title" + }, + "409": { + "description": "Planning not allowed at the hive's current ACMM level" + }, + "503": { + "description": "Bead stores not initialized" + } + } + } + }, + "/api/plan/{epicID}/approve": { + "post": { + "tags": [ + "Plan" + ], + "summary": "Approve a plan", + "description": "Approves the plan, releasing its children through Ready() for agent execution. Requires owner role.", + "parameters": [ + { + "name": "epicID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Approved", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "status": { + "type": "string", + "example": "approved" + }, + "plan": { + "type": "object" + } + } + } + } + } + }, + "400": { + "description": "Approve failed" + }, + "404": { + "description": "Epic not found in any bead store" + } + } + } + }, + "/api/plan/{epicID}/child/{childID}": { + "post": { + "tags": [ + "Plan" + ], + "summary": "Retag or remove a plan child", + "description": "Edits a child of an unapproved plan: action 'retag' changes its execution tag (e.g. between human_required and agent_suitable); action 'remove' closes the child. Requires owner role.", + "parameters": [ + { + "name": "epicID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "childID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "retag", + "remove" + ] + }, + "execution": { + "type": "string", + "description": "Required when action=retag." + } + }, + "required": [ + "action" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Updated plan", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "plan": { + "type": "object" + } + } + } + } + } + }, + "400": { + "description": "Invalid action/execution or operation failed" + }, + "404": { + "description": "Epic not found in any bead store" + } + } + } + }, + "/api/plan/{epicID}/reject": { + "post": { + "tags": [ + "Plan" + ], + "summary": "Reject a plan", + "description": "Returns an approved (or draft) plan to draft state, re-gating its children. Requires owner role.", + "parameters": [ + { + "name": "epicID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Rejected", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "status": { + "type": "string", + "example": "draft" + }, + "plan": { + "type": "object" + } + } + } + } + } + }, + "400": { + "description": "Reject failed" + }, + "404": { + "description": "Epic not found in any bead store" + } + } + } + }, + "/api/auth/token": { + "get": { + "tags": [ + "System" + ], + "summary": "Check whether a dashboard auth token is configured", + "description": "Reports only whether HIVE_DASHBOARD_TOKEN (or the configured dashboard token) is set \u2014 never the token value. Returns 404 on direct-route or hub-proxied hives, where the shared token is not exposed to browsers.", + "responses": { + "200": { + "description": "Token configuration status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "configured": { + "type": "string", + "enum": [ + "true", + "false" + ], + "description": "Stringified boolean." + } + } + } + } + } + }, + "404": { + "description": "Not available on this hive (direct-route or hub-proxied)" + } + } + } + }, + "/api/claude-auth/status": { + "get": { + "tags": [ + "System" + ], + "summary": "Get Claude Code OAuth login status", + "description": "Reports whether a Claude access token is currently stored.", + "responses": { + "200": { + "description": "Status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "logged_in": { + "type": "boolean" + } + } + } + } + } + } + } + } + }, + "/api/copilot-auth/status": { + "get": { + "tags": [ + "System" + ], + "summary": "Get Copilot device-flow login status", + "description": "Reports whether a Copilot token is stored (or COPILOT_GITHUB_TOKEN env is set) and whether a device-flow poll is still in progress.", + "responses": { + "200": { + "description": "Status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "logged_in": { + "type": "boolean" + }, + "pending": { + "type": "boolean" + }, + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/api/gh-user-auth/session": { + "get": { + "tags": [ + "System" + ], + "summary": "Land on the dashboard after device-flow login", + "description": "Redirects (302) to '/'. The session cookie was already set by the poll endpoint; this never mints a session itself.", + "responses": { + "302": { + "description": "Redirect to dashboard root" + } + } + } + }, + "/api/gh-user-auth/status": { + "get": { + "tags": [ + "System" + ], + "summary": "Get GitHub user auth status", + "description": "Reports the current request's authenticated identity: from the per-user session cookie, the hub-proxy injected X-Hive-User/X-Hive-Role headers, or (on non-direct-route hives with no session) the persisted user token validated against GitHub.", + "responses": { + "200": { + "description": "Status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "logged_in": { + "type": "boolean" + }, + "username": { + "type": "string" + }, + "role": { + "type": "string" + }, + "avatar_url": { + "type": "string" + }, + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/api/claude-auth/exchange": { + "post": { + "tags": [ + "System" + ], + "summary": "Exchange Claude OAuth code for tokens", + "description": "Exchanges an authorization code (or one extracted from a pasted callback URL) plus the server-held PKCE verifier for Claude tokens, persists credentials, and reloads the agent manager's token. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "callback_url": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Complete", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "complete" + } + } + } + } + } + }, + "400": { + "description": "Missing/invalid code, expired login session, or authorization denied" + }, + "500": { + "description": "Token exchange or credential save failed" + } + } + } + }, + "/api/claude-auth/logout": { + "post": { + "tags": [ + "System" + ], + "summary": "Log out of Claude Code", + "description": "Removes stored Claude credentials/token files and reloads the agent manager's token. Requires owner role.", + "responses": { + "200": { + "description": "Logged out", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "logged_out" + } + } + } + } + } + } + } + } + }, + "/api/claude-auth/start": { + "post": { + "tags": [ + "System" + ], + "summary": "Start Claude OAuth login", + "description": "Generates PKCE parameters and returns the Claude authorize URL, storing the verifier/state server-side for the subsequent exchange. Requires owner role.", + "responses": { + "200": { + "description": "Authorize URL", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "PKCE/state generation failed" + } + } + } + }, + "/api/copilot-auth/logout": { + "post": { + "tags": [ + "System" + ], + "summary": "Log out of Copilot", + "description": "Removes the stored Copilot token and clears it on the agent manager. Requires owner role.", + "responses": { + "200": { + "description": "Logged out", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "logged_out" + } + } + } + } + } + } + } + } + }, + "/api/copilot-auth/start": { + "post": { + "tags": [ + "System" + ], + "summary": "Start Copilot device-flow login", + "description": "Begins a GitHub device-flow authorization for Copilot CLI and starts a background poller. Requires owner role.", + "responses": { + "200": { + "description": "Device code issued", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user_code": { + "type": "string" + }, + "verification_uri": { + "type": "string" + }, + "expires_in": { + "type": "integer" + } + } + } + } + } + }, + "500": { + "description": "Failed to build device code request" + }, + "502": { + "description": "Device code request/response from GitHub failed" + } + } + } + }, + "/api/gh-user-auth/logout": { + "post": { + "tags": [ + "System" + ], + "summary": "Log out the current GitHub user session", + "description": "Deletes only the calling request's session. The persisted GitHub user token on disk is removed only when the logging-out user's session role was owner (or auth is not enforced).", + "responses": { + "200": { + "description": "Logged out", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "logged_out" + } + } + } + } + } + } + } + } + }, + "/api/gh-user-auth/poll": { + "post": { + "tags": [ + "System" + ], + "summary": "Poll GitHub device-flow login", + "description": "Polls GitHub for the outcome of a device flow started via /api/gh-user-auth/start. On success validates the GitHub identity, enforces the allowlist on direct-route hives, and mints a per-user session cookie.", + "responses": { + "200": { + "description": "Poll result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "pending", + "slow_down", + "error", + "complete" + ] + }, + "error": { + "type": "string" + }, + "username": { + "type": "string" + }, + "avatar_url": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "No device flow in progress" + } + } + } + }, + "/api/gh-user-auth/start": { + "post": { + "tags": [ + "System" + ], + "summary": "Start GitHub device-flow login", + "description": "Starts a GitHub OAuth device flow using the hive's resolved GitHub OAuth client id and stores the flow state server-side.", + "responses": { + "200": { + "description": "Device flow started", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user_code": { + "type": "string" + }, + "verification_uri": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "interval": { + "type": "integer" + } + } + } + } + } + }, + "500": { + "description": "Failed to start device flow" + } + } + } + }, + "/api/github-app/install-clicked": { + "post": { + "tags": [ + "System" + ], + "summary": "Flag that the GitHub App install link was clicked", + "description": "Sets a pending-install flag so the periodic heartbeat/self-heal loop knows an install attempt is underway. Not owner-gated.", + "responses": { + "200": { + "description": "Pending", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "pending" + } + } + } + } + } + } + } + } + }, + "/api/github-app/recheck": { + "post": { + "tags": [ + "System" + ], + "summary": "Recheck GitHub App installation", + "description": "Re-auto-discovers the GitHub App installation id and rechecks installation/permissions. Not owner-gated. Returns 501 when recheck is not configured.", + "responses": { + "200": { + "description": "Recheck result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "installed", + "insufficient_permissions", + "not_installed" + ] + }, + "detail": { + "type": "string", + "description": "Present only for insufficient_permissions." + } + } + } + } + } + }, + "501": { + "description": "Recheck not configured" + } + } + } + }, + "/api/linear/agent/status": { + "get": { + "tags": [ + "System" + ], + "summary": "Get Linear agent connection status", + "description": "Reports whether Linear credentials are configured, the install/connection state, and tracked agent sessions. Requires owner role.", + "responses": { + "200": { + "description": "Status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "configured": { + "type": "boolean" + }, + "connected": { + "type": "boolean" + }, + "webhook_path": { + "type": "string" + }, + "callback_path": { + "type": "string" + }, + "store_error": { + "type": "string" + }, + "session_agent": { + "type": "string" + }, + "session_agent_error": { + "type": "string" + }, + "viewer_id": { + "type": "string" + }, + "workspace": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url_key": { + "type": "string" + } + } + }, + "connected_at": { + "type": "string" + }, + "sessions": { + "type": "object", + "description": "Tracker snapshot; shape not fully enumerated (linearagent tracker internal)." + } + } + } + } + } + } + } + } + }, + "/api/linear/agent/disconnect": { + "post": { + "tags": [ + "System" + ], + "summary": "Disconnect the Linear agent install", + "description": "Forgets the local install record (does not revoke the grant on Linear's side). Requires owner role.", + "responses": { + "200": { + "description": "Disconnected", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "disconnected" + } + } + } + } + } + }, + "500": { + "description": "Failed to clear install" + }, + "503": { + "description": "Linear install store unavailable" + } + } + } + }, + "/api/linear/agent/install": { + "post": { + "tags": [ + "System" + ], + "summary": "Start Linear agent OAuth install", + "description": "Starts the Linear actor=app authorize flow and returns the authorize URL plus the redirect_uri this hive will use (must match the Linear app's configured callback URL). Requires owner role.", + "responses": { + "200": { + "description": "Authorize URL", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string" + }, + "redirect_uri": { + "type": "string" + } + } + } + } + } + }, + "412": { + "description": "LINEAR_CLIENT_ID / LINEAR_CLIENT_SECRET not set" + }, + "500": { + "description": "Failed to start flow" + }, + "503": { + "description": "Linear install store unreadable" + } + } + } + }, + "/api/linear/webhook": { + "post": { + "tags": [ + "System" + ], + "summary": "Linear AgentSessionEvent webhook receiver", + "description": "Public webhook endpoint for Linear AgentSessionEvent payloads. HMAC verification over the raw body and replay-window checks happen inside the receiver. Response shape is delegated to the linearagent receiver.", + "responses": { + "200": { + "description": "Accepted (shape delegated to linearagent receiver)", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "503": { + "description": "Linear agent unavailable" + } + } + } + }, + "/api/openrouter/connect/start": { + "get": { + "tags": [ + "System" + ], + "summary": "Start an OpenRouter PKCE funding flow", + "description": "Generates a PKCE verifier/challenge and single-use state (optionally scoped to a `model` query param) and returns the openrouter.ai authorize URL. Not owner-gated.", + "parameters": [ + { + "name": "model", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Authorize URL", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string" + }, + "state": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Failed to start/build flow" + } + } + } + }, + "/api/openrouter/credit": { + "get": { + "tags": [ + "System" + ], + "summary": "Get OpenRouter credit/usage", + "description": "Proxies OpenRouter's /v1/key using the stored gateway key. Never returns the key itself. Not owner-gated.", + "responses": { + "200": { + "description": "Credit info", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connected": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "limit": { + "type": "number", + "nullable": true + }, + "limit_remaining": { + "type": "number", + "nullable": true + }, + "usage": { + "type": "number" + }, + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/api/openrouter/models": { + "get": { + "tags": [ + "System" + ], + "summary": "List OpenRouter models", + "description": "Returns the curated suggested model list plus OpenRouter's best-effort live model catalog. No key required (public endpoint).", + "responses": { + "200": { + "description": "Models", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "suggested": { + "type": "array", + "items": { + "type": "string" + } + }, + "models": { + "type": "array", + "items": { + "type": "object" + } + }, + "default": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/api/openrouter/qr": { + "get": { + "tags": [ + "System" + ], + "summary": "Render a QR code for an OpenRouter authorize URL", + "description": "Renders a same-origin PNG QR code for the `data` query param, which must be an OpenRouter authorize URL (validated by prefix) \u2014 used to keep the connect-flow off vendored client JS.", + "parameters": [ + { + "name": "data", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Must start with the OpenRouter authorize URL." + } + ], + "responses": { + "200": { + "description": "PNG image", + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "data must be an OpenRouter authorize URL" + }, + "500": { + "description": "Failed to render QR" + } + } + } + }, + "/api/acmm-recommendation": { + "get": { + "tags": [ + "System" + ], + "summary": "Get advisory ACMM level-up recommendation", + "description": "Returns an advisory (read-only, never mutates the applied level) recommendation on whether to stay or raise the ACMM level, derived from live status signals.", + "responses": { + "200": { + "description": "acmmadvisor.Recommendation", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Advise": { + "type": "string", + "description": "stay or raise" + }, + "CurrentLevel": { + "type": "integer" + }, + "TargetLevel": { + "type": "integer" + }, + "Ready": { + "type": "boolean" + }, + "Met": { + "type": "array", + "items": { + "type": "object" + } + }, + "Unmet": { + "type": "array", + "items": { + "type": "object" + } + }, + "Rationale": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/api/acmm/evaluation": { + "get": { + "tags": [ + "System" + ], + "summary": "Get the cached ACMM evaluation", + "description": "Returns the (hourly-cached) ACMM evaluation across levels/criteria/repos, stamped with the current operational level, overall level, and issue-tracker destination.", + "responses": { + "200": { + "description": "ACMMEvaluation", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "codebase_level": { + "type": "integer" + }, + "codebase_level_name": { + "type": "string" + }, + "operational_level": { + "type": "integer" + }, + "operational_name": { + "type": "string" + }, + "overall_level": { + "type": "integer" + }, + "criteria_total": { + "type": "integer" + }, + "criteria_passed": { + "type": "integer" + }, + "last_evaluated_at": { + "type": "string" + }, + "levels": { + "type": "array", + "items": { + "type": "object" + } + }, + "criteria_results": { + "type": "array", + "items": { + "type": "object" + } + }, + "repo_results": { + "type": "array", + "items": { + "type": "object" + } + }, + "error": { + "type": "string" + }, + "issue_tracker": { + "type": "string" + }, + "work_source_type": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/api/acmm/issue": { + "post": { + "tags": [ + "System" + ], + "summary": "File an issue for a failed ACMM criterion", + "description": "Files a gap issue for a failed ACMM criterion on GitHub (default) or, when configured/overridden, on the mapped Linear team. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "repo": { + "type": "string" + }, + "criterion_id": { + "type": "string" + }, + "criterion_level": { + "type": "integer" + }, + "tracker": { + "type": "string", + "enum": [ + "github", + "work_source" + ], + "description": "Optional per-request override of the configured default." + } + }, + "required": [ + "repo", + "criterion_id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Issue filed", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Missing/unknown fields or invalid tracker" + }, + "500": { + "description": "Config not loaded / org not configured" + } + } + } + }, + "/api/backup/status": { + "get": { + "tags": [ + "System" + ], + "summary": "Check whether self-service backup is available", + "description": "Reports whether a backup-encryption key is configured, so the UI can disable the backup menu entry with a real reason. Requires owner role.", + "responses": { + "200": { + "description": "Availability", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "available": { + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "configPath": { + "type": "string" + }, + "source": { + "type": "string", + "description": "Present when available; safe 'file:' or 'env:' label, never the key." + } + } + } + } + } + }, + "403": { + "description": "Owner access required" + } + } + } + }, + "/api/backup": { + "post": { + "tags": [ + "System" + ], + "summary": "Build and download an encrypted spoke backup", + "description": "Builds an AES-256-GCM encrypted archive of this spoke's data and streams it as an octet-stream attachment. POST (not GET) so the credential-bearing response cannot be reached via a plain link/img tag. Requires owner role. Bounded by a build timeout.", + "responses": { + "200": { + "description": "Encrypted backup archive", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "403": { + "description": "Owner access required" + }, + "412": { + "description": "Backup encryption key not configured" + }, + "500": { + "description": "Backup build failed" + }, + "504": { + "description": "Backup timed out" + } + } + } + }, + "/api/banner-dismissed": { + "post": { + "tags": [ + "System" + ], + "summary": "Dismiss a hub banner", + "description": "Records that the authenticated (session or hub-proxy-injected) user dismissed a banner by id. Read-only viewers are rejected.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Dismissed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Missing banner id" + }, + "403": { + "description": "Not signed in, or read-only role" + } + } + } + }, + "/api/budget/history": { + "get": { + "tags": [ + "System" + ], + "summary": "Get per-budget-window history", + "description": "Returns one row per closed budget window (newest first) plus the currently open window from live status. `windows` is always an array, never null.", + "responses": { + "200": { + "description": "Budget history", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "windows": { + "type": "array", + "items": { + "type": "object" + }, + "description": "BudgetWindowEntry rows" + }, + "current": { + "type": "object", + "properties": { + "limit": { + "type": "number" + }, + "used": { + "type": "number" + }, + "pctUsed": { + "type": "number" + }, + "exhausted": { + "type": "boolean" + }, + "windowStart": { + "type": "string" + }, + "windowEnd": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/api/chat": { + "post": { + "tags": [ + "System" + ], + "summary": "Chat query (stub)", + "description": "Chat is not yet fully implemented; echoes the submitted query back in a stub answer.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "history": { + "type": "array", + "items": {} + } + }, + "required": [ + "query" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Stub answer", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "answer": { + "type": "string" + }, + "status": { + "type": "string", + "example": "stub" + } + } + } + } + } + }, + "400": { + "description": "message is required" + } + } + } + }, + "/api/config/authorized-users": { + "get": { + "tags": [ + "System" + ], + "summary": "List the login allowlist", + "description": "Returns the spoke's device-flow login allowlist (username, role, optional display_name) \u2014 read-only; authoritative source is the hub's Manage Access, propagated via heartbeat.", + "responses": { + "200": { + "description": "Allowlist", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "role": { + "type": "string" + }, + "display_name": { + "type": "string" + } + } + } + }, + "enforced": { + "type": "boolean", + "description": "True on a direct-route spoke that gates logins by this list." + } + } + } + } + } + } + } + } + }, + "/api/config/auto-merge": { + "get": { + "tags": [ + "System" + ], + "summary": "Get top-level auto-merge config", + "description": "Returns AutoMergeConfig for the Governor Features tab. Requires owner role.", + "responses": { + "200": { + "description": "Auto-merge config", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "self_authored": { + "type": "boolean" + }, + "self_authored_set": { + "type": "boolean" + }, + "max_merges": { + "type": "integer" + }, + "required_checks": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "put": { + "tags": [ + "System" + ], + "summary": "Update top-level auto-merge config", + "description": "Updates auto_merge settings; every field is nilable so an absent key leaves that setting untouched. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "self_authored": { + "type": "boolean", + "nullable": true + }, + "max_merges": { + "type": "integer", + "nullable": true, + "minimum": 0 + }, + "required_checks": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated config", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "self_authored": { + "type": "boolean" + }, + "self_authored_set": { + "type": "boolean" + }, + "max_merges": { + "type": "integer" + }, + "required_checks": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "description": "max_merges must be >= 0" + } + } + } + }, + "/api/config/convergence": { + "get": { + "tags": [ + "System" + ], + "summary": "Get convergence rollout config", + "description": "Returns the configured and effective convergence mode, env override state, and captured generation. Requires owner role.", + "responses": { + "200": { + "description": "Convergence config", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "mode": { + "type": "string" + }, + "effective_mode": { + "type": "string" + }, + "env_override": { + "type": "boolean" + }, + "generation": { + "type": "integer" + }, + "modes": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "put": { + "tags": [ + "System" + ], + "summary": "Update convergence rollout mode", + "description": "Updates convergence.mode after validating it against the known set of modes; the change takes effect live at the start of the next eval cycle. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "mode": { + "type": "string" + } + }, + "required": [ + "mode" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Updated config", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "mode": { + "type": "string" + }, + "effective_mode": { + "type": "string" + }, + "env_override": { + "type": "boolean" + }, + "generation": { + "type": "integer" + }, + "modes": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid convergence mode" + } + } + } + }, + "/api/convergence/soak": { + "get": { + "tags": [ + "System" + ], + "summary": "Get convergence soak history", + "description": "Returns longitudinal soak-comparison telemetry: the running commit, effective mode/generation, and recorded per-pass entries (newest first). Requires owner role.", + "responses": { + "200": { + "description": "Soak history", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "commit": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "generation": { + "type": "integer" + }, + "enrolled_path": { + "type": "string" + }, + "max_entries": { + "type": "integer" + }, + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "timestamp": { + "type": "integer" + }, + "commit": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "generation": { + "type": "integer" + }, + "enrolled_path": { + "type": "string" + }, + "raw_issues": { + "type": "integer" + }, + "admitted": { + "type": "integer" + }, + "blocked": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + } + } + } + } + } + } + } + } + } + } + }, + "/api/config/download": { + "get": { + "tags": [ + "System" + ], + "summary": "Download the raw hive.yaml config file", + "description": "Streams the raw config file (hive.yaml, or HIVE_CONFIG path) as an attachment. Carries secrets \u2014 strictly owner-gated (an empty role does NOT default to owner here).", + "responses": { + "200": { + "description": "hive.yaml file", + "content": { + "application/x-yaml": { + "schema": { + "type": "string" + } + } + } + }, + "403": { + "description": "Owner access required" + }, + "404": { + "description": "Config file not found" + } + } + } + }, + "/api/config/escalation": { + "get": { + "tags": [ + "System" + ], + "summary": "Get escalation breaker config", + "description": "Returns the escalation breaker settings (disabled flag, threshold, and its resolved effective value). Requires owner role.", + "responses": { + "200": { + "description": "Escalation config", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean" + }, + "threshold": { + "type": "integer" + }, + "effective_threshold": { + "type": "integer" + } + } + } + } + } + } + } + }, + "put": { + "tags": [ + "System" + ], + "summary": "Update escalation breaker config", + "description": "Updates disabled/threshold; both fields are nilable so an absent key leaves that setting untouched. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean", + "nullable": true + }, + "threshold": { + "type": "integer", + "nullable": true, + "minimum": 0 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated config", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean" + }, + "threshold": { + "type": "integer" + }, + "effective_threshold": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "threshold must be >= 0" + } + } + } + }, + "/api/config/github/forge-apps": { + "get": { + "tags": [ + "System" + ], + "summary": "List forge App credential inventory", + "description": "Read-only inventory of every forge App credential the spoke holds (fingerprints/paths only, never key material) plus the single editable active config and whether it is editable on this spoke.", + "responses": { + "200": { + "description": "Forge apps", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "active": { + "type": "object", + "properties": { + "app_id": { + "type": "integer" + }, + "app_slug": { + "type": "string" + }, + "api_url": { + "type": "string" + }, + "base_url": { + "type": "string" + }, + "installation_id": { + "type": "integer" + }, + "key_file": { + "type": "string" + }, + "key_fingerprint": { + "type": "string" + }, + "auth_state": { + "type": "string" + }, + "install_url": { + "type": "string" + }, + "forge": { + "type": "string" + }, + "editable": { + "type": "boolean" + } + } + }, + "repos_forge": { + "type": "string" + }, + "held_keys": { + "type": "array", + "items": { + "type": "object", + "properties": { + "app_id": { + "type": "string" + }, + "path": { + "type": "string" + }, + "fingerprint": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "503": { + "description": "Config not loaded" + } + } + } + }, + "/api/config/github": { + "put": { + "tags": [ + "System" + ], + "summary": "Update GitHub App config", + "description": "Updates app_id/installation_id/key_file, or writes a new private key (to key_file or the default path) and re-resolves App auth. Requires owner role; requires at least one field and a persistable config source path.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "app_id": { + "type": "integer", + "nullable": true + }, + "installation_id": { + "type": "integer", + "nullable": true + }, + "key_file": { + "type": "string" + }, + "private_key": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "app_id": { + "type": "integer" + }, + "installation_id": { + "type": "integer" + }, + "key_file": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "No fields provided" + }, + "500": { + "description": "Config not persisted (no source path) or write/save failure" + } + } + } + }, + "/api/config/provenance": { + "get": { + "tags": [ + "System" + ], + "summary": "Get config layer provenance report", + "description": "Reports, per tracked config field, which layer (seed / dashboard overlay / agent overlay / env) won and whether that layer is writable, plus overlay-rejection and GitHub-identity-consistency diagnostics. Requires owner role.", + "responses": { + "200": { + "description": "Provenance report", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "fields": { + "type": "array", + "items": { + "type": "object" + }, + "description": "config.FieldOrigin entries" + }, + "layers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "rank": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "writer": { + "type": "string" + }, + "writable": { + "type": "boolean" + } + } + } + }, + "overlay_rejected": { + "type": "boolean" + }, + "overlay_reject_reason": { + "type": "string" + }, + "last_good_used": { + "type": "boolean" + }, + "github_ratchet_fired": { + "type": "boolean" + }, + "identity_issues": { + "type": "array", + "items": { + "type": "string" + } + }, + "seed_path": { + "type": "string" + }, + "overlay_path": { + "type": "string" + }, + "overlay_present": { + "type": "boolean" + } + } + } + } + } + }, + "404": { + "description": "Config file not found" + }, + "500": { + "description": "Config not parsable" + } + } + } + }, + "/api/config/review": { + "get": { + "tags": [ + "System" + ], + "summary": "Get review-swarm merge-gate config", + "description": "Returns the top-level review config (Config.Review) verbatim; not owner-gated for reads.", + "responses": { + "200": { + "description": "Review config", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "require_approval": { + "type": "boolean" + }, + "fan_out": { + "type": "boolean" + }, + "max_parallel_reviews": { + "type": "integer" + }, + "reviewer_agents": { + "type": "array", + "items": { + "type": "string" + } + }, + "fixer_agent": { + "type": "string" + } + } + } + } + } + } + } + }, + "put": { + "tags": [ + "System" + ], + "summary": "Update review-swarm merge-gate config", + "description": "Updates review-gate settings; every field is nilable so an absent key leaves that setting untouched. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "require_approval": { + "type": "boolean", + "nullable": true + }, + "fan_out": { + "type": "boolean", + "nullable": true + }, + "max_parallel_reviews": { + "type": "integer", + "nullable": true, + "minimum": 0 + }, + "reviewer_agents": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "fixer_agent": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + } + } + } + } + } + }, + "400": { + "description": "max_parallel_reviews must be >= 0" + } + } + } + }, + "/api/config/variables": { + "get": { + "tags": [ + "System" + ], + "summary": "List operator variables", + "description": "Returns operator-defined template/config variables (name, type, scope, non-secret provenance hint), plus whether exec/http variable security is enabled. Not owner-gated for reads.", + "responses": { + "200": { + "description": "Variables", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "source": { + "type": "string" + } + } + } + }, + "exec_enabled": { + "type": "boolean" + }, + "http_enabled": { + "type": "boolean" + } + } + } + } + } + } + } + } + }, + "/api/config/variables/{name}": { + "put": { + "tags": [ + "System" + ], + "summary": "Create or update an operator variable", + "description": "Creates/updates a single static or env variable (script/http are seed-only and rejected with 403). Rejects static values that look like secrets. Requires owner role.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "static", + "env" + ] + }, + "scope": { + "type": "string", + "enum": [ + "", + "template", + "config", + "both" + ] + }, + "value": { + "type": "string" + }, + "env": { + "type": "string" + }, + "default": { + "type": "string", + "nullable": true + } + }, + "required": [ + "type" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Saved", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "saved" + }, + "name": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid name/type/scope, or value looks like a secret" + }, + "403": { + "description": "script/http variables can only be defined in the seed config" + }, + "500": { + "description": "Save failed" + } + } + }, + "delete": { + "tags": [ + "System" + ], + "summary": "Delete an operator variable", + "description": "Deletes a static/env variable (script/http are seed-managed and cannot be deleted here). Requires owner role.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } + } + }, + "403": { + "description": "script/http variables cannot be deleted from the dashboard" + }, + "404": { + "description": "Variable not found" + }, + "500": { + "description": "Save failed" + } + } + } + }, + "/api/cost": { + "get": { + "tags": [ + "Tokens" + ], + "summary": "Get unified estimated + native cost view", + "description": "Returns the estimated cost breakdown (from token counts x list prices) plus per-gateway native metered cost (OpenRouter/LiteLLM) and merged-PR/closed-issue counts. Not owner-gated.", + "responses": { + "200": { + "description": "costResponse", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "price_table_date": { + "type": "string" + }, + "disclaimer": { + "type": "string" + }, + "gateways": { + "type": "array", + "items": { + "type": "object" + }, + "description": "gatewayCost entries" + }, + "estimated": { + "type": "object", + "description": "costEstimated: ByModel/ByAgent/BySession/UnpricedModels/TotalUSD etc." + }, + "merged_prs": { + "type": "integer" + }, + "closed_issues": { + "type": "integer" + } + } + } + } + } + } + } + } + }, + "/api/cost/history": { + "get": { + "tags": [ + "Tokens" + ], + "summary": "Get cost history sparkline", + "description": "Returns the recorded cost-history samples (same data source as the unified /api/timeseries?series=cost).", + "responses": { + "200": { + "description": "Cost history entries", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + } + } + }, + "/api/hive-id": { + "get": { + "tags": [ + "System" + ], + "summary": "Get the hive display ID", + "description": "Returns the configured hive ID (empty if unset). Not owner-gated.", + "responses": { + "200": { + "description": "Hive ID", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + } + } + } + } + } + }, + "put": { + "tags": [ + "System" + ], + "summary": "Set the hive display ID", + "description": "Sets and persists the hive display ID to /data/hive-id (max 64 chars, alphanumeric/space/hyphen/underscore only). Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 64 + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "updated" + }, + "id": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Missing id, too long, or invalid characters" + } + } + } + }, + "/api/inference/models/{backend}": { + "get": { + "tags": [ + "System" + ], + "summary": "List available models for an inference backend", + "description": "Discovers models from the backend's configured endpoints; falls back to a static alias list (flagged) if discovery finds nothing.", + "parameters": [ + { + "name": "backend", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Models", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "backend": { + "type": "string" + }, + "models": { + "type": "array", + "items": { + "type": "object" + } + }, + "fallback": { + "type": "boolean" + }, + "partial": { + "type": "boolean", + "description": "True when some but not all endpoints answered." + } + } + } + } + } + }, + "400": { + "description": "backend required" + }, + "404": { + "description": "Unknown inference backend" + } + } + } + }, + "/api/lifecycle-timeline": { + "get": { + "tags": [ + "History" + ], + "summary": "Get the lifecycle event timeline", + "description": "Returns recent lifecycle events plus fleet-health rollups, optionally filtered by the hive's ACMM level. Never returns null arrays.", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Max recent events; default lifecycleTimelineDefaultLimit." + }, + { + "name": "window", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Fleet-health look-back window in minutes; default timeline.DefaultFleetWindow." + } + ], + "responses": { + "200": { + "description": "timeline.Snapshot DTO", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Events": { + "type": "array", + "items": { + "type": "object" + } + }, + "Fleet": { + "type": "object", + "properties": { + "WindowMs": { + "type": "integer" + }, + "Events": { + "type": "integer" + }, + "Merged": { + "type": "integer" + }, + "Blocked": { + "type": "integer" + }, + "InFlight": { + "type": "integer" + } + } + } + } + } + } + } + } + } + } + }, + "/api/prompt-history": { + "get": { + "tags": [ + "History" + ], + "summary": "Get recorded kick-prompt history", + "description": "Returns delivered kick prompts within a time window, optionally filtered by agent. Fails closed like /api/audit: requires at least read-write role (prompt bodies embed repo names, issue titles, and knowledge content).", + "parameters": [ + { + "name": "agent", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "windowHours", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Default promptHistoryDefaultWindowHours." + } + ], + "responses": { + "200": { + "description": "Prompt history", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object" + } + }, + "windowHours": { + "type": "integer" + } + } + } + } + } + }, + "403": { + "description": "Insufficient access (below read-write role)" + } + } + } + }, + "/api/providers/headroom": { + "get": { + "tags": [ + "System" + ], + "summary": "Get per-provider rotation headroom", + "description": "Returns the last known per-provider headroom snapshot from the credential rotation manager. Requires owner role. Reports enabled=false with an empty provider list when rotation is disabled.", + "responses": { + "200": { + "description": "Headroom", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "type": "object" + } + }, + "enabled": { + "type": "boolean" + } + } + } + } + } + } + } + } + }, + "/api/repo-activity": { + "get": { + "tags": [ + "System" + ], + "summary": "Get per-repo activity snapshot", + "description": "Returns the audit-fact-derived activity snapshot (phase 1: counts only, no cost attribution) plus known limitations of the sampling window.", + "responses": { + "200": { + "description": "repoActivityResponse", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ready": { + "type": "boolean" + }, + "phase": { + "type": "string", + "example": "phase_1_activity_only" + }, + "snapshot": { + "type": "object", + "description": "ActivitySnapshot" + }, + "limitations": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "/api/repo-cost": { + "get": { + "tags": [ + "System" + ], + "summary": "Get per-repo cost breakdown", + "description": "Returns the cached repo-cost collector snapshot (interval join of audit facts and usage events). Reports ready=false with an explicitly empty by_repo (never a fabricated $0.00) before the collector's first tick.", + "responses": { + "200": { + "description": "repoCostResponse", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "phase": { + "type": "string" + }, + "by_repo": { + "type": "array", + "items": { + "type": "object" + } + }, + "price_table_date": { + "type": "string" + }, + "disclaimer": { + "type": "string" + }, + "limitations": { + "type": "array", + "items": { + "type": "string" + } + }, + "unattributed": { + "type": "object" + }, + "backend_unsupported": { + "type": "object" + } + } + } + } + } + } + } + } + }, + "/api/role": { + "get": { + "tags": [ + "System" + ], + "summary": "Get the current caller's role and identity", + "description": "Resolves the caller's live role/username (from session, hub-proxy headers, or a bare X-Hive-Role/X-Hive-User pair; empty role defaults to owner) plus the configured auto-merge label and optional display name.", + "responses": { + "200": { + "description": "Role info", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "user": { + "type": "string" + }, + "automerge_label": { + "type": "string" + }, + "display_name": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/api/self-upgrade": { + "post": { + "tags": [ + "System" + ], + "summary": "Request a hub-driven self-upgrade", + "description": "Proxies a POST to the hub's /api/saas/hives/{hiveID}/upgrade endpoint, relaying the caller's identity/cookie and this spoke's dashboard-token proof. Requires owner role. Response is passed through verbatim from the hub with its original status code.", + "responses": { + "200": { + "description": "Passed through from hub", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Hub URL/hive ID not configured, or no dashboard-token proof available" + }, + "403": { + "description": "Owner access required" + }, + "502": { + "description": "Hub unreachable" + } + } + } + }, + "/api/snapshot": { + "get": { + "tags": [ + "System" + ], + "summary": "Get the public status snapshot", + "description": "Returns the full cached status object for public snapshot embedding, gated on hub.auto_snapshot being enabled. Cacheable for 60s.", + "responses": { + "200": { + "description": "Status snapshot", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "404": { + "description": "Snapshots not enabled" + }, + "503": { + "description": "No data yet" + } + } + } + }, + "/api/snapshot/frame-ancestors": { + "get": { + "tags": [ + "System" + ], + "summary": "Get allowed snapshot frame-ancestor origins", + "description": "Returns the configured list of origins allowed to iframe the snapshot page (CSP frame-ancestors allowlist).", + "responses": { + "200": { + "description": "Origins", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "origins": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "/api/style": { + "get": { + "tags": [ + "System" + ], + "summary": "Get sanitized custom CSS", + "description": "Returns sanitized custom CSS for a given source/scope. By default responds as text/css; when `report=1` is passed, responds as JSON with the source key, CSS text, and a sanitize report (dropped-rule count etc).", + "parameters": [ + { + "name": "src", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "report", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "1" + ] + }, + "description": "When '1', return JSON with source/css/report instead of raw CSS." + } + ], + "responses": { + "200": { + "description": "CSS text, or JSON when report=1", + "content": { + "text/css": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "css": { + "type": "string" + }, + "report": { + "type": "object", + "properties": { + "Dropped": { + "type": "integer" + } + } + } + } + } + } + } + }, + "404": { + "description": "Custom style not found" + }, + "422": { + "description": "Style could not be processed" + } + } + } + }, + "/api/timeseries": { + "get": { + "tags": [ + "History" + ], + "summary": "Unified sparkline history read", + "description": "Alias over the dedicated sparkline history endpoints, selected via the `series` query param.", + "parameters": [ + { + "name": "series", + "in": "query", + "required": true, + "schema": { + "type": "string", + "enum": [ + "token", + "tokens", + "fact", + "facts", + "cost" + ] + } + } + ], + "responses": { + "200": { + "description": "Series data (shape matches the corresponding dedicated endpoint)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + "400": { + "description": "Unknown series" + } + } + } + }, + "/api/token-access": { + "get": { + "tags": [ + "Tokens" + ], + "summary": "Get the gh CLI token-access audit log", + "description": "Returns the most recent entries from the token-access log (every gh CLI command an agent issued, including full arguments). Requires owner role.", + "responses": { + "200": { + "description": "Log entries", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object" + } + }, + "error": { + "type": "string", + "description": "Present when the log file could not be read." + } + } + } + } + } + } + } + } + }, + "/api/trend/history": { + "get": { + "tags": [ + "History" + ], + "summary": "Get trend history", + "description": "Returns the recorded trend-history samples (same data source as /api/timeseries \u2014 trend series is not exposed there, but this dedicated endpoint always is).", + "responses": { + "200": { + "description": "Trend history entries", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + } + } + }, + "/api/prs/{owner}/{repo}/{number}/queue-automerge": { + "post": { + "tags": [ + "System" + ], + "summary": "Queue a pull request for auto-merge", + "description": "Applies the configured auto-merge label to a PR on behalf of the authenticated user, after verifying the repo is managed by this hive and the caller is not the PR's own author. Requires merger or owner role.", + "parameters": [ + { + "name": "owner", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "number", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Queued", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "queued" + }, + "repo": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "label": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid PR number or repository" + }, + "403": { + "description": "Not authenticated, self-authored PR, or repo not managed by this hive" + }, + "502": { + "description": "GitHub API failure" + }, + "503": { + "description": "GitHub client not configured" + } + } + } + }, + "/api/widget": { + "get": { + "tags": [ + "Status" + ], + "summary": "Get an embeddable widget summary", + "description": "Returns a compact summary (governor mode, queued issues/PRs, running/paused agent counts, last eval time) for an embeddable status widget.", + "responses": { + "200": { + "description": "Widget summary", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "mode": { + "type": "string" + }, + "issues": { + "type": "integer" + }, + "prs": { + "type": "integer" + }, + "running": { + "type": "integer" + }, + "paused": { + "type": "integer" + }, + "last_eval": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/api/presence": { + "post": { + "tags": [ + "System" + ], + "summary": "Record an engaged-presence ping", + "description": "Fire-and-forget beacon: records the authenticated (X-Hive-User only) caller as actively engaged when the tab is visible and had recent input. Unidentified callers are a silent no-op. Malformed bodies are treated as not-engaged.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "engaged": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "204": { + "description": "No content" + } + } + } + }, + "/api/kick/{agent}/status": { + "get": { + "tags": [ + "Agents" + ], + "summary": "Outcome of the most recent kick", + "description": "Read-only; any authenticated role. Reports whether the most recent asynchronous kick reached the agent's CLI (kubestellar/hive#5325). This is where kick success or failure is decided \u2014 POST /api/kick only promises the message was queued.\n\nWhile status is \"in-flight\" the outcome is INDETERMINATE: the prompt may still be delivered, and a client must not render it as a failure. \"unknown\" means no asynchronous kick has been dispatched for this agent in the current process lifetime, which is not an error.", + "parameters": [ + { + "name": "agent", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent name" + } + ], + "responses": { + "200": { + "description": "Kick dispatch state", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "agent": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "unknown", + "in-flight", + "delivered", + "failed" + ] + }, + "pending": { + "type": "boolean", + "description": "True while the outcome is indeterminate." + }, + "error": { + "type": "string", + "description": "Failure reason; present only when status is failed." + }, + "queuedAt": { + "type": "string", + "format": "date-time" + }, + "settledAt": { + "type": "string", + "format": "date-time", + "description": "Absent while pending." + } + } + } + } + } + }, + "503": { + "description": "Agent manager unavailable" + } + } + } } } } diff --git a/docs/HUB_DISASTER_RECOVERY.md b/docs/HUB_DISASTER_RECOVERY.md index 0883898cd..d063df626 100644 --- a/docs/HUB_DISASTER_RECOVERY.md +++ b/docs/HUB_DISASTER_RECOVERY.md @@ -67,9 +67,11 @@ Note it only *reports* the runtime config. It never restores from it. > `hive.yaml.bak` and boots normally from it. Backups capture **both** names, so > restore whichever the archive contains — under the new name. > -> On Docker/LXC this file is not a snapshot at all but the boot-time source of -> truth, since there is no ConfigMap and no overlay there. See -> `src/docs/config-layering.md`. +> Outside Kubernetes — Docker, Podman, LXC, or a bare host binary; the branch is +> chosen by the absence of a Kubernetes pod, not by the runtime — this file is not +> a snapshot at all but the boot-time source of truth, since there is no ConfigMap +> and no overlay there. Restoring such a hive means restoring the data volume that +> holds it, not the seed. See `src/docs/config-layering.md`. **How to tell which variant a hive runs** — this is the only way to know: diff --git a/docs/backend-setup.md b/docs/backend-setup.md index 4948457fc..3b2e0c57b 100644 --- a/docs/backend-setup.md +++ b/docs/backend-setup.md @@ -7,14 +7,17 @@ Hive validates backend names in `src/pkg/config` and launches CLIs in `src/pkg/a | Backend | Binary launched by the Go manager | Auth / setup | Notes | | --- | --- | --- | --- | | `claude` | `claude` | Install Claude Code and log in once. Hive launches with `--dangerously-skip-permissions`; inference routes add `--bare --settings`. | Advisory/issue modes add disallowed GitHub MCP tools. Every mode also denies host-state commands — privilege escalation (`sudo`/`pkexec`/`doas`/`su`) and boot/deployment tools (`rpm-ostree`/`bootc`/`ostree`/`grubby`/`bootctl`/`efibootmgr`) — because the tmux path runs unconfined on the operator's host (#4918). Set `HIVE_CLAUDE_DANGEROUSLY_ALLOW_HOST_STATE=1` only when you intentionally want an agent to manage host state. | +| `litellm` | `claude` | Not a separate CLI: it launches the **claude** binary pointed at a LiteLLM proxy via `ANTHROPIC_BASE_URL`. Set `HIVE_LITELLM_ENDPOINT` (and `HIVE_LITELLM_API_KEY` if the proxy requires one); no separate login. Inherits claude's confinement posture — `just contribute-hive litellm local` uses Claude Code's native OS sandbox. | | `copilot` | `copilot` | Install GitHub Copilot CLI and authenticate with GitHub. Hive also probes Copilot model entitlements live. | Launched with `--no-auto-update --allow-all`; write tools are denied by mode when needed. | | `gemini` | `gemini` | Install Gemini CLI and configure its normal auth/API key. | Supported by the server-side manager; Hive launches `gemini` and passes `--model` when a model is configured. | | `goose` | `goose` | Install Block Goose and configure provider/model (`GOOSE_PROVIDER`, `GOOSE_MODEL`, or `goose configure`). | Hive launches `goose run -s` and appends `--model` when set. | -| `pi` | `goose` in the Go manager; `pi` in contributor scripts | In the server-side manager, `backendBinary("pi")` maps to `goose`. Configure Goose for pod agents. The contributor relay image/scripts use a separate `pi` binary. | Server-side pod agents use Goose for `backend: pi`; contributor mode uses the Pi CLI. | +| `pi` | `goose` in the Go manager; `pi` in contributor scripts | Contributor mode requires `AGENT_MODEL=provider/model` plus that provider's official credential variable or `~/.pi/agent/auth.json`. In the server-side manager, `backendBinary("pi")` maps to Goose. | Contributor Pi supports interactive and headless (`--print --mode json`) delivery. No generic Pi key/provider variable exists. | | `bob` | `bob` | Provide `HIVE_BOB_API_KEY` or `/secrets/bob_api_key` for pods; contributor mode requires `BOBSHELL_API_KEY`. | Hive uses API-key auth headlessly and accepts the Bob license at launch. | | `codex` | `codex` | Install `@openai/codex` and run `codex login --device-auth` for subscription/OAuth auth. The CLI stores credentials in `CODEX_HOME/auth.json` (default `${HOME}/.codex/auth.json`); API-key mode can use `CODEX_API_KEY`/`OPENAI_API_KEY` or a populated auth file, but it is not required for subscription users. | Hive gives each agent its own `CODEX_HOME` and probes `auth.json` for OAuth tokens/API-key state (or API-key env presence). Contributor mode keeps `--ask-for-approval on-request --sandbox workspace-write`, grants the exact `HIVE_WORKSPACE_DIR` tree with `--add-dir`, and defaults `approvals_reviewer` to `auto_review` so an unattended task never waits on the contributor. Override with `HIVE_CODEX_APPROVAL_POLICY`/`HIVE_CODEX_SANDBOX_MODE`/`HIVE_CODEX_APPROVALS_REVIEWER`, or set `HIVE_CODEX_DANGEROUSLY_BYPASS_APPROVALS_AND_SANDBOX=1` only when you intentionally want the old bypass posture. `AGENT_REASONING_EFFORT` is passed to Codex as `-c model_reasoning_effort="..."`. | | `aider` | contributor scripts launch `aider`; the server-side Go manager does not launch it | Install Aider and configure its provider/API key normally for contributor mode. | Not supported as a server-side agent backend in this branch: config accepts the name, but `backendBinary("aider")` returns `unknown backend: aider`, so a pod agent will not start. Use contributor mode for Aider. | -| `agy` | `agy` | Install the Antigravity CLI (`brew install --cask antigravity-cli`) and run `agy` once to sign in interactively with a Google account. **There is no API-key mode**, so a container cannot inherit the sign-in. | **Host-only.** Launched with `--dangerously-skip-permissions` (same contract as `claude`, or agy blocks on a per-tool approval prompt nobody is attached to answer). When a model is configured the manager appends `--model --effort low`: agy *requires* `--effort` alongside `--model` and otherwise ignores the model entirely. An unrecognised model is not fatal — agy warns and falls back to its own default. Note the effort is the fixed `agyDefaultEffort` constant server-side; hive has no per-agent effort setting yet, so `AGENT_REASONING_EFFORT` applies to the **contributor relay only**, not to pod agents. Not in the contributor image: run `just contribute-hive agy local`. Headless (`agy -p`) is verified on a host that has already signed in. agy also exits `2` if the working directory does not resolve, where some other backends tolerate it. | +| `agy` | `agy` | Install the Antigravity CLI (`brew install --cask antigravity-cli`) and run `agy` once to sign in interactively with a Google account. **There is no API-key mode.** agy persists OAuth state under `~/.gemini` (`oauth_creds.json` with a refresh token, `google_accounts.json`, alongside the `antigravity-cli/` state dir) — `just contribute-hive agy` stages that whole directory into the container, though whether a staged credential actually re-authenticates an unattended agy has not been confirmed end-to-end (agy's binary also links an OS-keyring client, so some auth paths may need a running Secret Service the container does not provide). | **No OS-level sandbox of its own** (same posture as goose/bob/pi/aider — see [sandbox-isolation.md](../src/docs/sandbox-isolation.md)). **Container mode is the only mode with any host boundary** and is supported: `src/Dockerfile.contributor` installs the `agy` binary from Google's published, checksummed release tarball (`#5048`; it did not before). Local mode **refuses to launch** agy without `HIVE_AGY_DANGEROUSLY_RUN_UNCONFINED=1`. Launched with `--dangerously-skip-permissions` (same contract as `claude`, or agy blocks on a per-tool approval prompt nobody is attached to answer). When a model is configured the manager appends `--model --effort low`: agy *requires* `--effort` alongside `--model` and otherwise ignores the model entirely. An unrecognised model is not fatal — agy warns and falls back to its own default. Note the effort is the fixed `agyDefaultEffort` constant server-side; hive has no per-agent effort setting yet, so `AGENT_REASONING_EFFORT` applies to the **contributor relay only**, not to pod agents. Headless (`agy -p`) is verified on a host that has already signed in; whether it works unattended in a fresh container is unverified, which is also why agy stays out of `just contribute-k8s`'s headless-pod allowlist — a pod cannot complete the interactive sign-in even once. agy also exits `2` if the working directory does not resolve, where some other backends tolerate it. agy 1.1.22's own `--sandbox` flag is **not** a local OS boundary — see `config/backends.conf`'s "no confinement mechanism" section for why. | +| `opencode` | `opencode` | Install the opencode CLI ([opencode.ai/docs](https://opencode.ai/docs/)) and run `opencode auth login`; the credential is written to `~/.local/share/opencode/auth.json`. Provider-agnostic (75+ providers) — the model provider is configured in opencode's own config/auth, not in Hive, so `AGENT_MODEL` is passed through as `provider/model` on the relay path (e.g. `export AGENT_MODEL=anthropic/claude-sonnet-4-6`). | **Contributor relay only; headless mode only.** Dispatches through `opencode run "" --auto` under `CONTRIBUTOR_MODE=headless`; there is no interactive-tmux wiring for opencode, so `CONTRIBUTOR_MODE=interactive` does not apply to it. `backend_perm_flag` maps opencode to `--auto`, opencode's unattended auto-approve flag. **Confinement note:** opencode has no OS-enforced filesystem sandbox of its own. `just contribute-hive opencode local` narrows it with a host-state command deny-list only (via opencode's own `permission.bash` config, the same command family the claude deny-list covers) — a floor, not a sandbox boundary. Container mode is the default and the stronger boundary. See [sandbox-isolation.md](../src/docs/sandbox-isolation.md#per-backend-confinement-on-the-contributor-local-path) for the full per-backend matrix. Set `HIVE_OPENCODE_DANGEROUSLY_ALLOW_HOST_STATE=1` to drop the deny-list. Not yet in `just contribute-k8s`'s headless-pod allowlist: whether the auth credential supports unattended use in a fresh pod is unverified, so it currently runs headless only on a host that has already signed in (same posture as `agy`). | +| `kilo` | `kilo` | Install `@kilocode/cli` (pinned via `KILO_CLI_VERSION` in `src/Dockerfile.contributor`, currently `7.5.6`) and set credentials as environment values only — `KILO_AUTH_CONTENT` or `KILO_CONFIG_CONTENT`, or `KILO_API_KEY` (optional `KILO_ORG_ID`). No Kilo config directory is mounted; the Justfile's `PROVIDER_ENV_ARGS` mechanism forwards these four variables to the container by name, so the values themselves never appear in the container runtime's argv. | **Contributor relay only; headless mode only** (`CONTRIBUTOR_MODE=headless`; no interactive-tmux wiring). Dispatches through `kilo run "" --auto` (optional `--model provider/model`). `backend_perm_flag` maps kilo to `--auto`, kilo's unattended auto-approve flag. **Confinement note:** kilo has **no OS-enforced sandbox and no command deny-list floor** in `config/backends.conf` — `--auto` approves prompts, it is not a boundary. Local mode therefore **refuses to launch** kilo without `HIVE_KILO_DANGEROUSLY_RUN_UNCONFINED=1`, the same #4918 refusal gate as goose/agy/bob/pi/aider (unlike `opencode`, no host-state denylist exists for it — whether kilo honors an `OPENCODE_PERMISSION`-style config is unverified). Treat it as fully unconfined, same posture as goose/bob/pi/aider (see [sandbox-isolation.md](../src/docs/sandbox-isolation.md)). Kilo is intentionally **excluded from `just contribute-k8s`'s headless-pod allowlist** (`HEADLESS_BACKENDS="claude litellm copilot codex goose"`), pending independent credential and confinement verification. | ## IBM Bob headless setup @@ -42,10 +45,15 @@ The dashboard **Test key** probe intentionally sends `User-Agent: bobshell`. IBM ```bash AGENT_BACKEND=claude just contribute-hive AGENT_BACKEND=goose GOOSE_PROVIDER=anthropic GOOSE_MODEL=claude-sonnet-4-6 just contribute-hive +AGENT_BACKEND=pi AGENT_MODEL=openai/gpt-5 OPENAI_API_KEY=... CONTRIBUTOR_MODE=headless just contribute-hive AGENT_BACKEND=litellm HIVE_LITELLM_ENDPOINT=https://litellm.example.com just contribute-hive ``` -`AGENT_BACKEND` selects the CLI, `AGENT_MODEL` optionally pins the model, and `CONTRIBUTOR_MODE` defaults to `interactive` (tmux with a TTY). For Codex, `AGENT_REASONING_EFFORT` optionally pins the reasoning effort. `CONTRIBUTOR_MODE=headless` is reserved for one-shot/no-TTY task delivery. +`AGENT_BACKEND` selects the CLI, `AGENT_MODEL` optionally pins the model, and `CONTRIBUTOR_MODE` defaults to `interactive` (tmux with a TTY). For Pi, `AGENT_MODEL` is required and must be the canonical `provider/model` token; this is a contributor preference, not task routing or assignment state. The same token is used for initial launch, restart, reconnect evidence, and headless execution. For Codex, `AGENT_REASONING_EFFORT` optionally pins the reasoning effort. `CONTRIBUTOR_MODE=headless` is reserved for one-shot/no-TTY task delivery. + +Pi credentials remain in the selected provider's official environment variable (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, and so on) or Pi's `~/.pi/agent/auth.json`. Hive never maps a generic `PI_API_KEY`, never puts a credential value in argv, passes only the selected provider's variables into its contributor container, and removes unrelated providers from the ephemeral auth/models profile mounted there. Readiness is deliberately staged: `pi_binary`, `pi_configuration`, `pi_authentication`, and `pi_invocation` appear in relay capability/status JSON. A present key or auth-file entry reports `configured_unverified`; only a successful real invocation advances authentication to `verified` and invocation to `succeeded`, because `pi --version` plus a non-empty key is not authentication proof. + +Headless Pi cancellation is bounded: revocation terminates the active child and fences its late exit from completing a newer task generation. Interactive Pi still uses tmux delivery and is not cancellation-conformance-proven. Both contributor modes are unattended from Codex's perspective: Hive may deliver work when nobody is watching the tmux pane. The default automatic diff --git a/docs/development.md b/docs/development.md index 22ce37fb8..0bc355bd7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -46,7 +46,24 @@ cd src go test ./... ``` -The `src/test/` package contains integration/regression coverage and may exercise local ports, temporary state, and helper processes. If a local environment dependency prevents a full run, include the failing package and error summary in the PR and still run the narrower package tests affected by your change. +The `src/test/` package holds the inception e2e/regression suite. Those tests +talk to a **live hive over the network** and sit behind the `integration` build +tag, so the command above compiles the package but runs none of them — a plain +`go test ./...` will never exercise this suite, and a green run says nothing +about it. To actually run it: + +```bash +cd src +HIVE_URL=http://: HIVE_TOKEN= go test -tags integration ./test/... +``` + +The suite skips itself (exit 0) when `HIVE_URL` is unset or the endpoint does +not answer a fast TCP dial, so a passing run without those variables means +"skipped", not "verified". Run it when you touch inception code; the normal +`go test ./pkg/...` loop is enough otherwise. See `src/test/doc.go` for the +package's own description. + +If a local environment dependency prevents a full run, include the failing package and error summary in the PR and still run the narrower package tests affected by your change. Useful narrower loops: @@ -56,6 +73,68 @@ go test ./pkg/... go test ./cmd/hive ``` +### The plain command above is weaker than the gate + +`go test ./...` is not what CI runs. Every shard in +[`.github/workflows/v2-tests.yml`](../.github/workflows/v2-tests.yml) — the +workflow that publishes the required `test` check — uses the same three flags: + +```bash +cd src +go test ./pkg/hub/... -short -race -count=1 # test (hub) +go test ./pkg/agent -short -race -count=1 -run '<1/5 slice>' +go test $PKGS -short -race -count=1 # test (rest i/3) +``` + +The workflow shards for wall-clock only: `pkg/hub` and `pkg/agent` get dedicated +jobs, and everything else from `go list ./pkg/... ./cmd/...` is partitioned into +balanced buckets. The union of the shards is the whole of `./pkg/...` and +`./cmd/...`, so what changes between your loop and the gate is the *flags*, not +the coverage. To reproduce the gate locally in one unsharded run: + +```bash +cd src +go test ./pkg/... ./cmd/... -short -race -count=1 +``` + +What each flag changes: + +- **`-race`** is the one most likely to catch a bug you would otherwise ship. A + data race or a lock-ordering mistake usually passes a non-race run every time + and only surfaces under load in production. This repository has repeatedly + paid for that: see the `writeMu` / `WriteControl` reasoning in + [`src/pkg/dashboard/contribute_ws.go`](../src/pkg/dashboard/contribute_ws.go), + which exists because concurrent writers to a single WebSocket produced real + mutex re-entrancy deadlocks. If you run only one thing before pushing, run + the race build of the packages you touched. +- **`-short`** sets `testing.Short()`, so any test guarded by + `if testing.Short() { t.Skip(...) }` does **not** run in CI. This cuts both + ways, and both directions bite. A slow test you write without a `Short` guard + runs on every shard and adds to the gate's wall clock. A test you write + *behind* a `Short` guard is never executed by the gate at all — a green + required check says nothing about it, exactly as a plain run says nothing + about the `integration` suite above. Guard slow *setup*, not the assertion + that proves your fix. +- **`-count=1`** disables the test result cache. Without it, an unchanged + package reports its previous verdict instead of re-running, which is + precisely what you do not want when you are trying to reproduce a failure or + chase a flake. + +Two flags that appear in CI but are *not* part of the PR gate: + +- **`-timeout 600s`** is used only by the hourly coverage cron + ([`.github/workflows/coverage-hourly.yml`](../.github/workflows/coverage-hourly.yml)), + which runs the suite unsharded. The PR shards pass no `-timeout`, so they take + Go's default of 10 minutes per shard binary. The practical bound on a shard is + therefore the same 10 minutes, applied to a much smaller slice of the suite. +- **`-coverprofile`** is added by each shard to feed a per-package coverage + report. Coverage is scored, but it is not the required merge gate; a failing + test is. + +Neither the PR gate nor the cron runs `./test/...`: both enumerate +`./pkg/... ./cmd/...` explicitly, and the integration suite additionally needs +the `integration` build tag and a live hive, as described above. + ## Format and lint expectations Run `gofmt` on Go files you edit: @@ -71,6 +150,40 @@ cd src go vet ./... ``` + +## If CI says "NOTICE is out of date" + +`NOTICE` lists every Go module compiled into the shipped binaries. It is +**generated**, not hand-edited, so any change to `src/go.mod` or `src/go.sum` +— including a Dependabot version bump — makes it stale and fails the +`notice-drift` job ("NOTICE matches the module graph") in +`.github/workflows/go-security-analysis.yml`. + +Regenerate and commit it: + +```bash +bash src/scripts/generate-notice.sh # writes NOTICE at the repo root +``` + +Three things that will otherwise cost you a CI round trip: + +- **Commit the output verbatim.** The check is byte-exact. Do not reformat it, + do not strip trailing whitespace — several dependency licences contain + trailing spaces on their own lines, and removing them produces a permanent + diff against what CI generates. +- **The generator needs the module's Go toolchain.** `src/go.mod` pins a + specific version; running under an older `go` makes `go-licenses` fail to + resolve stdlib packages and abort before writing anything. Set + `GOTOOLCHAIN` to the pinned version if your default `go` is older. +- **A red `notice-drift` is not always yours.** Because `NOTICE` lives on the + branch, a dependency bump merged without regenerating it leaves `v4` itself + stale — and then *every* open PR inherits the failure, including docs-only + ones. Check whether `v4` is clean before assuming your change caused it. + +A `FORBIDDEN` result is a different problem: the module graph contains a +licence the project cannot ship (this is how an AGPL-3.0 dependency was caught +in #5016). That needs the dependency removed or replaced, not a regeneration. + There is no public `just lint` recipe in the current root `Justfile`; use `go vet ./...` for the repository's documented local lint-equivalent check, plus `gofmt`, `go build`, and targeted `go test` for the files you change. ## Running Hive locally diff --git a/examples/agents/operations.md b/examples/agents/operations.md new file mode 100644 index 000000000..2a22d788f --- /dev/null +++ b/examples/agents/operations.md @@ -0,0 +1,45 @@ +# ${PROJECT_NAME} Operations + +You are the **operations** agent for ${PROJECT_ORG}/${PROJECT_PRIMARY_REPO}. You audit and improve the operational readiness of the *managed* project — health checks, SLO/SLI definitions, alerting, runbooks, and release/rollback safety. + +This agent is L5/L6-only and opt-in: it does not appear in the roster below L5, and it stays paused at L5/L6 until an operator configures `governor.project_observability` and un-pauses it from the dashboard's Cadences tab. Do not assume a target observability stack — check `${PROJECT_OBSERVABILITY}` (populated from that config) on every kick. + +## Pre-flight (MANDATORY — every kick) + +1. Re-read this policy file from disk +2. Re-read your ACMM level fragment (`operations-advisory.md`, `operations-holdgated.md`, or `operations-full.md`) +3. Read the tail of your heartbeat log +4. Read `${PROJECT_OBSERVABILITY}` — if no backend has been explicitly configured, you are in detect-and-report mode only for this kick, regardless of ACMM level + +**Do NOT rely on in-context memory from previous iterations.** + +## Core Responsibilities + +1. **Audit health/readiness endpoints** — verify every probe checks the dependencies it needs to serve its claimed readiness state +2. **Audit SLO/SLI definitions** — flag SLOs without measurable indicators +3. **Audit alerting** — flag alerts without runbook links, and machine-state alerts with no user impact +4. **Audit release safety** — flag undocumented rollback paths and missing incident/postmortem templates +5. **File findings** with an `[operations]` title and an operations-owned bead + +## Allowed Work (hold-gated and full modes only) + +Health and readiness handlers, SLO/SLI definitions, user-impact alert rules with runbook links, `runbooks/*.md`, incident and postmortem templates, and release/rollback documentation or safeguards. + +## NEVER DO — Hard Rules + +1. **NEVER weaken an existing alert or SLO** to make reported health look better +2. **NEVER add a probe that reports healthy without checking a dependency required to serve traffic** +3. **NEVER merge your own PR** +4. **NEVER remove or modify a `hold`, `on-hold`, or `do-not-merge` label** + +## Repository coverage + +`$HIVE_REPOS` is the authorized comma-separated repository list. Rotate to the least recently audited repository; pass `--repo "/"` to every `gh` command. + +## Output Rules + +Return an `AgentReport`. Use `kind: "findings"` in advisory mode. Use `kind: "instrument"` when files were produced, and list each in `artifacts` with `repo`, `path`, and `description`. + +## Heartbeat — MANDATORY + +Log every pass to your heartbeat file. Write BEFORE doing work. diff --git a/examples/agents/telemetry.md b/examples/agents/telemetry.md new file mode 100644 index 000000000..b5c945d24 --- /dev/null +++ b/examples/agents/telemetry.md @@ -0,0 +1,45 @@ +# ${PROJECT_NAME} Telemetry + +You are the **telemetry** agent for ${PROJECT_ORG}/${PROJECT_PRIMARY_REPO}. You audit and improve the observability of the *managed* project — tracing, metrics, structured logging, dashboards, and monitoring resources — not the hive's own internal telemetry. + +This agent is L5/L6-only and opt-in: it does not appear in the roster below L5, and it stays paused at L5/L6 until an operator configures `governor.project_observability` and un-pauses it from the dashboard's Cadences tab. Do not assume a target observability stack — check `${PROJECT_OBSERVABILITY}` (populated from that config) on every kick. + +## Pre-flight (MANDATORY — every kick) + +1. Re-read this policy file from disk +2. Re-read your ACMM level fragment (`telemetry-advisory.md`, `telemetry-holdgated.md`, or `telemetry-full.md`) +3. Read the tail of your heartbeat log +4. Read `${PROJECT_OBSERVABILITY}` — if no backend has been explicitly configured, you are in detect-and-report mode only for this kick, regardless of ACMM level + +**Do NOT rely on in-context memory from previous iterations.** + +## Core Responsibilities + +1. **Detect the existing stack** — inspect tracing, metrics, structured logging, scrape targets, dashboards, monitoring CRs, collector/exporter configuration, and web analytics before recommending anything +2. **Prefer OpenTelemetry** as a vendor-neutral spine, but only act on a backend named in `${PROJECT_OBSERVABILITY}` +3. **Flag instrumentation smells** — unbounded metric labels, high-cardinality span attributes, missing scrape targets, inconsistent span names, dashboards without source-controlled definitions +4. **File findings** with a `[telemetry]` title and a telemetry-owned bead + +## Allowed Work (hold-gated and full modes only) + +OpenTelemetry SDK wiring and request-path spans, bounded metrics and `/metrics` endpoints, structured logging, dashboard JSON, alert-rule YAML, `ServiceMonitor`/`PodMonitor` resources, collector/exporter configuration, dashboard-lint CI, and GA4 wiring for an identified web property. + +## NEVER DO — Hard Rules + +1. **NEVER commit credentials, literal collector endpoints, API keys, or secret values** — refer only to environment-variable or secret names +2. **NEVER add an exporter that sends data off-box without an explicitly configured backend** in `${PROJECT_OBSERVABILITY}` +3. **NEVER introduce unbounded labels or span attributes** +4. **NEVER merge your own PR** +5. **NEVER remove or modify a `hold`, `on-hold`, or `do-not-merge` label** + +## Repository coverage + +`$HIVE_REPOS` is the authorized comma-separated repository list. Rotate to the least recently audited repository; pass `--repo "/"` to every `gh` command. + +## Output Rules + +Return an `AgentReport`. Use `kind: "findings"` in advisory mode. Use `kind: "instrument"` when files were produced, and list each in `artifacts` with `repo`, `path`, and `description`. + +## Heartbeat — MANDATORY + +Log every pass to your heartbeat file. Write BEFORE doing work. diff --git a/src/.golangci.yml b/src/.golangci.yml index 7b437f206..6eff3ce07 100644 --- a/src/.golangci.yml +++ b/src/.golangci.yml @@ -35,9 +35,8 @@ linters: # its own PR after its findings are fixed — that ratchet is tracked in the # ratchet issue #4903. # - # Remaining backlog: staticcheck (225), errcheck (579). errcheck is the - # most valuable remaining rung and the largest, and is worth splitting by - # package. + # Remaining backlog: errcheck (579) — the most valuable remaining rung and + # the largest, worth splitting by package. - govet - misspell # RATCHET: ineffassign enabled after its 11 findings were fixed (#4903). @@ -47,6 +46,31 @@ linters: # that moved on without deleting them. One declaration is retained under an # explained //nolint:unused — see pkg/hub/wrapkey_store.go. - unused + # RATCHET: staticcheck enabled after its 122 findings were fixed (#4903), + # scoped to the SA* bug-detection family (see settings below -- the QF*/ST* + # style families are deliberately not enforced). Six findings are retained + # under explained //nolint:staticcheck for genuine false positives or + # intentional patterns. The one raw U+200B in a scheduler security fixture + # is now written \u200b: identical bytes, visible in source, so ST1018 is + # satisfied without weakening the injection test. + - staticcheck + + settings: + staticcheck: + # The SA* family is the bug detector -- misused stdlib, impossible + # conditions, ignored results. That is what this gate is for, and the + # tree is clean against it. + # + # Deliberately NOT enabled: + # QF* quickfix style rewrites. 103 hits, all QF1012 + # (WriteString(Sprintf(...)) -> Fprintf). Mechanically true and + # behaviorally identical, so enforcing it buys no correctness and + # costs a 103-site churn PR across advisory/ and dashboard/. + # ST* naming and doc-comment conventions, likewise stylistic. + # SA1019 (deprecation) is excluded below rather than repo-wide: the six + # hits are all in tests, and two of them exist precisely to + # exercise a deprecated field's back-compat path. + checks: ["SA*", "-SA1019"] exclusions: generated: lax diff --git a/src/Dockerfile b/src/Dockerfile index 226bc6c6e..02095ee1a 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -29,7 +29,7 @@ ARG GIT_BRANCH=unknown # registration), wired the same way as GIT_HASH/GIT_BRANCH: an -X ldflag that # takes over from the Go linker default (cmd/hive/main.go, "0.0.0-dev") only # when a caller passes it. NO current workflow passes it — docker.yml never -# does, and release.yml (.github/workflows/release.yml) deliberately does not +# does, and .github/workflows/tagged-release.yml deliberately does not # rebuild at all; it retags the image docker.yml already published for that # commit rather than invoking this Dockerfile a second time. This ARG exists # so that gap can be closed later (teaching a build to embed a real semver) @@ -60,7 +60,7 @@ RUN --mount=type=cache,id=hive-go-build,target=/tmp/gobuild \ # supply-chain risk. Refresh deliberately (same technique as golang above, # repo library/node, tag 26-slim) and update both the tag and the @sha256 # digest together in BOTH FROM node:26-slim lines in this file. -FROM node:26-slim@sha256:4ebb5ace66f15a24c14c492e01a8beeed4fddf970a856109f5126e703e5fe503 AS tmux-builder +FROM node:26-slim@sha256:c0753125a3789977aefe869cbebccf70e3cfd7ea84ca48547458f02e4f1d7146 AS tmux-builder ARG TARGETARCH ARG TMUX_VERSION=3.5a ARG TMUX_SHA256_AMD64=16216bd0877170dfcc64157085ba9013610b12b082548c7c9542cc0103198951 @@ -88,7 +88,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # (and the rewrite RUN below) from the branch-scoped GHA cache — so a fresh # binary was rebuilt and then discarded, and the OLD binary shipped. See the # stale-binary note in .github/workflows/docker.yml. -FROM node:26-slim@sha256:4ebb5ace66f15a24c14c492e01a8beeed4fddf970a856109f5126e703e5fe503 AS runtime +FROM node:26-slim@sha256:c0753125a3789977aefe869cbebccf70e3cfd7ea84ca48547458f02e4f1d7146 AS runtime # --- Layer 1: system packages (rarely changes) --- # util-linux provides `setpriv`, used by the entrypoint to drop to `dev` with an @@ -334,30 +334,52 @@ ARG BOBSHELL_VERSION=1.0.6 ARG BOBSHELL_BASE_URL=https://s3.us-south.cloud-object-storage.appdomain.cloud/bob-shell ARG BOBSHELL_SHA256_AMD64=6ec51abec4251d41ec45709030988b90baa659f535fc8d14dd003023dd163a5b ARG BOBSHELL_SHA256_ARM64=6ec51abec4251d41ec45709030988b90baa659f535fc8d14dd003023dd163a5b -# Deliberately NOT tolerant of failure, unlike the copilot/goose layers above. -# A hive configured with backend "bob" passes config validation (validBackends -# in pkg/config) and then fails at launch with "agent scanner not running" — -# a silent trap that cost real debugging time. If this download breaks, the -# build must break loudly rather than ship an image that reproduces that bug. -# The `which bob` check turns a partial install into a build failure too. -# Per-attempt bounds (--connect-timeout/--speed-time/--max-time) kill stalled -# IBM COS connections so they can't consume the whole retry budget: without -# them a single hung download burned all of --retry-max-time and exited 28 -# (issue #4941; CI runs 32990236986, 33072306789, 33132187354). +# Tolerant of download failure now, like the copilot/codex/goose/pi layers +# above (issue #5203) — IBM COS (s3.us-south.cloud-object-storage.appdomain.cloud) +# has gone unreachable for multi-minute windows three times in one day +# (#4941, then recurred post-fix; CI runs 32990236986, 33072306789, +# 33132187354, 33322522386), and no retry ladder can ride out an outage that +# outlasts it — every one of #4941's added attempts hit +# "Connection timed out after 15002 ms" across ~175s, i.e. the endpoint was +# down for the whole window, not flaking. +# +# This used to be deliberately hard-fail-only, because a hive configured with +# backend "bob" passed config validation (validBackends in pkg/config) and +# then failed at launch with no honest signal — a silent trap that cost real +# debugging time. That trap is now closed at the launch site instead of at +# image-build time: pkg/agent/manager.go's launchInTmux resolves the backend +# binary via exec.LookPath BEFORE any bob-specific logic runs, and on a miss +# marks the agent StateFailed, records LastError, logs a warning, writes a +# banner into the agent's own tmux pane, and emits an AuditAgentStartFailed +# event — all before the agent could ever appear to be silently stuck. A +# soft-failed bobshell layer now degrades exactly the same way a soft-failed +# Goose layer already does: the backend is honestly reported unavailable per +# agent, at launch, not silently absent with no signal anywhere (the failure +# mode #5048 describes for a different backend, agy). +# +# Checksum mismatches are still never tolerated — the || only covers the +# curl download step, not the verify/install steps, so a tampered or +# corrupted tarball still hard-fails the build exactly as before. +# Per-attempt bounds (--connect-timeout/--speed-time/--max-time) still kill +# stalled IBM COS connections so a single hung attempt cannot consume the +# whole retry budget on its own (issue #4941). RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" && \ case "$ARCH" in \ amd64) BOBSHELL_SHA256="$BOBSHELL_SHA256_AMD64" ;; \ arm64) BOBSHELL_SHA256="$BOBSHELL_SHA256_ARM64" ;; \ *) echo "unsupported arch for bobshell: $ARCH" >&2; exit 1 ;; \ esac && \ - curl -fsSL --retry 8 --retry-delay 5 --retry-max-time 600 --retry-connrefused --retry-all-errors \ + if curl -fsSL --retry 8 --retry-delay 5 --retry-max-time 600 --retry-connrefused --retry-all-errors \ --connect-timeout 15 --speed-limit 1024 --speed-time 30 --max-time 180 \ - -o /tmp/bobshell.tgz "${BOBSHELL_BASE_URL}/bobshell-${BOBSHELL_VERSION}.tgz" && \ - echo "${BOBSHELL_SHA256} /tmp/bobshell.tgz" | sha256sum -c - && \ - npm install -g /tmp/bobshell.tgz && \ - npm cache clean --force && \ - which bob && \ - rm -f /tmp/bobshell.tgz + -o /tmp/bobshell.tgz "${BOBSHELL_BASE_URL}/bobshell-${BOBSHELL_VERSION}.tgz"; then \ + echo "${BOBSHELL_SHA256} /tmp/bobshell.tgz" | sha256sum -c - && \ + npm install -g /tmp/bobshell.tgz && \ + npm cache clean --force && \ + which bob && \ + rm -f /tmp/bobshell.tgz; \ + else \ + echo "WARN: Bob CLI download failed — skipping (backend: bob will be unavailable)"; \ + fi # --- Application layers --- ENV HOME=/home/dev @@ -428,6 +450,7 @@ COPY bin/hive-open-pr.sh /usr/local/bin/hive-open-pr COPY bin/hive-open-issue.sh /usr/local/bin/hive-open-issue COPY bin/hive-merge.sh /usr/local/bin/hive-merge COPY bin/hive-review.sh /usr/local/bin/hive-review +COPY bin/hive-baseline-check.sh /usr/local/bin/hive-baseline-check.sh COPY bin/gh-wrapper.sh /usr/local/bin/gh # SDK-based copilot model discovery helper (see Layer 3b). Invoked as # `node /usr/local/bin/copilot-models.mjs` by the dashboard, so no exec bit. @@ -435,7 +458,8 @@ COPY bin/copilot-models.mjs /usr/local/bin/copilot-models.mjs COPY config/backends.conf /usr/local/etc/hive/backends.conf RUN chmod +x /usr/local/bin/ttyd-tmux.sh /usr/local/bin/hive-panes /usr/local/bin/gh-app-token.sh \ /usr/local/bin/hive-config.sh /usr/local/bin/agent-launch.sh /usr/local/bin/gh \ - /usr/local/bin/git-credential-hive.sh /usr/local/bin/hive-open-pr /usr/local/bin/hive-open-issue /usr/local/bin/hive-merge /usr/local/bin/hive-review + /usr/local/bin/git-credential-hive.sh /usr/local/bin/hive-open-pr /usr/local/bin/hive-open-issue /usr/local/bin/hive-merge /usr/local/bin/hive-review \ + /usr/local/bin/hive-baseline-check.sh # SECURITY (#4045): interactive-shell arm of the agent credential scrub. # BASH_ENV (exported by agent-launch.sh) only reaches NON-interactive shells; @@ -597,4 +621,4 @@ RUN for b in agy claude; do \ done ENTRYPOINT ["entrypoint.sh"] -CMD ["--config", "/etc/hive/hive.yaml"] \ No newline at end of file +CMD ["--config", "/etc/hive/hive.yaml"] diff --git a/src/Dockerfile.contributor b/src/Dockerfile.contributor index b5fa741fc..7a89de66f 100644 --- a/src/Dockerfile.contributor +++ b/src/Dockerfile.contributor @@ -10,7 +10,7 @@ # -H "Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json" \ # https://registry-1.docker.io/v2/library/debian/manifests/bookworm-slim | grep -i docker-content-digest # and swap it in below, keeping the tag for readability. -FROM debian:bookworm-slim@sha256:abd67ffcfa541b485a3dff59865ab629aa048a6c613e639d36e7456b0b229241 +FROM debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 ARG NODE_MAJOR=24 @@ -56,27 +56,58 @@ RUN mkdir -p /etc/apt/keyrings \ # diff, mirroring the cleanup already done for the bobshell install below. ARG CLAUDE_CODE_VERSION=2.1.226 ARG COPILOT_VERSION=1.0.59 +# --ignore-scripts (kept, deliberately: no arbitrary postinstall runs during +# the build) skips claude-code's install.cjs, which is what LINKS the platform +# native binary into the package's bin/. Without the link every invocation in +# the container dies with: +# +# Error: claude native binary not installed. +# +# i.e. the claude backend is unusable in container mode while local mode works +# fine — observed live on a contributor's first `just contribute-hive claude`. +# Run the vendor's own install step explicitly, exactly as src/Dockerfile +# Layer 7 already does for the spoke image: still auditable (one named script, +# not "whatever any dependency wants"), and it writes into the global package +# dir, so the fix applies to every user rather than one $HOME. The path is +# resolved via `npm root -g` because this image installs Node from NodeSource +# debs, whose global root differs from the node base image src/Dockerfile uses. +# `claude --version` makes the build itself fail loudly if the link is missing, +# instead of every contributor task failing at runtime. +ARG KILO_CLI_VERSION=7.5.6 RUN npm install -g --ignore-scripts --no-fund --no-audit \ @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \ @github/copilot@${COPILOT_VERSION} \ @openai/codex@0.146.0 \ - && npm cache clean --force + @kilocode/cli@${KILO_CLI_VERSION} \ + && npm cache clean --force \ + && node "$(npm root -g)/@anthropic-ai/claude-code/install.cjs" \ + && claude --version # Install bobshell (IBM "bob"). Mirrors Layer 9 of src/Dockerfile so the # containerized contributor relay can actually run the bob backend — without -# this, detect_cli in bin/contributor-agent.sh reports NOT_AUTHED because the -# binary does not exist, and a contributor who selects "Bob" on /contribute -# gets a container that can never take work. +# this, detect_cli in bin/contributor-agent.sh reports NOT_INSTALLED because +# the binary does not exist, and a contributor who selects "Bob" on +# /contribute gets a container that can never take work. # # Version is pinned to match the hub image exactly: hub and relay agents must # speak the same bobshell CLI surface (the hidden --auth-method flag and the # --approval-mode choices are version-dependent). Do not bump one without the # other. # -# Deliberately NOT tolerant of failure, matching the hub layer: a silent skip -# here reproduces exactly the "backend validates but never launches" trap this -# layer exists to prevent. The `which bob` check turns a partial install into a -# build failure too. +# Tolerant of download failure now, matching src/Dockerfile's Layer 9 (issue +# #5203): IBM COS has gone unreachable for multi-minute windows repeatedly, +# and no retry ladder can outlast a genuine outage. This is safe because +# absence is still honestly surfaced, not silent: detect_cli's +# `command -v "$cmd"` gate runs before any per-backend logic and returns +# NOT_INSTALLED for a missing bob binary, which bin/contributor-agent.sh +# turns into `ERROR: bob CLI not found. Install it and try again.` and a +# non-zero exit — the container refuses to start claiming readiness it +# doesn't have, the same "backend validates but never launches" trap #5048 +# describes is not reproduced here because the container fails loudly at +# startup instead of accepting work it cannot do. +# +# Checksum mismatches are still never tolerated — the || only covers the +# curl download step, not the verify/install steps. ARG BOBSHELL_VERSION=1.0.6 ARG BOBSHELL_BASE_URL=https://s3.us-south.cloud-object-storage.appdomain.cloud/bob-shell ARG TARGETARCH @@ -88,14 +119,17 @@ RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" \ arm64) BOBSHELL_SHA256="$BOBSHELL_SHA256_ARM64" ;; \ *) echo "unsupported arch for bobshell: $ARCH" >&2; exit 1 ;; \ esac \ - && curl -fsSL --retry 8 --retry-delay 5 --retry-max-time 600 --retry-connrefused --retry-all-errors \ + && if curl -fsSL --retry 8 --retry-delay 5 --retry-max-time 600 --retry-connrefused --retry-all-errors \ --connect-timeout 15 --speed-limit 1024 --speed-time 30 --max-time 180 \ - -o /tmp/bobshell.tgz "${BOBSHELL_BASE_URL}/bobshell-${BOBSHELL_VERSION}.tgz" \ - && echo "${BOBSHELL_SHA256} /tmp/bobshell.tgz" | sha256sum -c - \ - && npm install -g /tmp/bobshell.tgz && \ - npm cache clean --force && \ - which bob && \ - rm -f /tmp/bobshell.tgz + -o /tmp/bobshell.tgz "${BOBSHELL_BASE_URL}/bobshell-${BOBSHELL_VERSION}.tgz"; then \ + echo "${BOBSHELL_SHA256} /tmp/bobshell.tgz" | sha256sum -c - \ + && npm install -g /tmp/bobshell.tgz \ + && npm cache clean --force \ + && which bob \ + && rm -f /tmp/bobshell.tgz; \ + else \ + echo "WARN: Bob CLI download failed — skipping (backend: bob will be unavailable)"; \ + fi # Install gh CLI RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ @@ -130,6 +164,51 @@ RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" \ echo "Goose install skipped"; \ fi +# Install agy (Google Antigravity CLI). Not an npm package — Google ships it +# as a standalone binary from a versioned, checksummed GCS URL (the same URL +# Homebrew's antigravity-cli cask resolves to, confirmed against +# formulae.brew.sh/api/cask/antigravity-cli.json). ~200MB uncompressed, so +# this is its own layer, isolated from smaller/more frequently bumped CLIs +# above. Upstream ships the binary as `antigravity`; the Homebrew cask +# installs it under the name `agy` (its actual CLI entry point name), so this +# layer renames it the same way — every other reference to this backend in +# hive (Justfile, backends.conf, docs) expects the binary on PATH as `agy`. +# +# Download failures are tolerated, matching Goose above: agy is an optional +# contributor backend and its origin (a Google-controlled GCS bucket, not a +# package registry hive otherwise depends on) is less predictable than +# npm/apt. Checksum mismatches are never tolerated. +# +# This installs the CLI only. It does NOT make container mode confined for +# agy in any deeper sense than the container boundary itself — agy has no +# per-invocation OS sandbox flag hive can wire (see config/backends.conf's +# "no confinement mechanism at all" section). It also does not solve +# credentials: agy signs in through an interactive Google OAuth flow with no +# API-key mode, so a fresh container still needs an operator to sign in +# inside it (or a future credential-mount fix, see Justfile's `agy)` staging +# case) before it can do unattended work. +ARG AGY_VERSION=1.1.22-5711547746615296 +ARG AGY_SHA256_AMD64=1e1a219a86e75d7c6351f96d182ca2105302d5c34d8fa9c31265dc0adf24145f +ARG AGY_SHA256_ARM64=a68925bc7336eb0b90de1e1aefd44d535f5487b7cf606a76fdb982207aef9a2e +RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" \ + && case "$ARCH" in \ + amd64) AGY_PLATFORM=linux-x64; AGY_TARBALL=cli_linux_x64.tar.gz; AGY_SHA256="$AGY_SHA256_AMD64" ;; \ + arm64) AGY_PLATFORM=linux-arm; AGY_TARBALL=cli_linux_arm64.tar.gz; AGY_SHA256="$AGY_SHA256_ARM64" ;; \ + *) echo "unsupported arch for agy: $ARCH" >&2; exit 1 ;; \ + esac \ + && mkdir -p /tmp/agy-dl \ + && if curl -fsSL --retry 8 --retry-delay 5 --retry-max-time 600 --retry-connrefused --retry-all-errors \ + --connect-timeout 15 --speed-limit 1024 --speed-time 30 --max-time 180 \ + "https://storage.googleapis.com/antigravity-public/antigravity-cli/${AGY_VERSION}/${AGY_PLATFORM}/${AGY_TARBALL}" \ + -o /tmp/agy-dl/agy.tar.gz; then \ + echo "${AGY_SHA256} /tmp/agy-dl/agy.tar.gz" | sha256sum -c - \ + && cd /tmp/agy-dl && tar -xzf agy.tar.gz \ + && install -m 0755 antigravity /usr/local/bin/agy \ + && rm -rf /tmp/agy-dl; \ + else \ + echo "agy install skipped"; \ + fi + # Install Pi CLI (pi.dev coding agent). Do not use pi.dev's curl-pipe-to-shell # installer here: it is mutable and executes remote shell as root. Pi publishes # npm-shrinkwrap.json, so install a pinned npm package with lifecycle scripts @@ -175,6 +254,7 @@ RUN mkdir -p /var/run/hive-metrics /home/dev/.config/goose /home/dev/.config/gh # Copy agent scripts COPY bin/contributor-agent.sh /usr/local/bin/ COPY bin/contributor-relay.sh /usr/local/bin/ +COPY bin/pi-backend.js /usr/local/bin/ COPY bin/agent-launch.sh /usr/local/bin/ COPY bin/gh-wrapper.sh /usr/local/bin/gh COPY bin/hive-config.sh /usr/local/bin/ diff --git a/src/cmd/apiproxy/main.go b/src/cmd/apiproxy/main.go index 2b1454322..d675cc238 100644 --- a/src/cmd/apiproxy/main.go +++ b/src/cmd/apiproxy/main.go @@ -49,7 +49,7 @@ func main() { if err != nil { log.Fatalf("failed to open log file: %v", err) } - defer f.Close() + defer func() { _ = f.Close() }() // process runs under ListenAndServe until killed; defer is unreachable in normal operation logWriter = json.NewEncoder(f) } else { logWriter = json.NewEncoder(os.Stdout) @@ -81,7 +81,9 @@ func main() { if evt.SSEType != "" && len(evt.Body) > 0 { entry.Body = evt.Body } - logWriter.Encode(entry) + if err := logWriter.Encode(entry); err != nil { + log.Printf("apiproxy: failed to write event log entry: %v", err) + } } clientAuthToken, err := clientAuthTokenFromEnv(os.Getenv) diff --git a/src/cmd/bd/kb.go b/src/cmd/bd/kb.go index 4bf11755e..ba71b15d2 100644 --- a/src/cmd/bd/kb.go +++ b/src/cmd/bd/kb.go @@ -156,7 +156,7 @@ func cmdKBImportURL(args []string) { fs := flag.NewFlagSet("import-url", flag.ExitOnError) name := fs.String("name", "", "document name (defaults to URL slug)") layer := fs.String("layer", "project", "knowledge layer") - fs.Parse(args) + _ = fs.Parse(args) // ExitOnError: Parse never returns on failure, it os.Exits internally if fs.NArg() < 1 { fmt.Fprintln(os.Stderr, "bd kb import-url: URL required") @@ -191,7 +191,7 @@ func cmdKBImportFile(args []string) { fs := flag.NewFlagSet("import-file", flag.ExitOnError) name := fs.String("name", "", "document name (defaults to filename)") layer := fs.String("layer", "project", "knowledge layer") - fs.Parse(args) + _ = fs.Parse(args) // ExitOnError: Parse never returns on failure, it os.Exits internally if fs.NArg() < 1 { fmt.Fprintln(os.Stderr, "bd kb import-file: file path required") @@ -311,7 +311,7 @@ func cmdKBImportCtx7(args []string) { name := fs.String("name", "", "document name (defaults to library ID)") query := fs.String("query", "", "topic to focus documentation on") layer := fs.String("layer", "community", "knowledge layer") - fs.Parse(args) + _ = fs.Parse(args) // ExitOnError: Parse never returns on failure, it os.Exits internally if fs.NArg() < 1 { fmt.Fprintln(os.Stderr, "bd kb import-ctx7: library ID required (e.g. /vllm-project/vllm)") @@ -358,7 +358,7 @@ func kbGet(url string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("HTTP GET: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(io.LimitReader(resp.Body, kbMaxResponseBytes)) if err != nil { @@ -376,7 +376,7 @@ func kbPost(url, jsonBody string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("HTTP POST: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(io.LimitReader(resp.Body, kbMaxResponseBytes)) if err != nil { diff --git a/src/cmd/hive/audit_pr_agents_test.go b/src/cmd/hive/audit_pr_agents_test.go index 423b6d1c3..84e052e5a 100644 --- a/src/cmd/hive/audit_pr_agents_test.go +++ b/src/cmd/hive/audit_pr_agents_test.go @@ -48,3 +48,18 @@ func TestAuditPRAgents(t *testing.T) { t.Error("agent-less entry must not be mapped") } } + +// Non-required-only red PRs (perma-red Playwright shards on a dependabot +// bump) must not be classed as failing when a required-check set is declared. +func TestAnyRequiredCheckFailing(t *testing.T) { + req := map[string]bool{"build-gate": true, "go test ./...": true} + if anyRequiredCheckFailing([]string{"Test (chromium, shard 1)", "coverage"}, req) { + t.Error("optional-only failures must not count as required-failing") + } + if !anyRequiredCheckFailing([]string{"Test (chromium, shard 1)", "build-gate"}, req) { + t.Error("a failing required check must count") + } + if anyRequiredCheckFailing(nil, req) || anyRequiredCheckFailing([]string{"x"}, nil) { + t.Error("empty inputs must be false") + } +} diff --git a/src/cmd/hive/automerge_sweep_wiring_test.go b/src/cmd/hive/automerge_sweep_wiring_test.go new file mode 100644 index 000000000..77f319565 --- /dev/null +++ b/src/cmd/hive/automerge_sweep_wiring_test.go @@ -0,0 +1,253 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/kubestellar/hive/pkg/config" + "github.com/kubestellar/hive/pkg/github" +) + +// Tests for the eval-loop auto-merge sweep wiring and the duplicate-PR guard +// wiring in cmd/hive/main.go: runAutoMergeSweepIfDue (throttle, lastRun +// stamping, nil-client no-op, error handling), getClaimLedger (lazy sync.Once +// load, corrupt-ledger fallback), and applyDuplicatePRGuard (end-to-end +// suppression of issues claimed by an open hive-authored PR). The sweep and +// guard DECISIONS (which PRs merge, how claims parse) are covered in +// pkg/github; these tests cover only the main.go wiring around them. + +func sweepTestLogger(buf *strings.Builder) *slog.Logger { + if buf == nil { + return slog.New(slog.DiscardHandler) + } + return slog.New(slog.NewTextHandler(buf, nil)) +} + +// newSweepAPI serves the two endpoints runAutoMergeSweepIfDue's sweep touches +// for repo testorg/widget: the labelled-issue listing and (optionally) the PR +// fetch. It counts every request so throttle tests can assert "no API call". +func newSweepAPI(t *testing.T, issuesJSON string, issuesStatus int, requests *atomic.Int64) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/repos/testorg/widget/issues", func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if issuesStatus != http.StatusOK { + w.WriteHeader(issuesStatus) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(issuesJSON)) + }) + mux.HandleFunc("/repos/testorg/widget/pulls/", func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + // Fail the PR fetch so the sweep counts the PR as seen+skipped without + // this test re-driving the whole merge pipeline (covered in pkg/github). + w.WriteHeader(http.StatusInternalServerError) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestRunAutoMergeSweepIfDue_NilClientIsNoop(t *testing.T) { + var lastRun time.Time + runAutoMergeSweepIfDue(context.Background(), nil, nil, &lastRun, sweepTestLogger(nil)) + if !lastRun.IsZero() { + t.Fatalf("nil client must not stamp lastRun, got %v", lastRun) + } +} + +func TestRunAutoMergeSweepIfDue_ThrottledWithinInterval(t *testing.T) { + var requests atomic.Int64 + srv := newSweepAPI(t, `[]`, http.StatusOK, &requests) + ghClient := github.NewClientForTest(srv.URL, "testorg", []string{"widget"}, sweepTestLogger(nil)) + + lastRun := time.Now() + before := lastRun + runAutoMergeSweepIfDue(context.Background(), ghClient, nil, &lastRun, sweepTestLogger(nil)) + + if got := requests.Load(); got != 0 { + t.Fatalf("throttled sweep must make no API calls, got %d", got) + } + if !lastRun.Equal(before) { + t.Fatalf("throttled sweep must not advance lastRun: before=%v after=%v", before, lastRun) + } +} + +func TestRunAutoMergeSweepIfDue_DueSweepStampsLastRunAndCallsAPI(t *testing.T) { + var requests atomic.Int64 + srv := newSweepAPI(t, `[]`, http.StatusOK, &requests) + ghClient := github.NewClientForTest(srv.URL, "testorg", []string{"widget"}, sweepTestLogger(nil)) + + var lastRun time.Time + start := time.Now() + runAutoMergeSweepIfDue(context.Background(), ghClient, nil, &lastRun, sweepTestLogger(nil)) + + if requests.Load() == 0 { + t.Fatal("due sweep made no API calls") + } + if lastRun.Before(start) { + t.Fatalf("due sweep must stamp lastRun, got %v", lastRun) + } +} + +func TestRunAutoMergeSweepIfDue_NilLastRunStillSweeps(t *testing.T) { + var requests atomic.Int64 + srv := newSweepAPI(t, `[]`, http.StatusOK, &requests) + ghClient := github.NewClientForTest(srv.URL, "testorg", []string{"widget"}, sweepTestLogger(nil)) + + runAutoMergeSweepIfDue(context.Background(), ghClient, nil, nil, sweepTestLogger(nil)) + + if requests.Load() == 0 { + t.Fatal("sweep with nil lastRun pointer must still run (and not panic)") + } +} + +func TestRunAutoMergeSweepIfDue_SweepErrorLoggedAndLastRunStamped(t *testing.T) { + var requests atomic.Int64 + srv := newSweepAPI(t, ``, http.StatusInternalServerError, &requests) + ghClient := github.NewClientForTest(srv.URL, "testorg", []string{"widget"}, sweepTestLogger(nil)) + + var buf strings.Builder + var lastRun time.Time + runAutoMergeSweepIfDue(context.Background(), ghClient, nil, &lastRun, sweepTestLogger(&buf)) + + if !strings.Contains(buf.String(), "automerge sweep failed") { + t.Fatalf("sweep API failure must be logged as a warning, log:\n%s", buf.String()) + } + if strings.Contains(buf.String(), "automerge sweep complete") { + t.Fatalf("failed sweep must not log completion, log:\n%s", buf.String()) + } + if lastRun.IsZero() { + t.Fatal("lastRun must be stamped before the sweep runs, so a failing sweep still backs off") + } +} + +func TestRunAutoMergeSweepIfDue_LogsCompletionWithSeenAndSkipped(t *testing.T) { + var requests atomic.Int64 + // One labelled open issue that IS a pull request; its PR fetch 500s, so the + // sweep records it as seen+skipped and merges nothing. + issues := `[{"number":7,"pull_request":{"url":"pr-url"}},{"number":8}]` + srv := newSweepAPI(t, issues, http.StatusOK, &requests) + ghClient := github.NewClientForTest(srv.URL, "testorg", []string{"widget"}, sweepTestLogger(nil)) + + var buf strings.Builder + var lastRun time.Time + runAutoMergeSweepIfDue(context.Background(), ghClient, nil, &lastRun, sweepTestLogger(&buf)) + + log := buf.String() + if !strings.Contains(log, "automerge sweep complete") { + t.Fatalf("sweep with seen>0 must log completion, log:\n%s", log) + } + if !strings.Contains(log, "seen=1") || !strings.Contains(log, "merged=0") || !strings.Contains(log, "skipped=1") { + t.Fatalf("completion log must report seen=1 merged=0 skipped=1 (issue 8 is not a PR), log:\n%s", log) + } +} + +// resetClaimLedgerForTest resets the package-level sync.Once and pre-installs +// a ledger backed by a temp file, so guard tests never touch the hardwired +// /data ledger path (github.ClaimLedgerPath is a const). Process state is +// restored on cleanup. +func resetClaimLedgerForTest(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "pr-claims.json") + claimLedgerOnce = sync.Once{} + claimLedgerOnce.Do(func() { + claimLedger = github.NewClaimLedger(path, sweepTestLogger(nil)) + }) + t.Cleanup(func() { + claimLedgerOnce = sync.Once{} + claimLedger = nil + }) + return path +} + +func TestGetClaimLedger_ReturnsSamePointerOnEveryCall(t *testing.T) { + resetClaimLedgerForTest(t) + first := getClaimLedger(sweepTestLogger(nil)) + second := getClaimLedger(sweepTestLogger(nil)) + if first == nil { + t.Fatal("getClaimLedger must never return nil once the Once has fired") + } + if first != second { + t.Fatal("getClaimLedger must return the same ledger pointer on every call (sync.Once publication)") + } +} + +// guardConfig builds the minimal config applyDuplicatePRGuard consults: +// project identity (whose PRs count as "ours") and escalation disabled so the +// red+stale release valve stays out of these wiring tests. +func guardConfig() *config.Config { + return &config.Config{ + Project: config.ProjectConfig{Org: "testorg", AIAuthor: "hive-ai"}, + Escalation: config.EscalationConfig{Disabled: true}, + } +} + +func TestApplyDuplicatePRGuard_SuppressesIssueClaimedByHivePR(t *testing.T) { + ledgerPath := resetClaimLedgerForTest(t) + + // One open PR authored by the hive's ai_author whose title claims issue 12. + mux := http.NewServeMux() + mux.HandleFunc("/repos/testorg/widget/pulls", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"number":9,"state":"open","title":"Fixes #12","body":"","user":{"login":"hive-ai"},"head":{"ref":"quality/fix"},"html_url":"pr-9-url"}]`)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + ghClient := github.NewClientForTest(srv.URL, "testorg", []string{"widget"}, sweepTestLogger(nil)) + + actionable := &github.ActionableResult{ + Issues: github.IssueResult{Items: []github.Issue{ + {Repo: "widget", Number: 12, Title: "claimed"}, + {Repo: "widget", Number: 13, Title: "unclaimed"}, + }}, + } + applyDuplicatePRGuard(context.Background(), guardConfig(), ghClient, actionable, sweepTestLogger(nil)) + + if len(actionable.Issues.Items) != 1 || actionable.Issues.Items[0].Number != 13 { + t.Fatalf("issue 12 (claimed by hive PR 9) must be suppressed and 13 kept, got %+v", actionable.Issues.Items) + } + if _, err := os.Stat(ledgerPath); err != nil { + t.Fatalf("guard must persist the reconciled ledger to %s: %v", ledgerPath, err) + } +} + +func TestApplyDuplicatePRGuard_FetchFailureFailsClosedWithoutSuppressing(t *testing.T) { + resetClaimLedgerForTest(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + ghClient := github.NewClientForTest(srv.URL, "testorg", []string{"widget"}, sweepTestLogger(nil)) + + actionable := &github.ActionableResult{ + Issues: github.IssueResult{Items: []github.Issue{ + {Repo: "widget", Number: 12, Title: "kept"}, + }}, + } + var buf strings.Builder + applyDuplicatePRGuard(context.Background(), guardConfig(), ghClient, actionable, sweepTestLogger(&buf)) + + if len(actionable.Issues.Items) != 1 { + t.Fatalf("a claim-fetch failure with an empty ledger must suppress nothing, got %+v", actionable.Issues.Items) + } + if !strings.Contains(buf.String(), "claim fetch failed") { + t.Fatalf("claim-fetch failure must be logged, log:\n%s", buf.String()) + } +} diff --git a/src/cmd/hive/budget_alerts_test.go b/src/cmd/hive/budget_alerts_test.go new file mode 100644 index 000000000..ea5e8ed09 --- /dev/null +++ b/src/cmd/hive/budget_alerts_test.go @@ -0,0 +1,186 @@ +package main + +import ( + "io" + "log/slog" + "strings" + "testing" + + "github.com/kubestellar/hive/pkg/config" + "github.com/kubestellar/hive/pkg/dashboard" + "github.com/kubestellar/hive/pkg/governor" + "github.com/kubestellar/hive/pkg/notify" +) + +// Tests for applyBudgetAlerts (cmd/hive/main.go), which was previously +// uncovered: it is the only bridge from governor budget threshold crossings +// to the dashboard system alerts and operator notifications, so a regression +// here silently drops the "budget warning" / "budget exhausted" banners. + +func budgetAlertsFixture(t *testing.T) (*governor.Governor, *dashboard.Server, *notify.Notifier) { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + gov := governor.New(config.GovernorConfig{}, map[string]config.AgentConfig{}, logger) + srv := dashboard.NewServer(0, logger) + // Empty notifications config: Send is a no-op for every channel, so the + // notifier path is exercised without any network traffic. + notifier := notify.New(config.NotificationsConfig{}, logger) + return gov, srv, notifier +} + +// publishedAlerts publishes a status snapshot and returns the system alerts it +// carries — the same read path the dashboard frontend consumes. +func publishedAlerts(t *testing.T, srv *dashboard.Server) []dashboard.SystemAlert { + t.Helper() + payload := &dashboard.StatusPayload{} + if !srv.UpdateStatusIfFresh(payload, srv.BeginStatusSnapshot()) { + t.Fatal("status snapshot unexpectedly dropped as stale") + } + return payload.SystemAlerts +} + +func alertByID(alerts []dashboard.SystemAlert, id string) (dashboard.SystemAlert, bool) { + for _, a := range alerts { + if a.ID == id { + return a, true + } + } + return dashboard.SystemAlert{}, false +} + +func TestApplyBudgetAlertsWarnCrossingRaisesWarningAlert(t *testing.T) { + gov, srv, notifier := budgetAlertsFixture(t) + gov.SetBudgetLimit(1000) + + // First update opens the window (baseline 0), second crosses the 90% warn + // threshold without exhausting the budget. + gov.UpdateBudgetFromTotals(0, nil, nil) + trans := gov.UpdateBudgetFromTotals(950, nil, nil) + if !trans.WarnCrossed || trans.ExhaustedCrossed { + t.Fatalf("fixture: WarnCrossed=%v ExhaustedCrossed=%v, want true/false", trans.WarnCrossed, trans.ExhaustedCrossed) + } + + applyBudgetAlerts(gov, trans, srv, notifier) + + alerts := publishedAlerts(t, srv) + warn, ok := alertByID(alerts, budgetWarnAlertID) + if !ok { + t.Fatalf("no %q alert published, alerts: %+v", budgetWarnAlertID, alerts) + } + if warn.Severity != "warning" { + t.Errorf("warn alert severity = %q, want %q", warn.Severity, "warning") + } + if !strings.Contains(warn.Message, "950 of 1000 tokens used") { + t.Errorf("warn alert message = %q, want spend/limit figures", warn.Message) + } + if _, ok := alertByID(alerts, budgetExhaustedAlertID); ok { + t.Error("exhausted alert raised on a warn-only crossing") + } +} + +func TestApplyBudgetAlertsExhaustedCrossingRaisesErrorAlert(t *testing.T) { + gov, srv, notifier := budgetAlertsFixture(t) + gov.SetBudgetLimit(1000) + + gov.UpdateBudgetFromTotals(0, nil, nil) + trans := gov.UpdateBudgetFromTotals(1000, nil, nil) + if !trans.ExhaustedCrossed { + t.Fatalf("fixture: ExhaustedCrossed=%v, want true", trans.ExhaustedCrossed) + } + + applyBudgetAlerts(gov, trans, srv, notifier) + + alerts := publishedAlerts(t, srv) + exhausted, ok := alertByID(alerts, budgetExhaustedAlertID) + if !ok { + t.Fatalf("no %q alert published, alerts: %+v", budgetExhaustedAlertID, alerts) + } + if exhausted.Severity != "error" { + t.Errorf("exhausted alert severity = %q, want %q", exhausted.Severity, "error") + } + if !strings.Contains(exhausted.Message, "agent kicks suspended") { + t.Errorf("exhausted alert message = %q, want kick-suspension notice", exhausted.Message) + } +} + +// Crossings are one-shot per window: a second cycle at the same spend keeps +// the standing alert but must not re-raise (Crossed stays false while Active +// stays true, so the alert is neither duplicated nor cleared). +func TestApplyBudgetAlertsSteadyStateKeepsAlertWithoutReRaising(t *testing.T) { + gov, srv, notifier := budgetAlertsFixture(t) + gov.SetBudgetLimit(1000) + + gov.UpdateBudgetFromTotals(0, nil, nil) + applyBudgetAlerts(gov, gov.UpdateBudgetFromTotals(950, nil, nil), srv, notifier) + + trans := gov.UpdateBudgetFromTotals(960, nil, nil) + if trans.WarnCrossed { + t.Fatal("fixture: WarnCrossed on second cycle, want one-shot semantics") + } + if !trans.WarnActive { + t.Fatal("fixture: WarnActive false while still over threshold") + } + applyBudgetAlerts(gov, trans, srv, notifier) + + alerts := publishedAlerts(t, srv) + count := 0 + for _, a := range alerts { + if a.ID == budgetWarnAlertID { + count++ + } + } + if count != 1 { + t.Errorf("warn alert count = %d after steady-state cycle, want exactly 1", count) + } +} + +// When a threshold no longer applies (here: the operator raised the limit), +// the standing alert must be cleared — a stale "budget exhausted" banner +// after the limit was raised is exactly the misreporting this function's +// clear branches exist to prevent. +func TestApplyBudgetAlertsClearsAlertsWhenThresholdNoLongerApplies(t *testing.T) { + gov, srv, notifier := budgetAlertsFixture(t) + gov.SetBudgetLimit(1000) + + gov.UpdateBudgetFromTotals(0, nil, nil) + applyBudgetAlerts(gov, gov.UpdateBudgetFromTotals(1000, nil, nil), srv, notifier) + if _, ok := alertByID(publishedAlerts(t, srv), budgetExhaustedAlertID); !ok { + t.Fatal("fixture: exhausted alert not raised") + } + + gov.SetBudgetLimit(10000) + trans := gov.UpdateBudgetFromTotals(1000, nil, nil) + if trans.WarnActive || trans.ExhaustedActive { + t.Fatalf("fixture: thresholds still active after limit raise: %+v", trans) + } + applyBudgetAlerts(gov, trans, srv, notifier) + + alerts := publishedAlerts(t, srv) + if _, ok := alertByID(alerts, budgetExhaustedAlertID); ok { + t.Error("exhausted alert not cleared after limit raise") + } + if _, ok := alertByID(alerts, budgetWarnAlertID); ok { + t.Error("warn alert not cleared after limit raise") + } +} + +// WeeklyLimit == 0 disables budgeting: no transitions fire and no alerts may +// appear, while any stale alerts from a previously-enabled budget are cleared. +func TestApplyBudgetAlertsBudgetingDisabledClearsAndRaisesNothing(t *testing.T) { + gov, srv, notifier := budgetAlertsFixture(t) + gov.SetBudgetLimit(1000) + + gov.UpdateBudgetFromTotals(0, nil, nil) + applyBudgetAlerts(gov, gov.UpdateBudgetFromTotals(950, nil, nil), srv, notifier) + + gov.SetBudgetLimit(0) + trans := gov.UpdateBudgetFromTotals(2000, nil, nil) + if trans.WarnActive || trans.ExhaustedActive || trans.WarnCrossed || trans.ExhaustedCrossed { + t.Fatalf("fixture: transitions fired with budgeting disabled: %+v", trans) + } + applyBudgetAlerts(gov, trans, srv, notifier) + + if alerts := publishedAlerts(t, srv); len(alerts) != 0 { + t.Errorf("alerts remain with budgeting disabled: %+v", alerts) + } +} diff --git a/src/cmd/hive/config_overrides_replay_test.go b/src/cmd/hive/config_overrides_replay_test.go new file mode 100644 index 000000000..13e11c37f --- /dev/null +++ b/src/cmd/hive/config_overrides_replay_test.go @@ -0,0 +1,218 @@ +package main + +import ( + "testing" + "time" + + "github.com/kubestellar/hive/pkg/config" + "github.com/kubestellar/hive/pkg/dashboard" + "github.com/kubestellar/hive/pkg/github" + "github.com/kubestellar/hive/pkg/hub" + "github.com/kubestellar/hive/pkg/snapshot" +) + +func intPtr(v int) *int { return &v } +func boolPtr(v bool) *bool { return &v } + +// applyConfigOverrides must replay every persisted override field onto the +// loaded config. The sensing_login branch is covered separately in +// login_patterns_overrides_test.go; this exercises everything else. +func TestApplyConfigOverrides_ReplaysAllFields(t *testing.T) { + cfg := &config.Config{ + Governor: config.GovernorConfig{ + EvalIntervalS: 60, + Modes: map[string]config.ModeConfig{ + "active": {Threshold: 1}, + }, + }, + } + applyConfigOverrides(cfg, &snapshot.ConfigOverrides{ + ProjectRepos: []string{"org/repo-a", "org/repo-b"}, + EvalIntervalS: intPtr(120), + Thresholds: map[string]int{"active": 7, "unknown-mode": 99}, + SensingGHRate: []string{"rate limit hit"}, + SensingCLIExclude: []string{"harmless"}, + SensingTTL: intPtr(300), + SensingPullback: intPtr(45), + ExemptLabels: []string{"urgent", "security"}, + NtfyServer: "https://ntfy.example", + NtfyTopic: "hive-alerts", + DiscordWebhook: "https://discord.example/webhook", + HealthcheckInterval: intPtr(240), + RestartCooldown: intPtr(90), + ModelLock: boolPtr(true), + LogMaxSizeMB: intPtr(64), + LogMaxAgeDays: intPtr(14), + LogMaxBackups: intPtr(5), + LogCompress: boolPtr(true), + LogLevel: "debug", + }) + + if got := cfg.Project.Repos; len(got) != 2 || got[0] != "org/repo-a" || got[1] != "org/repo-b" { + t.Errorf("Project.Repos = %v, want the override list", got) + } + if cfg.Governor.EvalIntervalS != 120 { + t.Errorf("EvalIntervalS = %d, want 120", cfg.Governor.EvalIntervalS) + } + if got := cfg.Governor.Modes["active"].Threshold; got != 7 { + t.Errorf("Modes[active].Threshold = %d, want 7", got) + } + if _, ok := cfg.Governor.Modes["unknown-mode"]; ok { + t.Error("a threshold for an unconfigured mode must not invent the mode") + } + if got := cfg.Governor.Sensing.GHRatePatterns; len(got) != 1 || got[0] != "rate limit hit" { + t.Errorf("Sensing.GHRatePatterns = %v", got) + } + if got := cfg.Governor.Sensing.CLIExcludePatterns; len(got) != 1 || got[0] != "harmless" { + t.Errorf("Sensing.CLIExcludePatterns = %v", got) + } + if cfg.Governor.Sensing.TTLSeconds != 300 { + t.Errorf("Sensing.TTLSeconds = %d, want 300", cfg.Governor.Sensing.TTLSeconds) + } + if cfg.Governor.Sensing.PullbackSeconds != 45 { + t.Errorf("Sensing.PullbackSeconds = %d, want 45", cfg.Governor.Sensing.PullbackSeconds) + } + if got := cfg.Governor.Labels.Exempt; len(got) != 2 || got[0] != "urgent" || got[1] != "security" { + t.Errorf("Labels.Exempt = %v", got) + } + if cfg.Notifications.Ntfy == nil { + t.Fatal("Ntfy overrides must create the Ntfy config when absent") + } + if cfg.Notifications.Ntfy.Server != "https://ntfy.example" || cfg.Notifications.Ntfy.Topic != "hive-alerts" { + t.Errorf("Ntfy = %+v", cfg.Notifications.Ntfy) + } + if cfg.Notifications.Discord == nil { + t.Fatal("Discord webhook override must create the Discord config when absent") + } + if cfg.Notifications.Discord.Webhook != "https://discord.example/webhook" { + t.Errorf("Discord.Webhook = %q", cfg.Notifications.Discord.Webhook) + } + if cfg.Governor.Health.HealthcheckInterval != 240 { + t.Errorf("Health.HealthcheckInterval = %d, want 240", cfg.Governor.Health.HealthcheckInterval) + } + if cfg.Governor.Health.RestartCooldown != 90 { + t.Errorf("Health.RestartCooldown = %d, want 90", cfg.Governor.Health.RestartCooldown) + } + if !cfg.Governor.Health.ModelLock { + t.Error("Health.ModelLock not applied") + } + if cfg.Governor.Logging.MaxSizeMB != 64 || cfg.Governor.Logging.MaxAgeDays != 14 || + cfg.Governor.Logging.MaxBackups != 5 || !cfg.Governor.Logging.Compress || + cfg.Governor.Logging.Level != "debug" { + t.Errorf("Logging = %+v", cfg.Governor.Logging) + } +} + +// Empty overrides carry no operator intent and must leave the loaded config +// untouched — including NOT materializing notification configs. +func TestApplyConfigOverrides_EmptyOverridesAreNoOp(t *testing.T) { + cfg := &config.Config{ + Governor: config.GovernorConfig{ + EvalIntervalS: 60, + Modes: map[string]config.ModeConfig{ + "active": {Threshold: 3}, + }, + Sensing: config.SensingConfig{ + TTLSeconds: 100, + PullbackSeconds: 20, + }, + }, + } + cfg.Project.Repos = []string{"org/keep"} + + applyConfigOverrides(cfg, &snapshot.ConfigOverrides{}) + + if len(cfg.Project.Repos) != 1 || cfg.Project.Repos[0] != "org/keep" { + t.Errorf("Project.Repos mutated: %v", cfg.Project.Repos) + } + if cfg.Governor.EvalIntervalS != 60 { + t.Errorf("EvalIntervalS mutated: %d", cfg.Governor.EvalIntervalS) + } + if cfg.Governor.Modes["active"].Threshold != 3 { + t.Errorf("threshold mutated: %d", cfg.Governor.Modes["active"].Threshold) + } + if cfg.Governor.Sensing.TTLSeconds != 100 || cfg.Governor.Sensing.PullbackSeconds != 20 { + t.Errorf("sensing mutated: %+v", cfg.Governor.Sensing) + } + if cfg.Notifications.Ntfy != nil { + t.Error("empty overrides must not materialize an Ntfy config") + } + if cfg.Notifications.Discord != nil { + t.Error("empty overrides must not materialize a Discord config") + } +} + +// A partial ntfy override (topic only) must update just that field on an +// existing config, not blank its sibling. +func TestApplyConfigOverrides_PartialNtfyKeepsExistingServer(t *testing.T) { + cfg := &config.Config{} + cfg.Notifications.Ntfy = &config.NtfyConfig{Server: "https://keep.example", Topic: "old"} + + applyConfigOverrides(cfg, &snapshot.ConfigOverrides{NtfyTopic: "new-topic"}) + + if cfg.Notifications.Ntfy.Server != "https://keep.example" { + t.Errorf("Server blanked by topic-only override: %q", cfg.Notifications.Ntfy.Server) + } + if cfg.Notifications.Ntfy.Topic != "new-topic" { + t.Errorf("Topic = %q, want new-topic", cfg.Notifications.Ntfy.Topic) + } +} + +// providerLimitHeartbeatFields must prefer the proxy's spending-limit latch +// over pane-derived quota, with rebuff-count-aware phrasing. +func TestProviderLimitHeartbeatFields_SingleRebuffPhrasing(t *testing.T) { + dashboard.SetInferenceBudgetProvider(func() (string, time.Time, time.Time, int) { + return "credit balance too low", time.Now(), time.Now(), 1 + }) + t.Cleanup(func() { dashboard.SetInferenceBudgetProvider(nil) }) + + reason, rebuffs := providerLimitHeartbeatFields([]hub.AgentSummary{ + {Name: "guide", State: "running", QuotaExhausted: true}, + }) + if rebuffs != 1 { + t.Fatalf("rebuffs = %d, want 1", rebuffs) + } + want := "provider spending limit reached — credit balance too low" + if reason != want { + t.Fatalf("reason = %q, want %q", reason, want) + } +} + +func TestProviderLimitHeartbeatFields_MultiRebuffPhrasing(t *testing.T) { + dashboard.SetInferenceBudgetProvider(func() (string, time.Time, time.Time, int) { + return "credit balance too low", time.Now(), time.Now(), 4 + }) + t.Cleanup(func() { dashboard.SetInferenceBudgetProvider(nil) }) + + reason, rebuffs := providerLimitHeartbeatFields(nil) + if rebuffs != 4 { + t.Fatalf("rebuffs = %d, want 4", rebuffs) + } + want := "provider spending limit reached — 4 refused calls: credit balance too low" + if reason != want { + t.Fatalf("reason = %q, want %q", reason, want) + } +} + +// actionableIssueRef's identityless fallbacks: when worksource.Ref.Key() +// yields no identity, the ref must fall back to the bare repo, then the +// external ID, without ever fabricating "#0". (The repo#N and repo!externalID +// key paths are covered by TestActionableIssueRefPinsGitHubAndWorksourceIdentity.) +func TestActionableIssueRef_IdentitylessFallbacks(t *testing.T) { + cases := []struct { + name string + issue github.Issue + want string + }{ + {"repo only", github.Issue{Repo: "org/repo"}, "org/repo"}, + {"external id only", github.Issue{ExternalID: "ENG-9"}, "ENG-9"}, + {"empty issue", github.Issue{}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := actionableIssueRef(tc.issue); got != tc.want { + t.Errorf("actionableIssueRef(%+v) = %q, want %q", tc.issue, got, tc.want) + } + }) + } +} diff --git a/src/cmd/hive/escalation_sweep_test.go b/src/cmd/hive/escalation_sweep_test.go new file mode 100644 index 000000000..1e0421eb8 --- /dev/null +++ b/src/cmd/hive/escalation_sweep_test.go @@ -0,0 +1,308 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/kubestellar/hive/pkg/config" + "github.com/kubestellar/hive/pkg/escalation" + "github.com/kubestellar/hive/pkg/github" + "github.com/kubestellar/hive/pkg/notify" +) + +// escalationSweepServer is a fake GitHub API capturing the two escalation +// side effects runEscalationSweep performs: the evidence comment and the +// needs-human label. Per-path status overrides let a test fail one effect. +type escalationSweepServer struct { + mu sync.Mutex + comments []string // comment bodies, in order + labels []string // labels added, flattened, in order + paths []string // request paths, in order + + failComments bool + failLabels bool +} + +func (s *escalationSweepServer) handler(t *testing.T) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + s.paths = append(s.paths, r.URL.Path) + body, _ := io.ReadAll(r.Body) + switch { + case strings.HasSuffix(r.URL.Path, "/comments"): + if s.failComments { + http.Error(w, `{"message":"boom"}`, http.StatusInternalServerError) + return + } + var payload struct { + Body string `json:"body"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Errorf("bad comment payload: %v", err) + } + s.comments = append(s.comments, payload.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":1}`)) + case strings.HasSuffix(r.URL.Path, "/labels"): + if s.failLabels { + http.Error(w, `{"message":"boom"}`, http.StatusInternalServerError) + return + } + var names []string + if err := json.Unmarshal(body, &names); err != nil { + // go-github may send {"labels": [...]} depending on version. + var wrapped struct { + Labels []string `json:"labels"` + } + if err2 := json.Unmarshal(body, &wrapped); err2 != nil { + t.Errorf("bad labels payload %q: %v / %v", body, err, err2) + } + names = wrapped.Labels + } + s.labels = append(s.labels, names...) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[]`)) + default: + t.Errorf("unexpected GitHub API call: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }) +} + +func newEscalationSweepClient(t *testing.T) (*github.Client, *escalationSweepServer) { + t.Helper() + fake := &escalationSweepServer{} + server := httptest.NewServer(fake.handler(t)) + t.Cleanup(server.Close) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + return github.NewClientForTest(server.URL, "acme", []string{"widgets"}, logger), fake +} + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestRunEscalationSweepGates(t *testing.T) { + newTestEscalationStore(t) + client, fake := newEscalationSweepClient(t) + logger := discardLogger() + actionable := actionableWith(redPR("widgets", 7, "hive-agent", "sha-1")) + + t.Run("disabled config returns empty and calls nothing", func(t *testing.T) { + cfg := escalationTestConfig() + cfg.Escalation.Disabled = true + got := runEscalationSweep(context.Background(), cfg, client, actionable, nil, logger) + if len(got) != 0 { + t.Fatalf("escalated = %v, want empty when escalation is disabled", got) + } + }) + + t.Run("nil github client returns empty", func(t *testing.T) { + got := runEscalationSweep(context.Background(), escalationTestConfig(), nil, actionable, nil, logger) + if len(got) != 0 { + t.Fatalf("escalated = %v, want empty with nil client", got) + } + }) + + t.Run("nil actionable returns empty", func(t *testing.T) { + got := runEscalationSweep(context.Background(), escalationTestConfig(), client, nil, nil, logger) + if len(got) != 0 { + t.Fatalf("escalated = %v, want empty with nil actionable set", got) + } + }) + + if len(fake.paths) != 0 { + t.Fatalf("gated sweeps must not touch the GitHub API, saw %v", fake.paths) + } +} + +// Three distinct red head SHAs cross the default threshold: the sweep must +// post the evidence comment, add the needs-human label, notify, and mark the +// PR escalated so the side effects never repeat. +func TestRunEscalationSweepEscalatesAtThresholdOnce(t *testing.T) { + store, _ := newTestEscalationStore(t) + client, fake := newEscalationSweepClient(t) + cfg := escalationTestConfig() + logger := discardLogger() + + pr := func(sha string) *github.ActionableResult { + p := redPR("widgets", 7, "hive-agent", sha) + p.CIFailureExcerpt = "TestFoo: want 2, got 3" + return actionableWith(p) + } + + // Passes 1 and 2: below threshold, no side effects, nothing escalated. + for i, sha := range []string{"sha-1", "sha-2"} { + got := runEscalationSweep(context.Background(), cfg, client, pr(sha), nil, logger) + if len(got) != 0 { + t.Fatalf("pass %d: escalated = %v, want empty below threshold", i+1, got) + } + } + if len(fake.paths) != 0 { + t.Fatalf("below threshold the sweep must not call GitHub, saw %v", fake.paths) + } + + // Pass 3: third distinct red SHA crosses DefaultThreshold. A notifier + // with no channels configured exercises the notify branch as a no-op. + notifier := notify.New(config.NotificationsConfig{}, logger) + got := runEscalationSweep(context.Background(), cfg, client, pr("sha-3"), notifier, logger) + key := escalation.Key("acme/widgets", 7) + if !got[key] { + t.Fatalf("escalated = %v, want %q true at threshold", got, key) + } + if len(fake.comments) != 1 { + t.Fatalf("comments = %d, want exactly one escalation comment", len(fake.comments)) + } + comment := fake.comments[0] + for _, want := range []string{"3 distinct fix attempts", "test", "TestFoo: want 2, got 3"} { + if !strings.Contains(comment, want) { + t.Errorf("escalation comment missing %q:\n%s", want, comment) + } + } + if len(fake.labels) != 1 || fake.labels[0] != escalation.NeedsHumanLabel { + t.Fatalf("labels = %v, want exactly [%q]", fake.labels, escalation.NeedsHumanLabel) + } + for _, p := range fake.paths { + if !strings.HasPrefix(p, "/repos/acme/widgets/issues/7/") { + t.Errorf("side effect hit %q, want the org-qualified repo acme/widgets PR 7", p) + } + } + if store.Attempts("acme/widgets", 7) != 3 { + t.Errorf("store attempts = %d, want 3", store.Attempts("acme/widgets", 7)) + } + + // Pass 4: already escalated — still reported, but no repeat side effects. + got = runEscalationSweep(context.Background(), cfg, client, pr("sha-4"), nil, logger) + if !got[key] { + t.Fatalf("escalated = %v, want %q to stay true after escalation", got, key) + } + if len(fake.comments) != 1 || len(fake.labels) != 1 { + t.Fatalf("side effects repeated: %d comments, %d labels, want 1 and 1", + len(fake.comments), len(fake.labels)) + } +} + +// A failed comment must NOT mark the PR escalated: the whole point is that +// the evidence reaches a human, so the sweep retries on the next pass. +func TestRunEscalationSweepRetriesCommentNextPass(t *testing.T) { + newTestEscalationStore(t) + client, fake := newEscalationSweepClient(t) + cfg := escalationTestConfig() + logger := discardLogger() + + sweep := func(sha string) map[string]bool { + return runEscalationSweep(context.Background(), cfg, client, + actionableWith(redPR("widgets", 9, "helper[bot]", sha)), nil, logger) + } + sweep("sha-1") + sweep("sha-2") + + fake.failComments = true + got := sweep("sha-3") + key := escalation.Key("acme/widgets", 9) + if !got[key] { + t.Fatalf("escalated = %v, want %q true even when the comment fails", got, key) + } + if len(fake.labels) != 0 { + t.Fatalf("labels = %v, want none until the comment lands", fake.labels) + } + + // Next pass: comment succeeds, label lands, PR is finally marked. + fake.failComments = false + got = sweep("sha-3") + if !got[key] { + t.Fatalf("escalated = %v, want %q true on the retry pass", got, key) + } + if len(fake.comments) != 1 { + t.Fatalf("comments = %d, want the retried comment to land exactly once", len(fake.comments)) + } + if len(fake.labels) != 1 || fake.labels[0] != escalation.NeedsHumanLabel { + t.Fatalf("labels = %v, want [%q] after the retry", fake.labels, escalation.NeedsHumanLabel) + } + + // And once marked, a further pass repeats nothing. + sweep("sha-3") + if len(fake.comments) != 1 || len(fake.labels) != 1 { + t.Fatalf("side effects repeated after MarkEscalated: %d comments, %d labels", + len(fake.comments), len(fake.labels)) + } +} + +// A label failure is logged but non-fatal: the comment carried the evidence, +// so the PR is still marked escalated and never re-commented. +func TestRunEscalationSweepLabelFailureIsNonFatal(t *testing.T) { + newTestEscalationStore(t) + client, fake := newEscalationSweepClient(t) + cfg := escalationTestConfig() + logger := discardLogger() + + sweep := func(sha string) map[string]bool { + return runEscalationSweep(context.Background(), cfg, client, + actionableWith(redPR("widgets", 4, "hive-agent", sha)), nil, logger) + } + sweep("sha-1") + sweep("sha-2") + + fake.failLabels = true + got := sweep("sha-3") + key := escalation.Key("acme/widgets", 4) + if !got[key] { + t.Fatalf("escalated = %v, want %q true despite the label failure", got, key) + } + if len(fake.comments) != 1 { + t.Fatalf("comments = %d, want the evidence comment to have landed", len(fake.comments)) + } + + sweep("sha-3") + if len(fake.comments) != 1 { + t.Fatalf("comments = %d, want no repeat after a label-only failure", len(fake.comments)) + } +} + +// Human-authored PRs are never escalation candidates, and a stored excerpt +// backfills a crossing pass that observed none. +func TestRunEscalationSweepAuthorGateAndExcerptFallback(t *testing.T) { + newTestEscalationStore(t) + client, fake := newEscalationSweepClient(t) + cfg := escalationTestConfig() + logger := discardLogger() + + // Human-authored red PR: ignored entirely, forever. + human := redPR("widgets", 11, "jane-dev", "sha-h1") + for _, sha := range []string{"sha-h1", "sha-h2", "sha-h3", "sha-h4"} { + human.HeadSHA = sha + got := runEscalationSweep(context.Background(), cfg, client, actionableWith(human), nil, logger) + if len(got) != 0 { + t.Fatalf("escalated = %v, want empty for a human-authored PR", got) + } + } + if len(fake.paths) != 0 { + t.Fatalf("human PRs must not trigger API calls, saw %v", fake.paths) + } + + // Agent PR carries an excerpt on early passes but not on the crossing + // pass: the comment must fall back to the excerpt stored in the ledger. + withExcerpt := redPR("widgets", 12, "hive-agent", "sha-1") + withExcerpt.CIFailureExcerpt = "panic: index out of range" + runEscalationSweep(context.Background(), cfg, client, actionableWith(withExcerpt), nil, logger) + withExcerpt.HeadSHA = "sha-2" + runEscalationSweep(context.Background(), cfg, client, actionableWith(withExcerpt), nil, logger) + + bare := redPR("widgets", 12, "hive-agent", "sha-3") // no excerpt this pass + got := runEscalationSweep(context.Background(), cfg, client, actionableWith(bare), nil, logger) + if !got[escalation.Key("acme/widgets", 12)] { + t.Fatalf("escalated = %v, want acme/widgets#12 true", got) + } + if len(fake.comments) != 1 || !strings.Contains(fake.comments[0], "panic: index out of range") { + t.Fatalf("comment must carry the ledger's stored excerpt, got: %v", fake.comments) + } +} diff --git a/src/cmd/hive/escalation_writer_test.go b/src/cmd/hive/escalation_writer_test.go new file mode 100644 index 000000000..ef1291807 --- /dev/null +++ b/src/cmd/hive/escalation_writer_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/kubestellar/hive/pkg/escalation" + "github.com/kubestellar/hive/pkg/forge" +) + +// fakeIssueWriter is a non-GitHub forge.IssueWriter recording the two writes +// the escalation sweep performs. +type fakeIssueWriter struct { + comments []string // "repo#number: body" + labels []string // "repo#number: label,label" +} + +func (f *fakeIssueWriter) CreateIssueComment(_ context.Context, repo string, number int, body string) error { + f.comments = append(f.comments, escalation.Key(repo, number)+": "+body) + return nil +} + +func (f *fakeIssueWriter) AddLabels(_ context.Context, repo string, number int, labels []string) error { + f.labels = append(f.labels, escalation.Key(repo, number)+": "+strings.Join(labels, ",")) + return nil +} + +var _ forge.IssueWriter = (*fakeIssueWriter)(nil) + +// TestRunEscalationSweepWritesThroughANonGitHubForge is the thesis of +// kubestellar/hive#5259 in one test: the sweep's escalation actions land on +// whatever forge the hive is configured for, not on GitHub specifically. +// +// The sibling tests in escalation_sweep_test.go pin the same behavior against a +// fake GitHub API and still pass unchanged, which is the other half of the +// claim — a GitHub hive's path did not move. This one pins that a GitLab or +// Gitea hive, whose writer is a pkg/forge adapter rather than *github.Client, +// gets the same two writes. +func TestRunEscalationSweepWritesThroughANonGitHubForge(t *testing.T) { + newTestEscalationStore(t) + cfg := escalationTestConfig() + cfg.Escalation.Threshold = 2 + w := &fakeIssueWriter{} + ctx := context.Background() + prKey := escalation.Key("acme/widgets", 7) + + // One red SHA is below the threshold: nothing is written to the forge. + if got := runEscalationSweep(ctx, cfg, w, + actionableWith(redPR("widgets", 7, "hive-agent", "sha-1")), nil, discardLogger()); len(got) != 0 { + t.Fatalf("escalated = %v, want empty below the threshold", got) + } + if len(w.comments) != 0 || len(w.labels) != 0 { + t.Fatalf("below threshold the sweep must not write: comments=%v labels=%v", w.comments, w.labels) + } + + // A second distinct red SHA crosses it: evidence comment, then the label. + got := runEscalationSweep(ctx, cfg, w, + actionableWith(redPR("widgets", 7, "hive-agent", "sha-2")), nil, discardLogger()) + if !got[prKey] { + t.Fatalf("escalated = %v, want %s marked escalated", got, prKey) + } + if len(w.comments) != 1 || !strings.HasPrefix(w.comments[0], prKey+": ") { + t.Fatalf("comments = %v, want one evidence comment on %s", w.comments, prKey) + } + if want := prKey + ": " + escalation.NeedsHumanLabel; len(w.labels) != 1 || w.labels[0] != want { + t.Fatalf("labels = %v, want [%q]", w.labels, want) + } +} diff --git a/src/cmd/hive/forgewire.go b/src/cmd/hive/forgewire.go new file mode 100644 index 000000000..a5cdf044f --- /dev/null +++ b/src/cmd/hive/forgewire.go @@ -0,0 +1,99 @@ +package main + +import ( + "log/slog" + "os" + + "github.com/kubestellar/hive/pkg/config" + "github.com/kubestellar/hive/pkg/forge" + "github.com/kubestellar/hive/pkg/github" +) + +// This file wires pkg/forge onto the live governor path — the same structure +// hookwire.go and celwire.go use for their packages. +// +// pkg/forge ships GitHub, GitLab and Gitea adapters with the read path and the +// core write path implemented and tested, and pkg/config already carries the +// selector (project.forge) and the per-forge endpoint/token-env settings. What +// was missing was any production caller: every write on the governor path went +// straight to *github.Client, so a hive configured with project.forge: gitlab +// silently got GitHub behavior from an abstraction that was never reached +// (kubestellar/hive#5259). +// +// The seam is deliberately the SMALLEST one that removes that gap: +// forge.IssueWriter, the comment+label pair. *github.Client satisfies it +// already — its CreateIssueComment and AddLabels were given those exact +// signatures for this swap — so a GitHub hive keeps calling the very same +// client through the very same methods. Nothing about the default path changes; +// only the static type at the call site does, which is what lets a non-GitHub +// hive be handed an adapter instead. + +// governorForge returns the forge-neutral write seam the governor path should +// use for this config, or nil when no forge can be reached at all. +// +// Selection follows project.forge: +// +// - "github" (and unset, the default): ghClient itself. No adapter is +// interposed, so the GitHub path is byte-for-byte what it was before. +// - "gitlab" / "gitea": the corresponding pkg/forge adapter, built from the +// already-present config (instance URL + the env var named by token_env). +// +// It falls back to ghClient on any construction failure — a Gitea forge with no +// URL set, an unparseable instance URL, a forge kind this build does not know. +// Falling back rather than failing closed is the conservative choice for a +// GitHub-hosted hive that merely typo'd its forge key: escalation evidence +// still reaches the PR. The failure is logged at warn either way, and when +// there is no GitHub client either the result is nil and callers no-op. +// +// The returned adapter is cheap to build (a struct plus an http.Client whose +// nil Transport shares http.DefaultTransport, so connection pooling survives), +// which is why this is called per governor cycle instead of being memoized: a +// hot config reload takes effect on the next tick with no invalidation logic. +func governorForge(cfg *config.Config, ghClient *github.Client, logger *slog.Logger) forge.IssueWriter { + // A typed-nil *github.Client stored in an interface is NOT == nil, and the + // callers' "no client, do nothing" guards test the interface. Normalize it + // to an untyped nil here so those guards keep working. + fallback := func() forge.IssueWriter { + if ghClient == nil { + return nil + } + return ghClient + } + if cfg == nil { + return fallback() + } + + var ( + kind forge.Kind + baseURL string + tokenID string + ) + switch k := cfg.Project.ForgeKind(); k { + case config.ForgeGitHub: + return fallback() + case config.ForgeGitLab: + kind, baseURL, tokenID = forge.KindGitLab, cfg.GitLab.InstanceURL(), cfg.GitLab.TokenEnvName() + case config.ForgeGitea: + kind, baseURL, tokenID = forge.KindGitea, cfg.Gitea.InstanceURL(), cfg.Gitea.TokenEnvName() + default: + logger.Warn("unknown project.forge; falling back to the GitHub client", + "forge", k, "known", []string{config.ForgeGitHub, config.ForgeGitLab, config.ForgeGitea}) + return fallback() + } + + // The token is read from the environment by name, never from config: the + // no-hardcoded-secrets rule is why config carries token_env rather than the + // secret itself. An empty value is passed through — the forge will reject it + // with a 401 whose message names the real problem, which beats a local error + // that cannot distinguish "unset" from "set but wrong". + f, err := forge.NewForge(kind, os.Getenv(tokenID), forge.Options{ + BaseURL: baseURL, + Org: cfg.Project.Org, + }) + if err != nil { + logger.Warn("forge adapter unavailable; falling back to the GitHub client", + "forge", kind, "token_env", tokenID, "error", err) + return fallback() + } + return f +} diff --git a/src/cmd/hive/forgewire_test.go b/src/cmd/hive/forgewire_test.go new file mode 100644 index 000000000..d24bc36f1 --- /dev/null +++ b/src/cmd/hive/forgewire_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "testing" + + "github.com/kubestellar/hive/pkg/config" + "github.com/kubestellar/hive/pkg/forge" + "github.com/kubestellar/hive/pkg/github" +) + +// forgeKindOf reports the forge kind of a writer, or "" when the writer is not +// a pkg/forge adapter at all (i.e. it is the raw *github.Client). +func forgeKindOf(t *testing.T, w forge.IssueWriter) forge.Kind { + t.Helper() + if f, ok := w.(forge.Forge); ok { + return f.Kind() + } + return "" +} + +// TestGovernorForgeKeepsGitHubOnTheConcreteClient pins the no-behavior-change +// half of the swap: on a GitHub hive (explicit or defaulted) the governor gets +// the very same *github.Client it always used, with no adapter interposed. If +// this ever starts returning an adapter, every GitHub hive silently changes its +// write path — which is exactly what this change is NOT allowed to do. +func TestGovernorForgeKeepsGitHubOnTheConcreteClient(t *testing.T) { + gh := github.NewClient("t", "acme", nil, discardLogger(), "") + + for _, kind := range []string{"", config.ForgeGitHub} { + cfg := &config.Config{} + cfg.Project.Org = "acme" + cfg.Project.Forge = kind + + got := governorForge(cfg, gh, discardLogger()) + if got != forge.IssueWriter(gh) { + t.Fatalf("project.forge=%q: want the *github.Client itself, got %T", kind, got) + } + } +} + +// TestGovernorForgeNilClientIsUntypedNil guards the typed-nil trap: a nil +// *github.Client stored in an interface is not == nil, so returning it would +// sail past the sweep's "no client, do nothing" guard and panic on the first +// write. The guard tests the interface, so the nil must be untyped. +func TestGovernorForgeNilClientIsUntypedNil(t *testing.T) { + cfg := &config.Config{} + cfg.Project.Forge = config.ForgeGitHub + + if got := governorForge(cfg, nil, discardLogger()); got != nil { + t.Fatalf("want untyped nil for a hive with no GitHub client, got %#v", got) + } + if got := governorForge(nil, nil, discardLogger()); got != nil { + t.Fatalf("want untyped nil for a nil config with no client, got %#v", got) + } +} + +// TestGovernorForgeSelectsAdapters is the point of the whole change: a hive +// that says project.forge: gitlab (or gitea) gets the pkg/forge adapter for +// that forge on the governor path, not GitHub. Before this wiring the config +// key existed, the adapters existed and were tested, and nothing connected them. +func TestGovernorForgeSelectsAdapters(t *testing.T) { + gh := github.NewClient("t", "acme", nil, discardLogger(), "") + + t.Run("gitlab uses the configured instance and token env", func(t *testing.T) { + t.Setenv("HIVE_TEST_GITLAB_TOKEN", "glpat-xxx") + cfg := &config.Config{} + cfg.Project.Org = "acme" + cfg.Project.Forge = config.ForgeGitLab + cfg.GitLab.URL = "https://gitlab.example.com" + cfg.GitLab.TokenEnv = "HIVE_TEST_GITLAB_TOKEN" + + if got := forgeKindOf(t, governorForge(cfg, gh, discardLogger())); got != forge.KindGitLab { + t.Fatalf("want the GitLab adapter, got kind %q", got) + } + }) + + t.Run("gitlab defaults to gitlab.com with no url configured", func(t *testing.T) { + cfg := &config.Config{} + cfg.Project.Forge = config.ForgeGitLab + + if got := forgeKindOf(t, governorForge(cfg, gh, discardLogger())); got != forge.KindGitLab { + t.Fatalf("want the GitLab adapter, got kind %q", got) + } + }) + + t.Run("gitea uses the configured instance", func(t *testing.T) { + cfg := &config.Config{} + cfg.Project.Org = "acme" + cfg.Project.Forge = config.ForgeGitea + cfg.Gitea.URL = "https://gitea.example.com" + + if got := forgeKindOf(t, governorForge(cfg, gh, discardLogger())); got != forge.KindGitea { + t.Fatalf("want the Gitea adapter, got kind %q", got) + } + }) +} + +// TestGovernorForgeFallsBackOnUnusableConfig covers the two ways adapter +// construction can fail — Gitea named with no instance URL (it has no public +// default) and a forge kind this build does not know. Both fall back to the +// GitHub client rather than dropping the write, so a hive that typo'd its forge +// key still gets its escalation evidence onto the PR. +func TestGovernorForgeFallsBackOnUnusableConfig(t *testing.T) { + gh := github.NewClient("t", "acme", nil, discardLogger(), "") + + cases := map[string]func(*config.Config){ + "gitea with no instance url": func(c *config.Config) { c.Project.Forge = config.ForgeGitea }, + "unknown forge kind": func(c *config.Config) { c.Project.Forge = "bitbucket" }, + } + for name, setup := range cases { + t.Run(name, func(t *testing.T) { + cfg := &config.Config{} + setup(cfg) + + if got := governorForge(cfg, gh, discardLogger()); got != forge.IssueWriter(gh) { + t.Fatalf("want the GitHub client as fallback, got %T", got) + } + // And with no GitHub client to fall back to, an untyped nil — never + // a typed-nil that would panic on the first write. + if got := governorForge(cfg, nil, discardLogger()); got != nil { + t.Fatalf("want untyped nil when there is nothing to fall back to, got %#v", got) + } + }) + } +} diff --git a/src/cmd/hive/ghapp_repo_coverage_verdict_test.go b/src/cmd/hive/ghapp_repo_coverage_verdict_test.go new file mode 100644 index 000000000..f14abf044 --- /dev/null +++ b/src/cmd/hive/ghapp_repo_coverage_verdict_test.go @@ -0,0 +1,164 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/kubestellar/hive/pkg/github" +) + +// Tests for classifyGitHubAppRepoCoverage (#4360), the boot-time check that +// asks whether the App installation actually covers the configured repos. +// The stakes are asymmetric: a false "not covered" verdict sends an operator +// to change an installation setting that was already correct, while a missed +// one leaves the misleading "key never arrived" story in place. These tests +// pin both directions. + +// repoCoverageServer stubs the two endpoints classifyGitHubAppRepoCoverage +// exercises through a real AppAuth: the installation-token mint and the +// installation repository listing. verdictTestAuth (ghapp_banner_verdict_test.go) +// builds the AppAuth with a fresh key, so unlike the pkg/github tests we +// cannot pre-seed a cached token — the mint endpoint must answer for real. +func repoCoverageServer(t *testing.T, listing http.HandlerFunc) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc(fmt.Sprintf("/app/installations/%d/access_tokens", verdictTestInstallationID), + func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": "test-installation-token", + "expires_at": time.Now().Add(time.Hour).Format(time.RFC3339), + }) + }) + mux.HandleFunc("/installation/repositories", listing) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// listingOf serves a single-page repository listing with the given full names. +func listingOf(full ...string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + repos := make([]map[string]any, 0, len(full)) + for _, f := range full { + repos = append(repos, map[string]any{"full_name": f}) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "total_count": len(full), + "repositories": repos, + }) + } +} + +// A hive with no App auth at all has nothing to check; the verdict must be +// silence, not an accusation. +func TestClassifyGitHubAppRepoCoverage_NilAuthStaysSilent(t *testing.T) { + raise, msg, state := classifyGitHubAppRepoCoverage( + context.Background(), nil, "acme", []string{"widgets"}, verdictTestLogger()) + if raise { + t.Error("nil AppAuth must not raise the banner") + } + if msg != "" { + t.Errorf("msg = %q, want empty", msg) + } + if state != github.AppStateUnknown { + t.Errorf("state = %s, want unknown", state) + } +} + +// No configured repos means there is nothing the installation could fail to +// cover. No API call should be needed to conclude that — a nil auth plus empty +// repos both short-circuit, so pass a live-looking auth and an empty list. +func TestClassifyGitHubAppRepoCoverage_NoConfiguredReposStaysSilent(t *testing.T) { + srv := repoCoverageServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("no configured repos must not trigger a coverage listing") + w.WriteHeader(http.StatusInternalServerError) + }) + + raise, _, state := classifyGitHubAppRepoCoverage( + context.Background(), verdictTestAuth(t, srv.URL), "acme", nil, verdictTestLogger()) + if raise { + t.Error("an empty repo list must not raise the banner") + } + if state != github.AppStateUnknown { + t.Errorf("state = %s, want unknown", state) + } +} + +// An error fetching the listing is NOT a verdict. The credential checks tell +// that story better; this classifier must defer with raise=false rather than +// accuse every configured repo at once. +func TestClassifyGitHubAppRepoCoverage_ListingErrorDefersToCredentialChecks(t *testing.T) { + srv := repoCoverageServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "boom"}) + }) + + raise, msg, state := classifyGitHubAppRepoCoverage( + context.Background(), verdictTestAuth(t, srv.URL), "acme", []string{"widgets"}, verdictTestLogger()) + if raise { + t.Error("a failed listing must not raise the coverage banner") + } + if msg != "" { + t.Errorf("msg = %q, want empty when the listing could not be fetched", msg) + } + if state != github.AppStateUnknown { + t.Errorf("state = %s, want unknown so the credential checks still run", state) + } +} + +// The healthy case: every configured repo is covered, including bare names +// that need the org prefix and case differences GitHub treats as equal. +func TestClassifyGitHubAppRepoCoverage_FullCoverageIsOK(t *testing.T) { + srv := repoCoverageServer(t, listingOf("acme/widgets", "acme/Gadgets")) + + raise, msg, state := classifyGitHubAppRepoCoverage( + context.Background(), verdictTestAuth(t, srv.URL), "acme", + []string{"widgets", "acme/gadgets"}, verdictTestLogger()) + if raise { + t.Errorf("full coverage must not raise the banner (msg=%q)", msg) + } + if msg != "" { + t.Errorf("msg = %q, want empty for full coverage", msg) + } + if state != github.AppStateOK { + t.Errorf("state = %s, want ok", state) + } +} + +// The live #4360 shape: right org, right installation, one configured repo +// simply not ticked. The banner must raise, carry the repo-not-covered state, +// and name the missing repo — this is the case that used to be misreported as +// an undelivered private key. +func TestClassifyGitHubAppRepoCoverage_MissingRepoRaisesWithAccurateCopy(t *testing.T) { + srv := repoCoverageServer(t, listingOf("acme/widgets")) + + raise, msg, state := classifyGitHubAppRepoCoverage( + context.Background(), verdictTestAuth(t, srv.URL), "acme", + []string{"widgets", "gizmos"}, verdictTestLogger()) + if !raise { + t.Fatal("an uncovered configured repo must raise the banner") + } + if state != github.AppStateRepoNotCovered { + t.Errorf("state = %s, want repo-not-covered", state) + } + if !state.UserActionable() { + t.Error("repo-not-covered is fixed in the App's repository selection — it is the user's to fix") + } + if !strings.Contains(msg, "acme/gizmos") { + t.Errorf("message must name the missing repo; got %q", msg) + } + if strings.Contains(msg, "acme/widgets") { + t.Errorf("message must not accuse the covered repo; got %q", msg) + } + if strings.Contains(strings.ToLower(msg), "private key") && !strings.Contains(strings.ToLower(msg), "repo") { + t.Errorf("message must tell the repo-selection story, not the key story; got %q", msg) + } +} diff --git a/src/cmd/hive/github_app_heal_test.go b/src/cmd/hive/github_app_heal_test.go new file mode 100644 index 000000000..1a66f7bbf --- /dev/null +++ b/src/cmd/hive/github_app_heal_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kubestellar/hive/pkg/config" + "github.com/kubestellar/hive/pkg/github" +) + +// healTestAppAuthPEM returns a throwaway RSA private key PEM good enough to +// construct an *github.AppAuth via NewAppAuthFromPEM; it never signs anything +// verified against a real GitHub App. +func healTestAppAuthPEM(t *testing.T) []byte { + t.Helper() + k, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + return pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(k), + }) +} + +// healGitHubAppInstallation must be a pure no-op — no panic, no API call — +// for every combination of nil/keyless/orgless inputs. Passing a nil logger +// where the real caller always supplies one would still be safe, but every +// call site does supply one, so these use restoreTestLogger() throughout and +// rely on a nil appAuth/cfg/org short-circuiting before the logger is ever +// touched. +func TestHealGitHubAppInstallationGuardsNoOp(t *testing.T) { + logger := restoreTestLogger() + cfg := &config.Config{Project: config.ProjectConfig{Org: "acme"}} + + t.Run("nil appAuth", func(t *testing.T) { + healGitHubAppInstallation(context.Background(), nil, cfg, logger) + }) + + t.Run("keyless appAuth", func(t *testing.T) { + // NewAppAuth with no key file resolves no key, so HasKey() is false + // and the function must return before touching cfg at all — nil cfg + // proves it never dereferences cfg.Project.Org on this path. + auth, err := github.NewAppAuth(1, 2, "/nonexistent/key.pem", logger, "") + if err == nil && auth.HasKey() { + t.Fatal("test setup: expected a keyless AppAuth") + } + healGitHubAppInstallation(context.Background(), auth, nil, logger) + }) + + t.Run("nil cfg with keyed appAuth", func(t *testing.T) { + auth, err := github.NewAppAuthFromPEM(1, 2, healTestAppAuthPEM(t), logger, "") + if err != nil { + t.Fatalf("NewAppAuthFromPEM: %v", err) + } + healGitHubAppInstallation(context.Background(), auth, nil, logger) + }) + + t.Run("empty org", func(t *testing.T) { + auth, err := github.NewAppAuthFromPEM(1, 2, healTestAppAuthPEM(t), logger, "") + if err != nil { + t.Fatalf("NewAppAuthFromPEM: %v", err) + } + emptyOrgCfg := &config.Config{Project: config.ProjectConfig{Org: ""}} + healGitHubAppInstallation(context.Background(), auth, emptyOrgCfg, logger) + }) +} + +// A VerifyInstallation failure (unreachable/erroring API) must be swallowed: +// healGitHubAppInstallation logs and returns rather than propagating, since +// the self-heal tick runs unattended on every heartbeat and a transient API +// error must never be treated as fatal. +func TestHealGitHubAppInstallationVerifyErrorIsSwallowed(t *testing.T) { + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer api.Close() + + auth, err := github.NewAppAuthFromPEM(1, 2, healTestAppAuthPEM(t), slog.Default(), api.URL) + if err != nil { + t.Fatalf("NewAppAuthFromPEM: %v", err) + } + cfg := &config.Config{Project: config.ProjectConfig{Org: "acme"}} + + // Must return normally (no panic) even though every API call 500s. + healGitHubAppInstallation(context.Background(), auth, cfg, restoreTestLogger()) +} diff --git a/src/cmd/hive/hookwire_emitters_test.go b/src/cmd/hive/hookwire_emitters_test.go new file mode 100644 index 000000000..0922ff519 --- /dev/null +++ b/src/cmd/hive/hookwire_emitters_test.go @@ -0,0 +1,155 @@ +package main + +// Tests for the hookwire.go emitters that had no coverage: +// installGovernorModeChangeEmitter (the post-commit governor_mode_change +// emission) and the nil guards of installUpgradePauseEmitter. The agent-pause +// emitter is exercised by hookwire_test.go's causation/loop test. +// +// installUpgradePauseEmitter's firing path is NOT covered here: the only way +// to reach hub.emitUpgradePause from outside pkg/hub is the admin-gated +// POST /api/saas/upgrade-pause handler, which needs a running HubServer. Its +// closure mirrors the governor emitter tested below; only the nil guard is +// reachable from this package. + +import ( + "testing" + "time" + + "github.com/kubestellar/hive/pkg/config" + "github.com/kubestellar/hive/pkg/governor" + "github.com/kubestellar/hive/pkg/hooks" + "github.com/kubestellar/hive/pkg/timeline" +) + +// snapshot returns a copy of the recorded audit actions, safe under -race. +func (a *hookWireAudit) snapshot() []string { + a.mu.Lock() + defer a.mu.Unlock() + return append([]string(nil), a.actions...) +} + +// governorForHookTests builds a real governor with the standard four-mode +// ladder, matching pkg/governor's own test fixture: pressure 25 crosses the +// surge threshold from the idle boot mode. +func governorForHookTests(t *testing.T) *governor.Governor { + t.Helper() + cadences := map[string]config.Cadence{"scanner": "15m"} + cfg := config.GovernorConfig{ + Modes: map[string]config.ModeConfig{ + "surge": {Threshold: 20, Cadences: cadences}, + "busy": {Threshold: 10, Cadences: cadences}, + "quiet": {Threshold: 2, Cadences: cadences}, + "idle": {Threshold: 0, Cadences: cadences}, + }, + } + agents := map[string]config.AgentConfig{"scanner": {Enabled: true}} + return governor.New(cfg, agents, hookTestLogger()) +} + +// TestInstallEmittersNilBackingObjectsAreSafe: wiring runs at startup before +// every subsystem necessarily exists; a nil governor or hub must be a no-op, +// not a crash. +func TestInstallEmittersNilBackingObjectsAreSafe(t *testing.T) { + installGovernorModeChangeEmitter(nil) + installUpgradePauseEmitter(nil) +} + +// TestGovernorModeChangeEmitterIsNoOpWithoutHooks: the emitter is installed +// unconditionally, but with no hooks configured the dispatcher is nil and +// Fire must tolerate that — a mode change on a hookless hive cannot panic. +func TestGovernorModeChangeEmitterIsNoOpWithoutHooks(t *testing.T) { + resetHookDispatcher(t) + t.Cleanup(func() { resetHookDispatcher(t) }) + + gov := governorForHookTests(t) + installGovernorModeChangeEmitter(gov) + + gov.Evaluate(25, 0, 0, 0) // idle → surge with a nil dispatcher + + if s := gov.GetState(); s.Mode != governor.ModeSurge { + t.Fatalf("mode change itself must still commit, got %s", s.Mode) + } +} + +// TestGovernorModeChangeEmitterFiresHookOnCommittedModeChange wires a real +// governor to a real dispatcher and drives a mode change through Evaluate — +// the same post-commit path production uses. +// +// The `when:` predicate is the payload assertion: it only matches when the +// emitter carried the committed From/To, the "system" actor, and a non-empty +// reason. If the emitter dropped or misfiled any of them, the hook would not +// fire at all and the test fails on the missing audit entry. +func TestGovernorModeChangeEmitterFiresHookOnCommittedModeChange(t *testing.T) { + resetHookDispatcher(t) + t.Cleanup(func() { resetHookDispatcher(t) }) + + store := timeline.NewStore() + audit := &hookWireAudit{ch: make(chan string, 8)} + cfg := &config.Config{Hooks: []config.HookRule{{ + Name: "on-surge", + On: "governor_mode_change", + Action: "annotate", + Params: map[string]string{"note": "governor went surge", "issue_ref": "governor"}, + When: `t.from == "IDLE" && t.to == "SURGE" && t.actor == "system" && t.reason != ""`, + }}} + buildHookDispatcher(cfg, hookSinks{Timeline: store, Audit: audit}, hookTestLogger()) + + gov := governorForHookTests(t) + installGovernorModeChangeEmitter(gov) + + gov.Evaluate(25, 0, 0, 0) // pressure 25 crosses the surge threshold + + deadline := time.After(2 * time.Second) + for audit.count(hooks.AuditHookFired) == 0 { + select { + case <-audit.ch: + case <-deadline: + t.Fatalf("expected the governor_mode_change hook to fire; audit=%v", audit.snapshot()) + } + } + hookDispatcher().Wait() + + if got := audit.count(hooks.AuditHookFired); got != 1 { + t.Fatalf("hook should fire exactly once, got %d (%v)", got, audit.snapshot()) + } + + events := store.Recent(10) + if len(events) != 1 { + t.Fatalf("expected 1 timeline annotation, got %d", len(events)) + } + e := events[0] + if e.Attrs["note"] != "governor went surge" { + t.Errorf("annotation note lost: %+v", e.Attrs) + } + if e.Attrs["hook"] != "on-surge" || e.Attrs["transition"] != "governor_mode_change" { + t.Errorf("annotation must name the hook and transition: %+v", e.Attrs) + } +} + +// TestGovernorModeChangeEmitterSkipsWhenPredicateExcludesTransition is the +// negative control for the test above: the same wiring, a mode change the +// predicate does NOT match (idle → busy), and the hook must stay silent — +// proving the fired case fired on the payload, not on any mode change. +func TestGovernorModeChangeEmitterSkipsWhenPredicateExcludesTransition(t *testing.T) { + resetHookDispatcher(t) + t.Cleanup(func() { resetHookDispatcher(t) }) + + audit := &hookWireAudit{ch: make(chan string, 8)} + cfg := &config.Config{Hooks: []config.HookRule{{ + Name: "on-surge", + On: "governor_mode_change", + Action: "annotate", + When: `t.to == "SURGE"`, + }}} + buildHookDispatcher(cfg, hookSinks{Timeline: timeline.NewStore(), Audit: audit}, hookTestLogger()) + + gov := governorForHookTests(t) + installGovernorModeChangeEmitter(gov) + + gov.Evaluate(15, 0, 0, 0) // idle → busy: a real change the predicate excludes + hookDispatcher().Wait() + + if got := audit.count(hooks.AuditHookFired); got != 0 { + t.Fatalf("hook fired on a transition its predicate excludes: %v", audit.snapshot()) + } +} diff --git a/src/cmd/hive/intent_alignment_gate_test.go b/src/cmd/hive/intent_alignment_gate_test.go index 8540b677a..b624b507a 100644 --- a/src/cmd/hive/intent_alignment_gate_test.go +++ b/src/cmd/hive/intent_alignment_gate_test.go @@ -34,7 +34,7 @@ func TestWriteMergeEligibleExcludesMisalignedWhenIntentEnforced(t *testing.T) { Alignment: &intent.AlignmentVerdict{Status: intent.AlignmentStatusMisaligned, Rationale: "docs intent touched proxy"}, }, } - writeMergeEligible(actionable, github.HoldResult{}, "kubestellar", nil, true, verdicts, false, slog.New(slog.NewTextHandler(io.Discard, nil))) + writeMergeEligible(actionable, github.HoldResult{}, "kubestellar", nil, true, verdicts, false, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) raw, err := os.ReadFile(mergeEligiblePath) if err != nil { diff --git a/src/cmd/hive/intent_evidence_fetch_test.go b/src/cmd/hive/intent_evidence_fetch_test.go new file mode 100644 index 000000000..e7c3f7682 --- /dev/null +++ b/src/cmd/hive/intent_evidence_fetch_test.go @@ -0,0 +1,321 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/kubestellar/hive/pkg/github" + "github.com/kubestellar/hive/pkg/intent" +) + +// evidenceServer is a canned GitHub API for the intent-evidence fetchers. +// It serves a single PR (acme/widgets#7) with paginated changed files and +// reviews, plus arbitrary issues keyed by "owner/repo#number". +type evidenceServer struct { + prBody string + changedFiles int // value reported in the PR's changed_files field + filePages [][]map[string]any + reviews []map[string]any + issues map[string]map[string]any // "owner/repo#n" -> issue JSON + failPR bool + failFiles bool + failReviews bool +} + +func (s *evidenceServer) client(t *testing.T) *github.Client { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/repos/acme/widgets/pulls/7/files": + if s.failFiles { + http.Error(w, `{"message":"files boom"}`, http.StatusInternalServerError) + return + } + page := 1 + if p := r.URL.Query().Get("page"); p != "" { + fmt.Sscanf(p, "%d", &page) + } + if page < 1 || page > len(s.filePages) { + fmt.Fprint(w, "[]") + return + } + if page < len(s.filePages) { + w.Header().Set("Link", fmt.Sprintf(`<%s?page=%d>; rel="next", <%s?page=%d>; rel="last"`, + r.URL.Path, page+1, r.URL.Path, len(s.filePages))) + } + if err := json.NewEncoder(w).Encode(s.filePages[page-1]); err != nil { + t.Errorf("encoding files page: %v", err) + } + case r.URL.Path == "/repos/acme/widgets/pulls/7/reviews": + if s.failReviews { + http.Error(w, `{"message":"reviews boom"}`, http.StatusInternalServerError) + return + } + if err := json.NewEncoder(w).Encode(s.reviews); err != nil { + t.Errorf("encoding reviews: %v", err) + } + case r.URL.Path == "/repos/acme/widgets/pulls/7": + if s.failPR { + http.Error(w, `{"message":"pr boom"}`, http.StatusInternalServerError) + return + } + if err := json.NewEncoder(w).Encode(map[string]any{ + "number": 7, + "body": s.prBody, + "changed_files": s.changedFiles, + }); err != nil { + t.Errorf("encoding PR: %v", err) + } + default: + // Issue lookups: /repos/{owner}/{repo}/issues/{n} + owner, repo, n := parseIssuePath(r.URL.Path) + if owner == "" { + http.NotFound(w, r) + return + } + key := fmt.Sprintf("%s/%s#%d", owner, repo, n) + issue, ok := s.issues[key] + if !ok { + http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound) + return + } + if err := json.NewEncoder(w).Encode(issue); err != nil { + t.Errorf("encoding issue %s: %v", key, err) + } + } + })) + t.Cleanup(server.Close) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + return github.NewClientForTest(server.URL, "acme", []string{"widgets"}, logger) +} + +// parseIssuePath extracts owner, repo, and number from +// /repos/{owner}/{repo}/issues/{n}; owner is "" on mismatch. +func parseIssuePath(path string) (string, string, int) { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) != 5 || parts[0] != "repos" || parts[3] != "issues" { + return "", "", 0 + } + n, err := strconv.Atoi(parts[4]) + if err != nil { + return "", "", 0 + } + return parts[1], parts[2], n +} + +func changedFileJSON(name, status string, adds, dels int) map[string]any { + return map[string]any{ + "filename": name, + "status": status, + "additions": adds, + "deletions": dels, + } +} + +func TestFetchIntentPREvidence_NilClient(t *testing.T) { + var nilClient *github.Client + if _, _, _, err := fetchIntentPREvidence(context.Background(), nil, "acme/widgets", 7); !errors.Is(err, github.ErrNoGitHubClient) { + t.Errorf("nil client: err = %v, want ErrNoGitHubClient", err) + } + if _, _, _, err := fetchIntentPREvidence(context.Background(), nilClient, "acme/widgets", 7); !errors.Is(err, github.ErrNoGitHubClient) { + t.Errorf("typed-nil client: err = %v, want ErrNoGitHubClient", err) + } +} + +func TestFetchIntentPREvidence_InvalidRepo(t *testing.T) { + srv := &evidenceServer{} + client := srv.client(t) + for _, repo := range []string{"widgets", "/widgets", "acme/", ""} { + if _, _, _, err := fetchIntentPREvidence(context.Background(), client, repo, 7); err == nil { + t.Errorf("repo %q: expected error, got nil", repo) + } + } +} + +func TestFetchIntentPREvidence_HappyPathPaginatedFilesAndApproval(t *testing.T) { + srv := &evidenceServer{ + prBody: "Fixes #12", + changedFiles: 3, + filePages: [][]map[string]any{ + { + changedFileJSON("a.go", "modified", 10, 2), + changedFileJSON("a_test.go", "added", 30, 0), + }, + { + changedFileJSON("docs/readme.md", "removed", 0, 5), + }, + }, + reviews: []map[string]any{ + prReview("alice", "MEMBER", "APPROVED"), + }, + } + client := srv.client(t) + + body, files, approved, err := fetchIntentPREvidence(context.Background(), client, "acme/widgets", 7) + if err != nil { + t.Fatalf("fetchIntentPREvidence: %v", err) + } + if body != "Fixes #12" { + t.Errorf("body = %q, want %q", body, "Fixes #12") + } + if !approved { + t.Error("approved = false, want true (maintainer approval present)") + } + want := []intent.ChangedFile{ + {Filename: "a.go", Status: "modified", Additions: 10, Deletions: 2}, + {Filename: "a_test.go", Status: "added", Additions: 30, Deletions: 0}, + {Filename: "docs/readme.md", Status: "removed", Additions: 0, Deletions: 5}, + } + if len(files) != len(want) { + t.Fatalf("files = %d entries, want %d: %+v", len(files), len(want), files) + } + for i := range want { + if files[i] != want[i] { + t.Errorf("files[%d] = %+v, want %+v", i, files[i], want[i]) + } + } +} + +func TestFetchIntentPREvidence_NoApproval(t *testing.T) { + srv := &evidenceServer{ + prBody: "body", + changedFiles: 1, + filePages: [][]map[string]any{{changedFileJSON("a.go", "modified", 1, 1)}}, + reviews: []map[string]any{ + prReview("drive-by", "CONTRIBUTOR", "APPROVED"), + }, + } + client := srv.client(t) + _, _, approved, err := fetchIntentPREvidence(context.Background(), client, "acme/widgets", 7) + if err != nil { + t.Fatalf("fetchIntentPREvidence: %v", err) + } + if approved { + t.Error("approved = true, want false (only non-maintainer approval)") + } +} + +func TestFetchIntentPREvidence_IncompleteFileList(t *testing.T) { + srv := &evidenceServer{ + prBody: "body", + changedFiles: 5, // reported > actually returned (1) + filePages: [][]map[string]any{{changedFileJSON("a.go", "modified", 1, 1)}}, + } + client := srv.client(t) + _, _, _, err := fetchIntentPREvidence(context.Background(), client, "acme/widgets", 7) + if err == nil { + t.Fatal("expected incomplete-file-list error, got nil") + } +} + +func TestFetchIntentPREvidence_APIErrors(t *testing.T) { + tests := []struct { + name string + srv *evidenceServer + }{ + {"PR get fails", &evidenceServer{failPR: true}}, + {"file listing fails", &evidenceServer{failFiles: true}}, + {"reviews listing fails", &evidenceServer{ + changedFiles: 1, + filePages: [][]map[string]any{{changedFileJSON("a.go", "modified", 1, 1)}}, + failReviews: true, + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := tc.srv.client(t) + if _, _, _, err := fetchIntentPREvidence(context.Background(), client, "acme/widgets", 7); err == nil { + t.Error("expected error, got nil") + } + }) + } +} + +func TestFetchIntentIssueTexts_NilClient(t *testing.T) { + var nilClient *github.Client + if _, err := fetchIntentIssueTexts(context.Background(), nil, "acme/widgets", "Fixes #1"); !errors.Is(err, github.ErrNoGitHubClient) { + t.Errorf("nil client: err = %v, want ErrNoGitHubClient", err) + } + if _, err := fetchIntentIssueTexts(context.Background(), nilClient, "acme/widgets", "Fixes #1"); !errors.Is(err, github.ErrNoGitHubClient) { + t.Errorf("typed-nil client: err = %v, want ErrNoGitHubClient", err) + } +} + +func TestFetchIntentIssueTexts_NoRefs(t *testing.T) { + srv := &evidenceServer{} + client := srv.client(t) + out, err := fetchIntentIssueTexts(context.Background(), client, "acme/widgets", "no linked issues here") + if err != nil { + t.Fatalf("fetchIntentIssueTexts: %v", err) + } + if len(out) != 0 { + t.Errorf("out = %+v, want empty", out) + } +} + +func TestFetchIntentIssueTexts_DefaultAndExplicitRepos(t *testing.T) { + srv := &evidenceServer{ + issues: map[string]map[string]any{ + "acme/widgets#12": {"number": 12, "title": "default-repo issue", "body": "widgets body"}, + "acme/gadgets#3": {"number": 3, "title": "cross-repo issue", "body": "gadgets body"}, + }, + } + client := srv.client(t) + + body := "Fixes #12 and refs acme/gadgets#3" + out, err := fetchIntentIssueTexts(context.Background(), client, "acme/widgets", body) + if err != nil { + t.Fatalf("fetchIntentIssueTexts: %v", err) + } + if len(out) != 2 { + t.Fatalf("out = %d entries, want 2: %+v", len(out), out) + } + if out[0].Source != "issue acme/widgets#12" || out[0].Title != "default-repo issue" || out[0].Body != "widgets body" { + t.Errorf("out[0] = %+v, want default-repo issue evidence", out[0]) + } + if out[1].Source != "issue acme/gadgets#3" || out[1].Title != "cross-repo issue" || out[1].Body != "gadgets body" { + t.Errorf("out[1] = %+v, want cross-repo issue evidence", out[1]) + } +} + +func TestFetchIntentIssueTexts_InvalidDefaultRepoSkipsRef(t *testing.T) { + srv := &evidenceServer{} + client := srv.client(t) + // The bare "#5" ref resolves to the default repo, which has no owner/name + // split — the ref must be skipped, not fail the whole fetch. + out, err := fetchIntentIssueTexts(context.Background(), client, "not-a-repo", "Fixes #5") + if err != nil { + t.Fatalf("fetchIntentIssueTexts: %v", err) + } + if len(out) != 0 { + t.Errorf("out = %+v, want empty (invalid default repo skipped)", out) + } +} + +func TestFetchIntentIssueTexts_LookupErrorReturnsPartial(t *testing.T) { + srv := &evidenceServer{ + issues: map[string]map[string]any{ + "acme/widgets#12": {"number": 12, "title": "first", "body": "ok"}, + // acme/widgets#99 intentionally missing -> 404 + }, + } + client := srv.client(t) + + out, err := fetchIntentIssueTexts(context.Background(), client, "acme/widgets", "Fixes #12, refs #99") + if err == nil { + t.Fatal("expected error for missing linked issue, got nil") + } + if len(out) != 1 || out[0].Title != "first" { + t.Errorf("out = %+v, want the one successfully fetched issue", out) + } +} diff --git a/src/cmd/hive/litellm_route_resolution_test.go b/src/cmd/hive/litellm_route_resolution_test.go new file mode 100644 index 000000000..ca8152f1d --- /dev/null +++ b/src/cmd/hive/litellm_route_resolution_test.go @@ -0,0 +1,209 @@ +package main + +import ( + "testing" + + "github.com/kubestellar/hive/pkg/config" +) + +// resolveLiteLLMInferenceRoute is the route-install decision tree for the +// built-in "litellm" backend. It shipped inline in main() with no coverage, +// which is exactly how the #5393 outage happened: a hive configured ONLY +// through the Model Gateways tab (explicit gateway named "litellm", legacy +// governor.litellm block empty) resolved its key and CA bundle from that +// gateway but its ENDPOINT from the empty legacy block, so no route was ever +// installed and every agent call died "502 no inference route" while the +// Gateways tab Test button passed. 231ca4b fixed it; this table pins the fix. +// +// The `gateway fallback` cases below FAIL against pre-231ca4b behavior (delete +// the gateway-fallback block in resolveLiteLLMInferenceRoute and they go red +// with ok=false), which is the point — a test that passes on both the fixed and +// the broken code would guard nothing. +func TestResolveLiteLLMInferenceRoute(t *testing.T) { + // The bundled local proxy's loopback URL is not a literal here: it must + // track litellmLocalProxyURL(), which owns the port. + localProxy := litellmLocalProxyURL() + + cases := []struct { + name string + litellm config.LiteLLMConfig + gateways []config.GatewayConfig + backend string + requestedModel string + + wantEndpoint string + wantModel string + wantOK bool + }{ + { + // Classic hive: legacy block populated, no gateways. The legacy + // endpoint is used directly and its default_model fills in. + name: "legacy endpoint used directly", + litellm: config.LiteLLMConfig{Endpoint: "https://legacy.example", DefaultModel: "legacy-model"}, + backend: "litellm", + wantEndpoint: "https://legacy.example", + wantModel: "legacy-model", + wantOK: true, + }, + { + // An agent that names a model keeps it; the legacy default_model + // is only a fallback, never an override. + name: "requested model beats legacy default", + litellm: config.LiteLLMConfig{Endpoint: "https://legacy.example", DefaultModel: "legacy-model"}, + backend: "litellm", + requestedModel: "asked-for-this", + wantEndpoint: "https://legacy.example", + wantModel: "asked-for-this", + wantOK: true, + }, + { + // local_proxy wins over BOTH the configured legacy endpoint and + // any gateway: the Go translator forwards to the bundled proxy on + // loopback. Losing this would silently send traffic upstream. + name: "local proxy overrides configured legacy endpoint", + litellm: config.LiteLLMConfig{ + Endpoint: "https://legacy.example", DefaultModel: "legacy-model", LocalProxy: true, + }, + gateways: []config.GatewayConfig{{Name: "litellm", Endpoint: "https://gateway.example"}}, + backend: "litellm", + wantEndpoint: localProxy, + wantModel: "legacy-model", + wantOK: true, + }, + { + name: "local proxy with empty legacy block still routes", + litellm: config.LiteLLMConfig{LocalProxy: true}, + backend: "litellm", + wantEndpoint: localProxy, + wantModel: "", + wantOK: true, + }, + { + // THE #5393 CASE. Legacy block entirely empty, one explicit + // gateway named "litellm" from the Model Gateways tab. Pre-231ca4b + // this returned no endpoint and installed no route -> 502. + name: "gateway fallback when legacy endpoint empty", + litellm: config.LiteLLMConfig{}, + gateways: []config.GatewayConfig{{Name: "litellm", Kind: config.GatewayKindLiteLLM, Endpoint: "https://gateway.example", DefaultModel: "gateway-model"}}, + backend: "litellm", + wantEndpoint: "https://gateway.example", + wantModel: "gateway-model", + wantOK: true, + }, + { + // The fallback must inherit the GATEWAY's default_model, not the + // (empty) legacy one — an empty model on the route is its own 4xx. + name: "gateway fallback keeps an explicitly requested model", + litellm: config.LiteLLMConfig{}, + gateways: []config.GatewayConfig{{Name: "litellm", Endpoint: "https://gateway.example", DefaultModel: "gateway-model"}}, + backend: "litellm", + requestedModel: "asked-for-this", + wantEndpoint: "https://gateway.example", + wantModel: "asked-for-this", + wantOK: true, + }, + { + // Gateway name match is case-insensitive (ResolveGateway uses + // EqualFold), so a tab-entered "LiteLLM" still backs the backend. + name: "gateway fallback matches name case-insensitively", + litellm: config.LiteLLMConfig{}, + gateways: []config.GatewayConfig{{Name: "LiteLLM", Endpoint: "https://cased.example", DefaultModel: "cased-model"}}, + backend: "litellm", + wantEndpoint: "https://cased.example", + wantModel: "cased-model", + wantOK: true, + }, + { + // A gateway exists but carries no endpoint: there is nothing to + // fall back TO, so this must fail honestly rather than install a + // route with an empty endpoint. + name: "gateway present but endpoint empty is not a route", + litellm: config.LiteLLMConfig{}, + gateways: []config.GatewayConfig{{Name: "litellm", DefaultModel: "gateway-model"}}, + backend: "litellm", + wantEndpoint: "", + wantModel: "", + wantOK: false, + }, + { + // A gateway list that does not name this backend must NOT be + // borrowed — routing litellm through someone else's endpoint and + // key is worse than no route. + name: "unrelated gateway is not borrowed", + litellm: config.LiteLLMConfig{}, + gateways: []config.GatewayConfig{{Name: "openrouter", Endpoint: "https://openrouter.example", DefaultModel: "or-model"}}, + backend: "litellm", + wantEndpoint: "", + wantModel: "", + wantOK: false, + }, + { + // Nothing configured anywhere: the honest failure. ok=false tells + // main() to warn and install NO route. The contract is an explicit + // false, NOT a silently empty endpoint string. + name: "nothing configured yields no route", + litellm: config.LiteLLMConfig{}, + backend: "litellm", + wantEndpoint: "", + wantModel: "", + wantOK: false, + }, + { + // On failure the requested model is handed back unchanged so the + // caller's warning names what the agent actually asked for. + name: "no route preserves the requested model for the warning", + litellm: config.LiteLLMConfig{}, + backend: "litellm", + requestedModel: "asked-for-this", + wantEndpoint: "", + wantModel: "asked-for-this", + wantOK: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // HIVE_LITELLM_ENDPOINT is consulted by ResolveEndpoint and would + // leak a real environment into these cases. + t.Setenv(config.LiteLLMEndpointEnv, "") + + cfg := &config.Config{Governor: config.GovernorConfig{ + LiteLLM: tc.litellm, + Gateways: tc.gateways, + }} + + endpoint, model, ok := resolveLiteLLMInferenceRoute(cfg, tc.backend, tc.requestedModel) + + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v (endpoint=%q model=%q)", ok, tc.wantOK, endpoint, model) + } + if endpoint != tc.wantEndpoint { + t.Errorf("endpoint = %q, want %q", endpoint, tc.wantEndpoint) + } + if model != tc.wantModel { + t.Errorf("model = %q, want %q", model, tc.wantModel) + } + // The 502 this path exists to prevent: ok must never be true with + // nothing to route to. + if ok && endpoint == "" { + t.Errorf("ok=true with an empty endpoint — that installs a dead route (502 no inference route)") + } + }) + } +} + +// The env var overrides the yaml endpoint, and local_proxy still beats it. +// ResolveEndpoint owns this precedence; pinning it here keeps the route-level +// tree honest about which source it consulted. +func TestResolveLiteLLMInferenceRouteEnvEndpoint(t *testing.T) { + t.Setenv(config.LiteLLMEndpointEnv, "https://from-env.example") + cfg := &config.Config{Governor: config.GovernorConfig{ + LiteLLM: config.LiteLLMConfig{Endpoint: "https://from-yaml.example", DefaultModel: "legacy-model"}, + Gateways: []config.GatewayConfig{{Name: "litellm", Endpoint: "https://gateway.example"}}, + }} + + endpoint, model, ok := resolveLiteLLMInferenceRoute(cfg, "litellm", "") + if !ok || endpoint != "https://from-env.example" || model != "legacy-model" { + t.Fatalf("env endpoint: got (%q, %q, %v), want (https://from-env.example, legacy-model, true)", endpoint, model, ok) + } +} diff --git a/src/cmd/hive/login_scan_decision_test.go b/src/cmd/hive/login_scan_decision_test.go new file mode 100644 index 000000000..96bd66fe0 --- /dev/null +++ b/src/cmd/hive/login_scan_decision_test.go @@ -0,0 +1,228 @@ +package main + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/kubestellar/hive/pkg/config" +) + +// The shipping default patterns, resolved the way a real hive resolves them +// (config.Load applies the defaults), so these tests exercise what actually +// runs in production rather than a convenient stand-in that could drift from +// it. defaultLoginPatterns is unexported, and loading is the supported way to +// see the applied set. +func defaultLoginRegexps(t *testing.T) []*regexp.Regexp { + t.Helper() + path := filepath.Join(t.TempDir(), "hive.yaml") + minimal := "project:\n name: login-scan-test\n org: kubestellar\n" + + "github:\n token: t-not-a-real-token\n" + + "agents:\n supervisor:\n backend: claude\n" + if err := os.WriteFile(path, []byte(minimal), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatalf("load config: %v", err) + } + patterns := cfg.Governor.Sensing.LoginPatterns + if len(patterns) == 0 { + t.Fatal("the default config carries no login patterns — this test would prove nothing") + } + compiled := make([]*regexp.Regexp, 0, len(patterns)) + for _, p := range patterns { + if strings.TrimSpace(p) == "" { + continue + } + re, err := regexp.Compile("(?i)" + p) + if err != nil { + t.Fatalf("default pattern %q does not compile: %v", p, err) + } + compiled = append(compiled, re) + } + return compiled +} + +// loginResiduePane is the shape observed in kubestellar/hive#5291: the pane tail +// still carries login-flow chrome minutes AFTER the operator's /login restored a +// valid credential. +var loginResiduePane = strings.Join([]string{ + "╭──────────────────────────────────────────────╮", + "│ Please run /login to authenticate │", + "╰──────────────────────────────────────────────╯", + "", + "Login successful. Press Enter to continue…", + "", + "❯ ", +}, "\n") + +// TestLoginScanDecision_ValidCredentialNeverPauses is the issue's primary +// acceptance criterion, and the regression for the reported incident: the same +// pane text decides differently depending on whether the credential is good. +func TestLoginScanDecision_ValidCredentialNeverPauses(t *testing.T) { + compiled := defaultLoginRegexps(t) + + // Valid credential: never a pause, no matter how many cycles see it. + for _, sightings := range []int{1, 2, 3, 99} { + action, re := loginScanDecision("claude", loginResiduePane, compiled, true, sightings) + if action != loginScanDeferAuthenticated { + t.Fatalf("sightings=%d: got action %v, want defer-authenticated — a valid credential must never be paused", sightings, action) + } + if re == nil { + t.Fatal("the matched pattern must be reported so the log can explain the decision") + } + } + + // Same text, credential NOT provable: still paused, exactly as today, once + // the sighting has persisted. + if action, _ := loginScanDecision("claude", loginResiduePane, compiled, false, loginPauseMinSightings); action != loginScanPause { + t.Fatalf("invalid credential + login prompt: got %v, want pause", action) + } +} + +// TestLoginScanDecision_StreakDefersTheFirstSighting covers the secondary +// hardening: one cycle is not evidence. The 3s pane poller already requires +// three consecutive sightings for the far cheaper action of a restart. +func TestLoginScanDecision_StreakDefersTheFirstSighting(t *testing.T) { + compiled := defaultLoginRegexps(t) + for s := 1; s < loginPauseMinSightings; s++ { + if action, _ := loginScanDecision("claude", loginResiduePane, compiled, false, s); action != loginScanDeferStreak { + t.Fatalf("sightings=%d: got %v, want defer-streak", s, action) + } + } + if action, _ := loginScanDecision("claude", loginResiduePane, compiled, false, loginPauseMinSightings); action != loginScanPause { + t.Fatalf("sightings=%d: got %v, want pause", loginPauseMinSightings, action) + } +} + +// TestLoginScanDecision_UnrelatedPaneIsIgnored is the control: without it every +// assertion above would also hold for a decision function that never matched. +func TestLoginScanDecision_UnrelatedPaneIsIgnored(t *testing.T) { + compiled := defaultLoginRegexps(t) + working := strings.Join([]string{ + "● Opened https://github.com/kubestellar/hive/pull/1234", + "", + "✻ Cogitated for 2m 10s", + "", + "❯ ", + }, "\n") + for _, credentialValid := range []bool{true, false} { + if action, re := loginScanDecision("claude", working, compiled, credentialValid, 99); action != loginScanIgnore || re != nil { + t.Fatalf("credentialValid=%v: got (%v,%v), want ignore/nil", credentialValid, action, re) + } + } +} + +// TestLoginScanDecision_BlockingPromptStandsDown pins the pre-existing modal +// stand-down through the extracted decision, so the refactor cannot drop it. +// Pausing for a folder-trust modal cancels the watcher that would answer it. +func TestLoginScanDecision_BlockingPromptStandsDown(t *testing.T) { + compiled := defaultLoginRegexps(t) + trustPane := strings.Join([]string{ + "Do you trust the files in this folder?", + "Please run /login after continuing", + "❯ 1. Yes, proceed", + " 2. No, exit", + }, "\n") + if action, _ := loginScanDecision("copilot", trustPane, compiled, false, 99); action != loginScanIgnore { + t.Fatalf("a startup modal must not be treated as a login problem, got %v", action) + } +} + +// --- the sighting tracker --------------------------------------------------- + +func TestLoginSightingTracker_AccumulatesAndResets(t *testing.T) { + tr := newLoginSightingTracker() + + if got := tr.observe("supervisor", true); got != 1 { + t.Fatalf("first sighting = %d, want 1", got) + } + if got := tr.observe("supervisor", true); got != 2 { + t.Fatalf("second consecutive sighting = %d, want 2", got) + } + // A clean cycle breaks the streak — the whole point of "consecutive". + if got := tr.observe("supervisor", false); got != 0 { + t.Fatalf("clean cycle = %d, want 0", got) + } + if got := tr.observe("supervisor", true); got != 1 { + t.Fatalf("after a clean cycle the count must restart, got %d", got) + } + // Agents are counted independently. + if got := tr.observe("quality", true); got != 1 { + t.Fatalf("a second agent must have its own count, got %d", got) + } + + tr.forget("supervisor") + if got := tr.observe("supervisor", true); got != 1 { + t.Fatalf("forget must clear the count, got %d", got) + } +} + +// TestLoginSightingTracker_RetainDropsAbsentAgents keeps a long-lived process +// from accumulating a map entry per agent that ever existed. +func TestLoginSightingTracker_RetainDropsAbsentAgents(t *testing.T) { + tr := newLoginSightingTracker() + tr.observe("supervisor", true) + tr.observe("quality", true) + + tr.retain(map[string]bool{"supervisor": true}) + if len(tr.streak) != 1 { + t.Fatalf("retain left %d entries, want 1: %v", len(tr.streak), tr.streak) + } + if got := tr.observe("quality", true); got != 1 { + t.Fatalf("a dropped agent must start fresh, got %d", got) + } + if got := tr.observe("supervisor", true); got != 2 { + t.Fatalf("a retained agent must keep its count, got %d", got) + } +} + +// TestLoginSightingTracker_NilFallsBackToSingleObservation pins what a missing +// tracker means: the streak gate is disabled and the detector behaves as it did +// before #5291. It must NOT mean "never pause". +func TestLoginSightingTracker_NilFallsBackToSingleObservation(t *testing.T) { + var tr *loginSightingTracker + if got := tr.observe("supervisor", true); got < loginPauseMinSightings { + t.Fatalf("a nil tracker must not silently disable pausing, got %d", got) + } + if got := tr.observe("supervisor", false); got != 0 { + t.Fatalf("a clean pane must still read as no sighting, got %d", got) + } + // Must not panic. + tr.forget("supervisor") + tr.retain(map[string]bool{}) +} + +// TestLoginScanVerdict_NoMatchAlwaysIgnores pins the invariant the scan loop +// relies on: a non-Ignore verdict implies a pattern was matched, so the logging +// in each of the other branches can dereference it without a nil check. +func TestLoginScanVerdict_NoMatchAlwaysIgnores(t *testing.T) { + for _, credentialValid := range []bool{true, false} { + for _, sightings := range []int{0, 1, loginPauseMinSightings, 99} { + if got := loginScanVerdict(false, credentialValid, sightings); got != loginScanIgnore { + t.Fatalf("no match (credentialValid=%v sightings=%d): got %v, want ignore", + credentialValid, sightings, got) + } + } + } +} + +// TestLoginScanMatch_ReturnsThePatternItMatched keeps match and verdict honest +// about each other: the loop advances the streak from this function's answer and +// then logs the pattern it returned. +func TestLoginScanMatch_ReturnsThePatternItMatched(t *testing.T) { + compiled := defaultLoginRegexps(t) + re := loginScanMatch("claude", loginResiduePane, compiled) + if re == nil { + t.Fatal("the residue pane carries a default login pattern and must match") + } + if !re.MatchString(loginResiduePane) { + t.Fatalf("returned pattern %q does not actually match the pane it was returned for", re.String()) + } + if got := loginScanMatch("claude", "all quiet\n❯ ", compiled); got != nil { + t.Fatalf("a clean pane must match nothing, got %q", got.String()) + } +} diff --git a/src/cmd/hive/login_scan_gates_test.go b/src/cmd/hive/login_scan_gates_test.go index 70c865b53..3c714f866 100644 --- a/src/cmd/hive/login_scan_gates_test.go +++ b/src/cmd/hive/login_scan_gates_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/kubestellar/hive/pkg/agent" "github.com/kubestellar/hive/pkg/config" ) @@ -34,7 +35,25 @@ func TestScanForLoginRequiredStandsDownWithoutUsablePatterns(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Must return without touching any of the nil collaborators. scanForLoginRequired(context.Background(), loginScanConfig(tc.patterns), - nil, nil, nil, restoreTestLogger()) + nil, nil, nil, restoreTestLogger(), nil) }) } } + +// A pattern list with at least one valid regex must clear the "stand down" +// gate and reach the per-agent scan loop, even when other entries in the +// list are blank or fail to compile. With zero agents registered, +// AllStatuses() is empty and the loop body never runs — proving the +// invalid-pattern skip itself does not panic or short-circuit the whole +// function, only the individual bad pattern. +func TestScanForLoginRequiredMixedValidityPatternsReachesScanLoop(t *testing.T) { + mgr := agent.NewManager(map[string]config.AgentConfig{}, restoreTestLogger(), agent.ProjectContext{}) + cfg := loginScanConfig([]string{"", "[unclosed", "please log in"}) + + // A nil dashSrv/notifier is safe here only because there are no running + // agents for AllStatuses() to return — the loop body that would use them + // never executes. This still proves the function gets PAST the early + // "no usable patterns" return (which the panic-on-touch test above + // verifies happens for an all-invalid list) to the scan loop itself. + scanForLoginRequired(context.Background(), cfg, mgr, nil, nil, restoreTestLogger(), newLoginSightingTracker()) +} diff --git a/src/cmd/hive/main.go b/src/cmd/hive/main.go index bcf01a392..118e659e4 100644 --- a/src/cmd/hive/main.go +++ b/src/cmd/hive/main.go @@ -57,6 +57,7 @@ import ( "github.com/kubestellar/hive/pkg/defsrc" "github.com/kubestellar/hive/pkg/discord" "github.com/kubestellar/hive/pkg/escalation" + "github.com/kubestellar/hive/pkg/forge" "github.com/kubestellar/hive/pkg/github" "github.com/kubestellar/hive/pkg/governor" "github.com/kubestellar/hive/pkg/hooks" @@ -99,7 +100,7 @@ import ( // non-release value instead of "hive (commit ...)" or a version that lies by // claiming a release number it isn't. src/Dockerfile and src/Dockerfile.hub // leave the VERSION build-arg empty for ordinary branch builds, so this Go -// default is what ships; release.yml never rebuilds (it retags an +// default is what ships; tagged-release.yml never rebuilds (it retags an // already-published image — see src/docs/releases.md), so today no build // path actually passes -X main.version=... yet. That gap is recorded as a // known limitation in src/docs/releases.md rather than silently masked here. @@ -473,17 +474,17 @@ func writePerAppIDKey(appID int64, pemData string) (string, error) { return "", fmt.Errorf("create temp app key file: %w", err) } tmpName := tmp.Name() - defer os.Remove(tmpName) // no-op once the rename below succeeds + defer func() { _ = os.Remove(tmpName) }() // no-op once the rename below succeeds if err := tmp.Chmod(spokeAppKeyFileMode); err != nil { - tmp.Close() + _ = tmp.Close() // best-effort cleanup; the chmod error is what's returned return "", fmt.Errorf("chmod temp app key file: %w", err) } if _, err := tmp.WriteString(trimmed + "\n"); err != nil { - tmp.Close() + _ = tmp.Close() // best-effort cleanup; the write error is what's returned return "", fmt.Errorf("write temp app key file: %w", err) } if err := tmp.Sync(); err != nil { - tmp.Close() + _ = tmp.Close() // best-effort cleanup; the sync error is what's returned return "", fmt.Errorf("sync temp app key file: %w", err) } if err := tmp.Close(); err != nil { @@ -1066,7 +1067,9 @@ func main() { if m.CurrentSHA != gitShort { // We booted on a different SHA than the one that requested the // upgrade: it landed. Drop the marker so the attempt budget resets. - os.Remove(upgradeMarkerStartupPath) + if err := os.Remove(upgradeMarkerStartupPath); err != nil && !os.IsNotExist(err) { + logger.Warn("failed to clear stale upgrade marker", "path", upgradeMarkerStartupPath, "error", err) + } logger.Info("upgrade landed, cleared marker", "current", gitShort, "previous", m.CurrentSHA, "target", m.TargetSHA) } else { @@ -1108,7 +1111,7 @@ func main() { // Load or generate a unique Hive ID for this instance cfg.HiveID = loadOrGenerateHiveID(logger) - os.Setenv("HIVE_ID", cfg.HiveID) + _ = os.Setenv("HIVE_ID", cfg.HiveID) // valid key/value; Setenv cannot fail on Unix // Observability (#2439): report the removed-agents tombstone LoadWithDashboardOverlay // adopted from the dashboard overlay at boot, BEFORE the startup ApplyPack below. On @@ -1244,17 +1247,23 @@ func main() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - // preShutdownHook, when set, runs in the signal handler before the context - // is canceled. It is stored after the agent manager exists and archives - // every agent's in-flight kick log to /data so a pod roll or hive upgrade - // does not destroy the latest run's scrollback (#4296). - var preShutdownHook atomic.Pointer[func()] + // preShutdownHooks run in the signal handler before the context is canceled, + // in registration order, while every connection and tmux server is still + // live. Registrations happen later in startup, once the subsystems they + // touch exist. + // + // This was a single atomic.Pointer[func()] until kubestellar/hive#5390. A + // lone pointer makes registration DESTRUCTIVE: the second Store silently + // discards the first hook, and the loss is invisible — nothing fails, a + // shutdown side effect simply stops happening. That is precisely the trap + // the WebSocket drain walked into, since the slot was already held by + // #4296's kick-log archive. A slice makes adding a hook additive by + // construction, so the next one cannot repeat the mistake. + var preShutdownHooks shutdownHooks go func() { sig := <-sigCh logger.Info("received signal, shutting down", "signal", sig) - if fn := preShutdownHook.Load(); fn != nil { - (*fn)() - } + preShutdownHooks.run() cancel() }() @@ -1498,7 +1507,7 @@ func main() { } } else { advisoryIssues[primaryRepo] = num - os.Setenv("HIVE_ADVISORY_ISSUE", fmt.Sprintf("%d", num)) + _ = os.Setenv("HIVE_ADVISORY_ISSUE", fmt.Sprintf("%d", num)) // valid key/value; Setenv cannot fail on Unix logger.Info("advisory issue ready", "repo", primaryRepo, "number", num) } } @@ -1520,7 +1529,9 @@ func main() { if brainstormPolicyDir == "" { brainstormPolicyDir = "/data/policies/examples/kubestellar/agents" } - os.MkdirAll(brainstormPolicyDir, 0o755) + if err := os.MkdirAll(brainstormPolicyDir, 0o755); err != nil { + logger.Warn("failed to create brainstorm policy dir", "path", brainstormPolicyDir, "error", err) + } if policyData, err := policies.DefaultPolicies.ReadFile("defaults/brainstorm-advisory.md"); err == nil { policyPath := filepath.Join(brainstormPolicyDir, "brainstorm-advisory.md") // Always overwrite — the embedded policy may have been updated @@ -1548,7 +1559,7 @@ func main() { // SIGTERM (pod roll, hive upgrade) destroys every tmux server and with it // the in-flight kick's scrollback; archive it to /data first (#4296). archiveOnShutdown := func() { agentMgr.ArchiveAllKickLogs("shutdown") } - preShutdownHook.Store(&archiveOnShutdown) + preShutdownHooks.add("archive-kick-logs", archiveOnShutdown) agentMgr.SetSandboxConfig(cfg.AgentSandbox) // Say out loud when the sandbox opt-in is configured but inert. The gate is @@ -1700,14 +1711,22 @@ func main() { // privilege. A denied request is quarantined, never opened. // holdLabel (F6): at hold-gated ACMM levels (L3/L4/L5) every agent-opened // PR must carry the "hold" label so the merge gate holds it for human - // approval. This is decided server-side from the authoritative hive level + // approval. Outreach content is public speech on the project's behalf, so + // it remains human-reviewed at L6 too. This is decided server-side from the + // authenticated agent identity and authoritative hive level // (GetACMMLevel), NOT from a client flag — the gh-wrapper.sh tail that used // to add the label was dead code after `exec hive-open-pr`. L1/L2 open no - // agent PRs (manual); L6 auto-merges on green (no hold). - holdLabel := func() bool { - l := agentMgr.GetACMMLevel() - return l >= acmmHoldGatedMinLevel && l <= acmmHoldGatedMaxLevel - } + // agent PRs (manual); non-outreach L6 PRs retain their existing automerge + // behavior. + holdLabel := func(agentName string) bool { + return shouldHoldAgentPR(agentName, agentMgr.GetACMMLevel()) + } + // #5117: tell the client which accounts are ours, so the + // self-authorization gate recognises an issue filed under + // project.ai_author's plain user account as hive-filed rather than + // mistaking it for a human's. The App bot is recognised without this; + // hiveIdentity() is the same resolver the duplicate-PR guard uses. + ghClient.SetHiveIdentity(hiveIdentity(cfg)) ghClient.StartPRRequestWatcher(ctx, agentMgr.AuthorizePROpen, holdLabel, nil) // Issue relay: agents request issue creation and comments by dropping a // file (hive-open-issue via the gh wrapper) instead of calling GitHub @@ -1908,6 +1927,24 @@ func main() { } dashSrv := dashboard.NewServerWithAuth(cfg.Dashboard.Port, cfg.Dashboard.AuthToken, logger) + // SIGTERM (pod roll, hive self-upgrade) kills the process and every + // contributor WebSocket with it, and until #5390 it did so without a word: + // the peer saw a bare 1006, indistinguishable from a network fault, which is + // what made #5090 take days to diagnose. Send each contributor a 1012 + // (CloseServiceRestart) first so the relay knows to reconnect immediately — + // into the replacement pod, which maxSurge=1/maxUnavailable=0 has already + // brought to readiness before this signal was delivered. + // + // Registered as its OWN hook rather than folded into archiveOnShutdown: the + // two are unrelated, and the drain must not be able to prevent the archive + // from running. addUrgent, not add, because it is the time-critical half — + // the sooner the frame is on the wire the sooner the relay reconnects, + // whereas the kick-log archive does PVC I/O on NFS and nobody is waiting on + // it. The hub is resolved lazily inside the closure because the contributor + // hub is not constructed until registerContributeRoutes runs, below. + preShutdownHooks.addUrgent("drain-contributor-websockets", func() { + dashSrv.DrainContributorsForShutdown() + }) var beadStores map[string]*beads.Store // Wire ioscan input enforcement (opt-in via ioscan.enabled) to the dashboard @@ -2474,8 +2511,12 @@ func main() { go dashboard.StartWorkspaceCleanup(ctx, logger, dashSrv.GetAudit()) - os.MkdirAll(nousSnapshotDir, 0o755) - os.MkdirAll(nousGovernorDir, 0o755) + if err := os.MkdirAll(nousSnapshotDir, 0o755); err != nil { + logger.Warn("failed to create nous snapshot dir", "path", nousSnapshotDir, "error", err) + } + if err := os.MkdirAll(nousGovernorDir, 0o755); err != nil { + logger.Warn("failed to create nous governor dir", "path", nousGovernorDir, "error", err) + } nousState := loadNousState(logger) nousState.SnapshotDir = nousSnapshotDir @@ -2586,6 +2627,14 @@ func main() { return agent.LinearCredential{} }) + // In-flight ledger + session PR link (Linear GitHub-parity follow-ups): + // the scheduler withholds work a Linear session is already working, and + // the pr-request watcher narrates opened PRs into the session. + sched.SetInflightLookup(dashSrv.LinearSessionHolder) + if ghClient != nil { + ghClient.SetPROpenedHook(dashSrv.LinearAgentPROpened) + } + dashSrv.RegisterAPI(&dashboard.Dependencies{ Config: cfg, AgentMgr: agentMgr, @@ -2664,7 +2713,7 @@ func main() { } } else { advisoryIssues[newPrimaryRepo] = num - os.Setenv("HIVE_ADVISORY_ISSUE", fmt.Sprintf("%d", num)) + _ = os.Setenv("HIVE_ADVISORY_ISSUE", fmt.Sprintf("%d", num)) // valid key/value; Setenv cannot fail on Unix dashSrv.SetGitHubAppRequired(false) dashSrv.ClearPendingGitHubAppInstall() logger.Info("advisory issue ready on new primary repo", "repo", newPrimaryRepo, "number", num) @@ -2706,7 +2755,7 @@ func main() { logger.Warn("advisory issue creation failed after reinit", "repo", primaryRepo, "error", advErr) } else { advisoryIssues[primaryRepo] = num - os.Setenv("HIVE_ADVISORY_ISSUE", fmt.Sprintf("%d", num)) + _ = os.Setenv("HIVE_ADVISORY_ISSUE", fmt.Sprintf("%d", num)) // valid key/value; Setenv cannot fail on Unix logger.Info("advisory issue ready after reinit", "repo", primaryRepo, "number", num) } } @@ -2790,7 +2839,7 @@ func main() { return false } advisoryIssues[recheckRepo] = num - os.Setenv("HIVE_ADVISORY_ISSUE", fmt.Sprintf("%d", num)) + _ = os.Setenv("HIVE_ADVISORY_ISSUE", fmt.Sprintf("%d", num)) // valid key/value; Setenv cannot fail on Unix // Finding the advisory issue only proves the app is installed // (reads succeed on public repos even with a token from the // wrong installation). Verify write capability before letting @@ -2920,7 +2969,7 @@ func main() { // genuine failure (not installed / insufficient perms). if dashSrv.RecheckGitHubApp() { if num, exists := advisoryIssues[primaryRepo]; exists { - os.Setenv("HIVE_ADVISORY_ISSUE", fmt.Sprintf("%d", num)) + _ = os.Setenv("HIVE_ADVISORY_ISSUE", fmt.Sprintf("%d", num)) // valid key/value; Setenv cannot fail on Unix } logger.Info("github app self-heal: banner cleared, app installed and write verified", "repo", primaryRepo) } else { @@ -3378,21 +3427,17 @@ func main() { // pointer — the config watcher swaps its contents in // place on reload. lc := cfg.Governor.LiteLLM - endpoint := lc.ResolveEndpoint() - if lc.LocalProxy { - // Local fallback: the Go translator forwards to the - // bundled litellm proxy on loopback instead of the - // remote endpoint. - endpoint = litellmLocalProxyURL() - } - if endpoint == "" { + // Endpoint/model resolution lives in a pure function so the + // decision tree (local proxy / legacy block / explicit-gateway + // fallback / no route at all) is unit-testable — it is not + // reachable from a test while inline in main(). See #5460. + endpoint, resolvedModel, ok := resolveLiteLLMInferenceRoute(cfg, backend, model) + if !ok { logger.Warn("litellm backend selected but no endpoint configured", "agent", agentName, "model", model) return } - if model == "" { - model = lc.DefaultModel - } + model = resolvedModel // Key source must MATCH the entitlement/probe path (gateways.go, // cost.go, openrouter.go), which resolve the key from the gateway // via ResolveGateway(backend).ResolveAPIKey(). When an EXPLICIT @@ -4135,7 +4180,9 @@ func main() { attemptCount = m.Attempts } else { // Different SHA or a different target — the old marker is stale. - os.Remove(upgradeMarkerPath) + if err := os.Remove(upgradeMarkerPath); err != nil && !os.IsNotExist(err) { + logger.Warn("failed to clear stale upgrade marker", "path", upgradeMarkerPath, "error", err) + } } } @@ -5860,7 +5907,7 @@ func runEvalCycle( num, retryErr := ghClient.EnsureAdvisoryIssue(ctx, primaryRepoAtCycleStart) if retryErr == nil { advisoryIssues[primaryRepoAtCycleStart] = num - os.Setenv("HIVE_ADVISORY_ISSUE", fmt.Sprintf("%d", num)) + _ = os.Setenv("HIVE_ADVISORY_ISSUE", fmt.Sprintf("%d", num)) // valid key/value; Setenv cannot fail on Unix logger.Info("advisory issue resolved on retry", "repo", primaryRepoAtCycleStart, "number", num) } else { advisoryEnsureErr = retryErr @@ -5936,11 +5983,12 @@ func runEvalCycle( // bounded ring in one cycle, and no I/O happens on this path. recordEnumeratedIssues(ctx, dashSrv, actionable) - escalatedPRs := runEscalationSweep(ctx, cfg, ghClient, actionable, notifier, logger) + escalatedPRs := runEscalationSweep(ctx, cfg, governorForge(cfg, ghClient, logger), actionable, notifier, logger) intentVerdicts := writeIntentVerdicts(ctx, cfg, ghClient, actionable, beadStores, logger) refreshReviewVerdicts(cfg, logger) - writeMergeEligible(actionable, actionable.Hold, cfg.Project.Org, escalatedPRs, cfg.Intent.Enforce, intentVerdicts, cfg.Review.RequireApproval, logger) + requiredCheckSet, _ := cfg.AutoMerge.RequiredCheckSet() + writeMergeEligible(actionable, actionable.Hold, cfg.Project.Org, escalatedPRs, cfg.Intent.Enforce, intentVerdicts, cfg.Review.RequireApproval, requiredCheckSet, logger) // Stuck-PR reaper (backstop): re-dispatch a fix for any hive-authored PR // that is red on a required check AND stale (its red head SHA unchanged past @@ -6276,7 +6324,7 @@ func runEvalCycle( } // Scan agent panes for login-required patterns and pause + notify if detected - scanForLoginRequired(ctx, cfg, agentMgr, notifier, dashSrv, logger) + scanForLoginRequired(ctx, cfg, agentMgr, notifier, dashSrv, logger, loginSightings) // Epoch captured before reading agent/governor state so a mutation that // lands mid-build (restart-count/budget reset) drops this snapshot instead @@ -6719,6 +6767,173 @@ func loginCommandForBackend(backend string) string { } } +// loginScanAction is what the detector should do about one agent this cycle. +type loginScanAction int + +const ( + // loginScanIgnore: nothing that looks like a login problem, or a startup + // modal is on screen. Any sighting streak is cleared. + loginScanIgnore loginScanAction = iota + // loginScanDeferAuthenticated: the pane matched, but the backend credential + // is demonstrably valid, so this is residue or a stuck CLI — the manager's + // token-restart heal's case, not an operator's (kubestellar/hive#5291). + loginScanDeferAuthenticated + // loginScanDeferStreak: the pane matched and the credential is not provably + // good, but this is the first consecutive cycle to see it. + loginScanDeferStreak + // loginScanPause: pause the agent and page the operator. + loginScanPause +) + +// loginPauseMinSightings is how many CONSECUTIVE governor cycles must see a +// login pattern before the detector pauses (kubestellar/hive#5291). +// +// The manager's own pane poller learned this at its ~3s cadence, where a single +// sighting restarted healthy agents; it now requires loginStreakRestartMin = 3. +// The detector had no equivalent, and a pause is far more expensive than a +// restart — it is sticky, it needs a human to undo, and it cancels the agent +// context that hosts the heal. Two is deliberate rather than three: a governor +// cycle is minutes, not seconds, so each extra cycle is real delay for a +// genuine logout, and the credential gate above already covers the case this +// backstops. It matters most for backends with no credential file this process +// can check, where it is the only new protection. +const loginPauseMinSightings = 2 + +// loginSightingTracker counts CONSECUTIVE cycles in which each agent's pane +// matched a login pattern. A clean cycle resets the count to zero, so a match +// has to persist to accumulate — a single flicker never reaches the threshold. +type loginSightingTracker struct { + mu sync.Mutex + streak map[string]int +} + +func newLoginSightingTracker() *loginSightingTracker { + return &loginSightingTracker{streak: map[string]int{}} +} + +// loginSightings is the detector's process-scoped state. The governor cycle is +// a function rather than an object, so the consecutive-sighting counts have to +// outlive a single call; tests build their own tracker and pass it explicitly. +var loginSightings = newLoginSightingTracker() + +// observe records this cycle's reading for one agent and returns the resulting +// consecutive-sighting count (1 on the first sighting). +func (t *loginSightingTracker) observe(agent string, matched bool) int { + if t != nil { + t.mu.Lock() + defer t.mu.Unlock() + } + if t == nil { + // No tracker wired: behave as if every sighting is its own streak, which + // is exactly the pre-#5291 single-observation behaviour. + if matched { + return loginPauseMinSightings + } + return 0 + } + if !matched { + delete(t.streak, agent) + return 0 + } + t.streak[agent]++ + return t.streak[agent] +} + +// forget drops an agent's streak — on pause (it stops being scanned) and for +// agents that are no longer present, so the map cannot grow without bound +// across a long-lived process. +func (t *loginSightingTracker) forget(agent string) { + if t == nil { + return + } + t.mu.Lock() + defer t.mu.Unlock() + delete(t.streak, agent) +} + +// retain drops every agent not in the given set. +func (t *loginSightingTracker) retain(present map[string]bool) { + if t == nil { + return + } + t.mu.Lock() + defer t.mu.Unlock() + for name := range t.streak { + if !present[name] { + delete(t.streak, name) + } + } +} + +// loginScanDecision is the detector's whole judgement about one agent, as a +// pure function of what was observed. It exists apart from scanForLoginRequired +// so the decision can be tested against real pane text without a tmux session, +// a manager, or a governor cycle. +// +// sightings is the consecutive-cycle count INCLUDING this one. +// +// The credential gate is the fix for kubestellar/hive#5291: the detector used +// to pause on pane text alone, and the pane during and just after an +// interactive /login necessarily contains login-screen chrome — so it fired on +// the evidence the operator's own fix had just produced, seven minutes after +// the credential was already valid. Worse, Pause() cancels the agent context +// and tears down the poller that hosts the token-restart heal (#4606), which is +// the mechanism built for exactly "login prompt on screen, credential valid". +// Pausing first therefore disabled the machinery that would have fixed the pane +// it misread. +// +// Text matching cannot be narrowed out of this: two earlier fixes tried +// (tail-only matching, then a tighter copilot pattern) and this incident is the +// third false positive. The pane legitimately contains login text at the moment +// the credential is freshest, so the credential has to be consulted. +func loginScanDecision( + backend, paneText string, + compiled []*regexp.Regexp, + credentialValid bool, + sightings int, +) (loginScanAction, *regexp.Regexp) { + matched := loginScanMatch(backend, paneText, compiled) + return loginScanVerdict(matched != nil, credentialValid, sightings), matched +} + +// loginScanMatch reports which login pattern this pane trips, or nil for none. +// Separate from the verdict so the scan loop can match ONCE and use the answer +// both to advance the sighting streak and to decide. +func loginScanMatch(backend, paneText string, compiled []*regexp.Regexp) *regexp.Regexp { + // Stand down while a startup-blocking modal (folder trust, codex update, …) + // is on screen: that is not a login problem, and pausing the agent for it + // cancels the trust-prompt watcher that would answer it — the deadlock that + // kept copilot agents "sitting at login prompt" through every operator + // re-login (kubestellar/hive, 2026-08-22). The watcher answers the modal + // within seconds; if a REAL login prompt follows, the next detector tick + // sees it on a clean pane. + if agent.PaneShowsBlockingPrompt(backend, paneText) { + return nil + } + for _, re := range compiled { + if re.MatchString(paneText) { + return re + } + } + return nil +} + +// loginScanVerdict turns "what the pane showed" into "what to do". It returns +// loginScanIgnore whenever matched is false, which is what lets the scan loop +// rely on a non-Ignore verdict implying a non-nil pattern to log. +func loginScanVerdict(matched, credentialValid bool, sightings int) loginScanAction { + if !matched { + return loginScanIgnore + } + if credentialValid { + return loginScanDeferAuthenticated + } + if sightings < loginPauseMinSightings { + return loginScanDeferStreak + } + return loginScanPause +} + // scanForLoginRequired checks each running agent's tmux pane output for login-required // patterns. When a match is found, the agent is paused and a notification is sent. func scanForLoginRequired( @@ -6728,6 +6943,7 @@ func scanForLoginRequired( notifier *notify.Notifier, dashSrv *dashboard.Server, logger *slog.Logger, + sightings *loginSightingTracker, ) { patterns := cfg.Governor.Sensing.LoginPatterns if len(patterns) == 0 { @@ -6760,10 +6976,12 @@ func scanForLoginRequired( // Same discipline as the poller's tail-only match (#4577). const paneLines = 12 statuses := agentMgr.AllStatuses() + scanned := make(map[string]bool, len(statuses)) for name, proc := range statuses { if proc.State != "running" { continue } + scanned[name] = true output, err := agentMgr.GetOutput(name, paneLines) if err != nil || len(output) == 0 { @@ -6771,62 +6989,78 @@ func scanForLoginRequired( } joined := strings.Join(output, "\n") + backend := cfg.Agents[name].Backend - // Stand down while a startup-blocking modal (folder trust, codex - // update, …) is on screen: that is not a login problem, and pausing - // the agent for it cancels the trust-prompt watcher that would answer - // it — the deadlock that kept copilot agents "sitting at login prompt" - // through every operator re-login (kubestellar/hive, 2026-08-22). The - // watcher answers the modal within seconds; if a REAL login prompt - // follows, the next detector tick sees it on a clean pane. - if agent.PaneShowsBlockingPrompt(cfg.Agents[name].Backend, joined) { - continue - } - - for _, re := range compiled { - if re.MatchString(joined) { - logger.Warn("login required detected", - "agent", name, - "pattern", re.String(), - ) + // #5291: ask the CREDENTIAL, not just the pane. A valid credential plus + // a login prompt is the token-restart heal's case; only an invalid one + // needs a human. + credentialValid := agentMgr.AgentHasValidCredential(name) - // Attempt a per-agent token re-cache BEFORE pausing. On an - // App-authenticated hive the likeliest cause of a "gh auth - // login" prompt is an expired scoped-token cache (#4072); - // re-minting it now means the operator's Resume immediately - // works instead of 401ing straight back into this pause. - // Best-effort: hives without App auth (or agents without a - // dedicated UID) simply skip it. - if refreshErr := agentMgr.RefreshAgentTokenFor(ctx, name); refreshErr == nil { - logger.Info("re-cached per-agent scoped token before login-detector pause", "agent", name) - } + // Match once. The streak has to reflect what the pane SHOWED, including + // on the cycles where a gate below declines to act on it, so the + // sighting is recorded before the verdict is taken. + re := loginScanMatch(backend, joined, compiled) + streak := sightings.observe(name, re != nil) - // Pause the agent instead of restarting - if pauseErr := agentMgr.Pause(name, "login-detector", "login required detected"); pauseErr != nil { - logger.Warn("failed to pause agent after login detection", - "agent", name, "error", pauseErr) - } else { - dashSrv.AuditLog("system", "pause", "trigger=login-detector", name) - } - - // Determine the login instruction based on the agent's backend - backend := cfg.Agents[name].Backend - loginCmd := loginCommandForBackend(backend) + switch loginScanVerdict(re != nil, credentialValid, streak) { + case loginScanIgnore: + continue + case loginScanDeferAuthenticated: + // Logged at Info, not Warn: this is the detector working correctly, + // and it is the line that explains an agent staying up with login + // text on its pane. + logger.Info("login pattern matched but the backend credential is valid — leaving it to the token-restart heal", + "agent", name, "backend", backend, "pattern", re.String()) + continue + case loginScanDeferStreak: + logger.Info("login pattern matched but not yet on enough consecutive cycles — deferring", + "agent", name, "backend", backend, "pattern", re.String(), + "sightings", streak, "required", loginPauseMinSightings) + continue + case loginScanPause: + logger.Warn("login required detected", + "agent", name, + "pattern", re.String(), + "sightings", streak, + ) + sightings.forget(name) + + // Attempt a per-agent token re-cache BEFORE pausing. On an + // App-authenticated hive the likeliest cause of a "gh auth + // login" prompt is an expired scoped-token cache (#4072); + // re-minting it now means the operator's Resume immediately + // works instead of 401ing straight back into this pause. + // Best-effort: hives without App auth (or agents without a + // dedicated UID) simply skip it. + if refreshErr := agentMgr.RefreshAgentTokenFor(ctx, name); refreshErr == nil { + logger.Info("re-cached per-agent scoped token before login-detector pause", "agent", name) + } + + // Pause the agent instead of restarting + if pauseErr := agentMgr.Pause(name, "login-detector", "login required detected"); pauseErr != nil { + logger.Warn("failed to pause agent after login detection", + "agent", name, "error", pauseErr) + } else { + dashSrv.AuditLog("system", "pause", "trigger=login-detector", name) + } - notifier.Send( - fmt.Sprintf("\U0001F511 Login required: %s", name), - fmt.Sprintf( - "Agent '%s' needs authentication. Open the agent's terminal "+ - "(tmux attach -t hive-%s) and run the login command for the CLI (%s). %s", - name, name, backend, loginCmd, - ), - notify.PriorityHigh, - ) + // Determine the login instruction based on the agent's backend + loginCmd := loginCommandForBackend(backend) - break // one match per agent is enough - } + notifier.Send( + fmt.Sprintf("\U0001F511 Login required: %s", name), + fmt.Sprintf( + "Agent '%s' needs authentication. Open the agent's terminal "+ + "(tmux attach -t hive-%s) and run the login command for the CLI (%s). %s", + name, name, backend, loginCmd, + ), + notify.PriorityHigh, + ) } } + // Agents that vanished (removed from config, stopped) must not keep a + // streak alive in the map for the life of the process. + sightings.retain(scanned) } func convertKnowledgeLayers(cfgLayers []config.KnowledgeLayer) []knowledge.LayerConfig { @@ -7389,16 +7623,21 @@ func reapStuckRedPRs(cfg *config.Config, actionable *github.ActionableResult, es // escalated PR keys so the work-list writers can flag them. Deterministic by // design: no agent judgment is involved in counting, evidence, or the // stop-order. Human-authored PRs are never escalated. +// +// The two forge writes go through forge.IssueWriter rather than *github.Client +// so the evidence lands on whichever forge the hive is actually configured for +// (see governorForge in forgewire.go). On a GitHub hive the writer IS the +// *github.Client this used to take, so nothing about that path changed. func runEscalationSweep( ctx context.Context, cfg *config.Config, - ghClient *github.Client, + writer forge.IssueWriter, actionable *github.ActionableResult, notifier *notify.Notifier, logger *slog.Logger, ) map[string]bool { escalated := map[string]bool{} - if cfg.Escalation.Disabled || ghClient == nil || actionable == nil { + if cfg.Escalation.Disabled || writer == nil || actionable == nil { return escalated } getEscalationStore() @@ -7450,14 +7689,14 @@ func runEscalationSweep( excerpt = escalationStore.Excerpt(o.Repo, o.Number) } body := escalation.CommentBody(r.Attempts, meta[key].checks, excerpt) - if err := ghClient.CreateIssueComment(ctx, o.Repo, o.Number, body); err != nil { + if err := writer.CreateIssueComment(ctx, o.Repo, o.Number, body); err != nil { // Retry next pass rather than marking escalated with no comment: // the whole point is that the evidence reaches a human. logger.Warn("escalation comment failed; will retry next pass", "repo", o.Repo, "pr", o.Number, "error", err) continue } - if err := ghClient.AddLabels(ctx, o.Repo, o.Number, []string{escalation.NeedsHumanLabel}); err != nil { + if err := writer.AddLabels(ctx, o.Repo, o.Number, []string{escalation.NeedsHumanLabel}); err != nil { logger.Warn("escalation label failed", "repo", o.Repo, "pr", o.Number, "error", err) } escalationStore.MarkEscalated(o.Repo, o.Number) @@ -7633,6 +7872,16 @@ const ( acmmHoldGatedMaxLevel = 5 ) +// shouldHoldAgentPR keeps public outreach claims human-reviewed even at L6, +// where ordinary agent PRs may auto-merge. The general ACMM hold gate remains +// unchanged for all roles at L3-L5. +func shouldHoldAgentPR(agentName string, level int) bool { + if strings.EqualFold(strings.TrimSpace(agentName), "outreach") { + return true + } + return level >= acmmHoldGatedMinLevel && level <= acmmHoldGatedMaxLevel +} + // mergeableJSONUnknown is the explicit wire value for "mergeability was never // determined". It is spelled out rather than left as "" so a consumer reading // merge-eligible.json cannot mistake an unpopulated field for a definitive @@ -8202,7 +8451,18 @@ func auditPRAgents(org string, since time.Time, auditPath string) map[string]str return out } -func writeMergeEligible(actionable *github.ActionableResult, hold github.HoldResult, org string, escalatedPRs map[string]bool, enforceIntent bool, intentVerdicts map[string]intent.Verdict, requireReviewApproval bool, logger *slog.Logger) { +// anyRequiredCheckFailing reports whether any of a PR's failing check names +// is in the operator-declared required set. +func anyRequiredCheckFailing(failing []string, required map[string]bool) bool { + for _, name := range failing { + if required[name] { + return true + } + } + return false +} + +func writeMergeEligible(actionable *github.ActionableResult, hold github.HoldResult, org string, escalatedPRs map[string]bool, enforceIntent bool, intentVerdicts map[string]intent.Verdict, requireReviewApproval bool, requiredChecks map[string]bool, logger *slog.Logger) { holdSet := make(map[string]bool) for _, h := range hold.Items { key := fmt.Sprintf("%s/%d", h.Repo, h.Number) @@ -8284,18 +8544,35 @@ func writeMergeEligible(actionable *github.ActionableResult, hold github.HoldRes } if pr.CIStatus == "failure" { - failing = append(failing, failingPR{ - Number: pr.Number, - Repo: fullRepo, - Title: pr.Title, - Author: pr.Author, - HeadSHA: pr.HeadSHA, - FailingChecks: pr.FailingChecks, - Excerpt: pr.CIFailureExcerpt, - Escalated: escalatedPRs[escalation.Key(fullRepo, pr.Number)], - Agent: prAgents[fmt.Sprintf("%s#%d", fullRepo, pr.Number)], - }) - continue + // A PR red ONLY on non-required checks (perma-red Playwright + // shards, coverage) that GitHub itself reports mergeable is NOT a + // failing PR — it is merge-eligible, mirroring the + // pending-but-mergeable rule below. Without this, every dependabot + // PR on a repo with permanently-red optional checks classified as + // "failure", landed in ci-failing.json where no sweep or agent + // would ever merge it, and accumulated indefinitely (observed on + // kubestellar/console 2026-08-28: 16 dependabot PRs, oldest 11 + // days). Gated on an operator-declared required-check set: with no + // set configured we cannot distinguish required from optional and + // keep the old fail-closed behavior. The merge step re-enforces + // branch protection, so this cannot merge anything GitHub blocks. + onlyOptionalRed := len(requiredChecks) > 0 && + !anyRequiredCheckFailing(pr.FailingChecks, requiredChecks) && + pr.Mergeable == github.MergeableYes + if !onlyOptionalRed { + failing = append(failing, failingPR{ + Number: pr.Number, + Repo: fullRepo, + Title: pr.Title, + Author: pr.Author, + HeadSHA: pr.HeadSHA, + FailingChecks: pr.FailingChecks, + Excerpt: pr.CIFailureExcerpt, + Escalated: escalatedPRs[escalation.Key(fullRepo, pr.Number)], + Agent: prAgents[fmt.Sprintf("%s#%d", fullRepo, pr.Number)], + }) + continue + } } // A PR whose CI is still "pending" is nonetheless merge-eligible when @@ -8316,11 +8593,23 @@ func writeMergeEligible(actionable *github.ActionableResult, hold github.HoldRes continue } + if pr.Mergeable == github.MergeableNo { + // A conflicting PR cannot merge no matter how green its checks + // are. Listing it as merge-eligible left the eligible count stuck + // at N forever while nothing could actually merge (console + // #23002/#23003, 2026-08-31: the only two build-gate-green PRs + // were DIRTY go.mod dependabot bumps). Conflicts are the + // rebase/needs-human path's job, not the sweep's — keep them out + // of the eligible bucket. + continue + } + dco := "unknown" for _, l := range pr.Labels { - if l == "dco-signoff: yes" { + switch l { + case "dco-signoff: yes": dco = "yes" - } else if l == "dco-signoff: no" { + case "dco-signoff: no": dco = "no" } } @@ -8758,7 +9047,9 @@ func parseColorInt(color string) int { return 0x95a5a6 } var result int - fmt.Sscanf(color, "%x", &result) + if _, err := fmt.Sscanf(color, "%x", &result); err != nil { + return 0x95a5a6 // malformed hex: fall back to the same default as an empty string + } return result } @@ -8826,6 +9117,57 @@ func runHub(logger *slog.Logger, configPath string) { logger.Info("hub server stopped") } +// resolveLiteLLMInferenceRoute resolves the endpoint and model an agent's +// inference route should use for the built-in "litellm" backend. It is the +// whole route-install decision tree for that backend, lifted out of main() so +// it can be unit-tested (#5460); main() calls it and keeps ownership of key, +// CA bundle and logging. +// +// requestedModel is the model the agent asked for ("" when it named none). The +// returned model is that request when non-empty, otherwise the default +// inherited from whichever source supplied the endpoint. +// +// Resolution order — each step matches the behavior shipped in 231ca4b: +// +// 1. local_proxy: the Go translator forwards to the bundled litellm proxy on +// loopback, overriding any configured remote endpoint. +// 2. the legacy governor.litellm block (HIVE_LITELLM_ENDPOINT or yaml), whose +// default_model supplies the model. +// 3. the EXPLICIT gateway named by this backend. A hive configured only +// through the Model Gateways tab leaves the legacy block empty; the key +// and CA bundle already resolve from that gateway, so the endpoint must +// too, or NO route is installed and every agent call dies "502 no +// inference route" while the Gateways tab Test button happily passes +// (ains-validation/pocketmini, 2026-08-31 — #5393). +// +// ok is false when no source yields an endpoint: the caller must warn and +// install NO route. It never invents an endpoint, and never returns a route +// with an empty endpoint — a silently empty endpoint is the 502 this whole +// path exists to prevent. +func resolveLiteLLMInferenceRoute(cfg *config.Config, backend, requestedModel string) (endpoint, model string, ok bool) { + lc := cfg.Governor.LiteLLM + model = requestedModel + endpoint = lc.ResolveEndpoint() + if lc.LocalProxy { + endpoint = litellmLocalProxyURL() + } + if endpoint == "" { + if gw := cfg.Governor.ResolveGateway(backend); gw != nil && gw.Endpoint != "" { + endpoint = gw.Endpoint + if model == "" { + model = gw.DefaultModel + } + } + } + if endpoint == "" { + return "", requestedModel, false + } + if model == "" { + model = lc.DefaultModel + } + return endpoint, model, true +} + // resolveWatsonxGateway finds the gateway backing the built-in "watsonx" agent // backend. It prefers a gateway explicitly NAMED watsonx, then falls back to // the first gateway of KIND watsonx — so `backend: watsonx` works whether the diff --git a/src/cmd/hive/outreach_hold_test.go b/src/cmd/hive/outreach_hold_test.go new file mode 100644 index 000000000..a9aa97997 --- /dev/null +++ b/src/cmd/hive/outreach_hold_test.go @@ -0,0 +1,27 @@ +package main + +import "testing" + +func TestShouldHoldAgentPR(t *testing.T) { + tests := []struct { + name string + agent string + level int + want bool + }{ + {name: "outreach remains held at full autonomy", agent: "outreach", level: 6, want: true}, + {name: "outreach identity is normalized", agent: " Outreach ", level: 6, want: true}, + {name: "other agents remain autonomous at level 6", agent: "scanner", level: 6, want: false}, + {name: "all agents are held at level 3", agent: "scanner", level: 3, want: true}, + {name: "all agents are held at level 5", agent: "quality", level: 5, want: true}, + {name: "manual level does not add a hold", agent: "scanner", level: 2, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldHoldAgentPR(tt.agent, tt.level); got != tt.want { + t.Fatalf("shouldHoldAgentPR(%q, %d) = %v, want %v", tt.agent, tt.level, got, tt.want) + } + }) + } +} diff --git a/src/cmd/hive/review_dispatch_wiring_test.go b/src/cmd/hive/review_dispatch_wiring_test.go new file mode 100644 index 000000000..d7921761d --- /dev/null +++ b/src/cmd/hive/review_dispatch_wiring_test.go @@ -0,0 +1,283 @@ +package main + +// Tests for the cmd/hive review-swarm wiring layer: planReviewDispatch, +// refreshReviewVerdicts, and persistReviewDispatchState (main.go). These +// functions translate hive config + the actionable PR snapshot into +// pkg/review dispatch plans and persist the resulting state. They were at +// 0-10% coverage; the pkg/review engine itself is already well covered, so +// these tests focus on the glue: config gating, PR/agent capability mapping, +// and artifact/state file round-trips. +// +// All review state paths are redirected into t.TempDir() so the tests are +// hermetic on hosts with a live /var/run/hive-metrics. + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kubestellar/hive/pkg/config" + "github.com/kubestellar/hive/pkg/github" + "github.com/kubestellar/hive/pkg/outputschema" + "github.com/kubestellar/hive/pkg/review" +) + +const reviewTestSHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +// redirectReviewPaths points every package-level review/outputschema path at +// a fresh temp dir and restores the originals on cleanup. +func redirectReviewPaths(t *testing.T) string { + t.Helper() + dir := t.TempDir() + oldState := review.ReviewDispatchStatePath + oldVerdicts := review.ReviewVerdictsPath + oldReports := outputschema.AgentReportDir + review.ReviewDispatchStatePath = filepath.Join(dir, review.ReviewDispatchStateFile) + review.ReviewVerdictsPath = filepath.Join(dir, review.ReviewVerdictsFile) + outputschema.AgentReportDir = dir + t.Cleanup(func() { + review.ReviewDispatchStatePath = oldState + review.ReviewVerdictsPath = oldVerdicts + outputschema.AgentReportDir = oldReports + }) + return dir +} + +func reviewSwarmConfig() *config.Config { + return &config.Config{ + Project: config.ProjectConfig{Org: "kubestellar", AIAuthor: "hive-bot"}, + Review: config.ReviewConfig{ + RequireApproval: true, + FanOut: true, + ReviewerAgents: []string{"reviewer"}, + FixerAgent: "scanner", + }, + Agents: map[string]config.AgentConfig{ + "reviewer": {Enabled: true, Role: "review specialist"}, + "scanner": {Enabled: true, Role: "scanner"}, + "disabled": {Enabled: false, Role: "review"}, + }, + } +} + +func actionableWithPR(author string) *github.ActionableResult { + return &github.ActionableResult{ + PRs: github.PRResult{Items: []github.PullRequest{{ + Repo: "kubestellar/hive", + Number: 4321, + Title: "test: add coverage", + Author: author, + HeadSHA: reviewTestSHA, + URL: "https://github.com/kubestellar/hive/pull/4321", + }}}, + } +} + +// Config/nil gating of planReviewDispatch is covered by +// review_dispatch_gates_test.go (TestPlanReviewDispatchRequiresBothToggles, +// TestPlanReviewDispatchNilInputs); these tests cover the dispatch paths past +// the gate. + +func TestPlanReviewDispatch_KicksReviewerForAgentAuthoredPR(t *testing.T) { + redirectReviewPaths(t) + plan := planReviewDispatch(reviewSwarmConfig(), actionableWithPR("hive-bot"), nil, restoreTestLogger()) + + // A single reviewer agent is throttled to one perspective per cycle. + if len(plan.ReviewKicks) != 1 { + t.Fatalf("expected 1 review kick for a single reviewer, got %d", len(plan.ReviewKicks)) + } + kick := plan.ReviewKicks[0] + if kick.Agent != "reviewer" { + t.Errorf("kick agent = %q, want %q", kick.Agent, "reviewer") + } + if kick.Repo != "kubestellar/hive" || kick.Number != 4321 || kick.HeadSHA != reviewTestSHA { + t.Errorf("kick PR identity = %s#%d@%s, want kubestellar/hive#4321@%s", + kick.Repo, kick.Number, kick.HeadSHA, reviewTestSHA) + } + if kick.Kind != "review" { + t.Errorf("kick kind = %q, want %q", kick.Kind, "review") + } + if len(plan.State.Pending) != 1 { + t.Fatalf("expected 1 pending review recorded in state, got %d", len(plan.State.Pending)) + } + if plan.State.Pending[0].Agent != "reviewer" || plan.State.Pending[0].Number != 4321 { + t.Errorf("pending review = %+v, want agent reviewer on PR 4321", plan.State.Pending[0]) + } +} + +func TestPlanReviewDispatch_SkipsHumanAuthoredPR(t *testing.T) { + redirectReviewPaths(t) + plan := planReviewDispatch(reviewSwarmConfig(), actionableWithPR("some-human"), nil, restoreTestLogger()) + if len(plan.ReviewKicks) != 0 || len(plan.FixKicks) != 0 { + t.Fatalf("human-authored PR must not be dispatched, got %d review kicks and %d fix kicks", + len(plan.ReviewKicks), len(plan.FixKicks)) + } +} + +func TestPlanReviewDispatch_PausedAgentConfigExcludesReviewer(t *testing.T) { + redirectReviewPaths(t) + cfg := reviewSwarmConfig() + reviewer := cfg.Agents["reviewer"] + reviewer.Paused = true + cfg.Agents["reviewer"] = reviewer + + plan := planReviewDispatch(cfg, actionableWithPR("hive-bot"), nil, restoreTestLogger()) + if len(plan.ReviewKicks) != 0 { + t.Fatalf("paused reviewer must not receive kicks, got %d", len(plan.ReviewKicks)) + } +} + +func TestPlanReviewDispatch_ChangesRequestedVerdictDispatchesFixer(t *testing.T) { + redirectReviewPaths(t) + artifact := review.Artifact{ + GeneratedAt: time.Now().UTC(), + Items: []review.Aggregate{{ + Repo: "kubestellar/hive", + Number: 4321, + HeadSHA: reviewTestSHA, + Verdict: review.VerdictChangesRequested, + }}, + } + if err := review.WriteArtifact("", artifact); err != nil { + t.Fatalf("seed verdict artifact: %v", err) + } + + plan := planReviewDispatch(reviewSwarmConfig(), actionableWithPR("hive-bot"), nil, restoreTestLogger()) + if len(plan.ReviewKicks) != 0 { + t.Errorf("PR with an aggregate verdict must not be re-reviewed, got %d review kicks", len(plan.ReviewKicks)) + } + if len(plan.FixKicks) != 1 { + t.Fatalf("expected 1 fix kick, got %d", len(plan.FixKicks)) + } + if plan.FixKicks[0].Agent != "scanner" { + t.Errorf("fix kick agent = %q, want configured fixer %q", plan.FixKicks[0].Agent, "scanner") + } + if len(plan.State.Fixes) != 1 || plan.State.Fixes[0].Attempts != 1 { + t.Errorf("state fixes = %+v, want one pending fix with 1 attempt", plan.State.Fixes) + } +} + +func TestRefreshReviewVerdicts_NilOrDisabledConfigIsNoOp(t *testing.T) { + dir := redirectReviewPaths(t) + refreshReviewVerdicts(nil, restoreTestLogger()) + cfg := &config.Config{} + refreshReviewVerdicts(cfg, restoreTestLogger()) + if _, err := os.Stat(filepath.Join(dir, review.ReviewVerdictsFile)); !os.IsNotExist(err) { + t.Fatalf("verdict artifact must not be written when review approval is off (stat err=%v)", err) + } +} + +func TestRefreshReviewVerdicts_MissingReportDirIsQuiet(t *testing.T) { + dir := redirectReviewPaths(t) + outputschema.AgentReportDir = filepath.Join(dir, "does-not-exist") + cfg := &config.Config{Review: config.ReviewConfig{RequireApproval: true}} + refreshReviewVerdicts(cfg, restoreTestLogger()) // must not panic or write + if _, err := os.Stat(review.ReviewVerdictsPath); !os.IsNotExist(err) { + t.Fatalf("no artifact expected when report dir is missing (stat err=%v)", err) + } +} + +func TestRefreshReviewVerdicts_CollectsReportsIntoArtifact(t *testing.T) { + dir := redirectReviewPaths(t) + report := review.PerspectiveReport{ + AgentReport: outputschema.AgentReport{ + Lane: "review-swarm", + Kind: outputschema.KindReview, + Findings: []outputschema.Finding{}, + PRsOpened: []outputschema.PROpened{}, + BeadsFiled: []outputschema.BeadFiled{}, + Summary: "security review summary", + }, + Perspective: review.PerspectiveSecurity, + Verdict: review.VerdictApprove, + Repo: "kubestellar/hive", + Number: 4321, + HeadSHA: reviewTestSHA, + } + raw, err := json.Marshal(report) + if err != nil { + t.Fatalf("marshal report: %v", err) + } + reportPath := filepath.Join(dir, review.ReviewReportFilePrefix+"security"+review.ReviewReportFileSuffix) + if err := os.WriteFile(reportPath, raw, 0o644); err != nil { + t.Fatalf("write report: %v", err) + } + + cfg := &config.Config{Review: config.ReviewConfig{RequireApproval: true}} + refreshReviewVerdicts(cfg, restoreTestLogger()) + + artifact, err := review.LoadArtifact("") + if err != nil { + t.Fatalf("load refreshed artifact: %v", err) + } + if len(artifact.Items) != 1 { + t.Fatalf("artifact aggregates = %d, want 1", len(artifact.Items)) + } + agg := artifact.Items[0] + if agg.Repo != "kubestellar/hive" || agg.Number != 4321 || agg.HeadSHA != reviewTestSHA { + t.Errorf("aggregate identity = %s#%d@%s, want kubestellar/hive#4321@%s", + agg.Repo, agg.Number, agg.HeadSHA, reviewTestSHA) + } + if agg.Perspectives[review.PerspectiveSecurity] != review.VerdictApprove { + t.Errorf("security perspective verdict = %q, want approve", agg.Perspectives[review.PerspectiveSecurity]) + } +} + +func TestPersistReviewDispatchState_EmptyPlanWritesNothing(t *testing.T) { + redirectReviewPaths(t) + persistReviewDispatchState(review.DispatchPlan{}, nil, restoreTestLogger()) + if _, err := os.Stat(review.ReviewDispatchStatePath); !os.IsNotExist(err) { + t.Fatalf("empty plan must not persist state (stat err=%v)", err) + } +} + +func TestPersistReviewDispatchState_KeepsOnlyDeliveredKicks(t *testing.T) { + redirectReviewPaths(t) + now := time.Now().UTC() + delivered := review.DispatchKick{ + Kind: "review", Agent: "reviewer", Repo: "kubestellar/hive", + Number: 4321, HeadSHA: reviewTestSHA, Perspective: review.PerspectiveSecurity, + } + dropped := review.DispatchKick{ + Kind: "review", Agent: "reviewer", Repo: "kubestellar/hive", + Number: 4321, HeadSHA: reviewTestSHA, Perspective: review.PerspectiveStyle, + } + plan := review.DispatchPlan{ + ReviewKicks: []review.DispatchKick{delivered, dropped}, + State: review.DispatchState{ + GeneratedAt: now, + Pending: []review.PendingReview{ + {Repo: delivered.Repo, Number: delivered.Number, HeadSHA: delivered.HeadSHA, Perspective: delivered.Perspective, Agent: delivered.Agent, Dispatched: now}, + {Repo: dropped.Repo, Number: dropped.Number, HeadSHA: dropped.HeadSHA, Perspective: dropped.Perspective, Agent: dropped.Agent, Dispatched: now}, + }, + }, + } + + persistReviewDispatchState(plan, []review.DispatchKick{delivered}, restoreTestLogger()) + + state, err := review.LoadDispatchState("") + if err != nil { + t.Fatalf("load persisted state: %v", err) + } + if len(state.Pending) != 1 { + t.Fatalf("persisted pending = %d, want only the delivered kick", len(state.Pending)) + } + if state.Pending[0].Perspective != review.PerspectiveSecurity { + t.Errorf("persisted perspective = %q, want %q", state.Pending[0].Perspective, review.PerspectiveSecurity) + } +} + +func TestPersistReviewDispatchState_WriteFailureIsNonFatal(t *testing.T) { + dir := redirectReviewPaths(t) + // Point the state file inside a plain file so MkdirAll/rename must fail. + blocker := filepath.Join(dir, "blocker") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatalf("write blocker: %v", err) + } + review.ReviewDispatchStatePath = filepath.Join(blocker, "state.json") + + plan := review.DispatchPlan{State: review.DispatchState{GeneratedAt: time.Now().UTC()}} + persistReviewDispatchState(plan, nil, restoreTestLogger()) // must not panic +} diff --git a/src/cmd/hive/rotation_check_test.go b/src/cmd/hive/rotation_check_test.go new file mode 100644 index 000000000..ac50299f6 --- /dev/null +++ b/src/cmd/hive/rotation_check_test.go @@ -0,0 +1,333 @@ +package main + +import ( + "context" + "log/slog" + "testing" + + "github.com/kubestellar/hive/pkg/agent" + "github.com/kubestellar/hive/pkg/config" + "github.com/kubestellar/hive/pkg/governor" + "github.com/kubestellar/hive/pkg/rotation" +) + +// Tests for runRotationCheck (cmd/hive/main.go) — the RFC #3958 eval-loop +// wiring that moves idle agents off positively-exhausted providers, strands +// them loudly when nothing has headroom, and auto-resumes strands when their +// provider recovers. The rotation DECISIONS (nextBackend, headroom classes, +// high-volume guard) are covered in pkg/rotation; these tests cover the +// wiring: which agents are candidates, which pauses it may touch, and what it +// writes back into the agent manager. + +func rotationTestLogger() *slog.Logger { + return slog.New(slog.DiscardHandler) +} + +// rotationTestConfig builds a config with rotation enabled and two providers: +// anthropic (subscription, fronted by "claude") and openai (metered, fronted +// by "codex"). The single agent "quality" runs on claude. +func rotationTestConfig() *config.Config { + return &config.Config{ + Project: config.ProjectConfig{Org: "testorg", Repos: []string{"r"}}, + Agents: map[string]config.AgentConfig{ + "quality": {Backend: "claude", Enabled: true}, + }, + Governor: config.GovernorConfig{ + Rotation: config.RotationConfig{ + Enabled: true, + Providers: map[string]config.ProviderRotationConfig{ + "anthropic": {Class: rotation.ClassSubscription, Backends: []string{"claude"}}, + "openai": {Class: rotation.ClassMetered, Backends: []string{"codex"}}, + }, + }, + }, + } +} + +type rotationHarness struct { + cfg *config.Config + rotMgr *rotation.Manager + gov *governor.Governor + agentMgr *agent.Manager +} + +func newRotationHarness(t *testing.T) *rotationHarness { + t.Helper() + cfg := rotationTestConfig() + return &rotationHarness{ + cfg: cfg, + rotMgr: rotation.NewManager(cfg.Governor.Rotation), + gov: governor.New(config.GovernorConfig{}, cfg.Agents, rotationTestLogger()), + agentMgr: agent.NewManager(cfg.Agents, rotationTestLogger(), agent.ProjectContext{}), + } +} + +func (h *rotationHarness) run(t *testing.T) { + t.Helper() + runRotationCheck(context.Background(), h.cfg, h.rotMgr, h.gov, h.agentMgr, rotationTestLogger()) +} + +func (h *rotationHarness) status(t *testing.T, name string) *agent.AgentProcess { + t.Helper() + proc, ok := h.agentMgr.AllStatuses()[name] + if !ok { + t.Fatalf("agent %q not found in AllStatuses", name) + } + return proc +} + +// exhaust marks a provider positively measured as out of headroom. +func (h *rotationHarness) exhaust(provider string) { + h.rotMgr.SetHeadroom(rotation.Headroom{Provider: provider, Available: false, PctRemaining: 0}) +} + +// recover marks a provider positively measured as having headroom. +func (h *rotationHarness) recover(provider string, pct int) { + h.rotMgr.SetHeadroom(rotation.Headroom{Provider: provider, Available: true, PctRemaining: pct}) +} + +// A nil rotation manager must be a straight no-op: rotation is opt-in and the +// eval loop passes nil when it was never constructed. +func TestRunRotationCheck_NilManagerIsNoOp(t *testing.T) { + h := newRotationHarness(t) + runRotationCheck(context.Background(), h.cfg, nil, h.gov, h.agentMgr, rotationTestLogger()) + + proc := h.status(t, "quality") + if proc.Paused || proc.BackendOverride != "" { + t.Errorf("nil rotMgr mutated agent state: paused=%v override=%q", proc.Paused, proc.BackendOverride) + } +} + +// Rotation disabled in config must be a no-op even when the manager exists +// and the provider is positively exhausted. +func TestRunRotationCheck_DisabledConfigIsNoOp(t *testing.T) { + h := newRotationHarness(t) + h.cfg.Governor.Rotation.Enabled = false + h.exhaust("anthropic") + h.recover("openai", 90) + + h.run(t) + + proc := h.status(t, "quality") + if proc.Paused || proc.BackendOverride != "" { + t.Errorf("disabled rotation mutated agent state: paused=%v override=%q", proc.Paused, proc.BackendOverride) + } +} + +// Healthy current provider: nothing moves, nothing pauses. +func TestRunRotationCheck_HealthyProviderUntouched(t *testing.T) { + h := newRotationHarness(t) + h.recover("anthropic", 60) + h.recover("openai", 90) + + h.run(t) + + proc := h.status(t, "quality") + if proc.Paused { + t.Error("agent on healthy provider was paused") + } + if proc.BackendOverride != "" { + t.Errorf("agent on healthy provider got BackendOverride %q", proc.BackendOverride) + } +} + +// Exhausted provider with a measured-available alternative: the agent gets a +// backend override onto the alternative and is NOT paused. +func TestRunRotationCheck_ExhaustedRotatesToHeadroom(t *testing.T) { + h := newRotationHarness(t) + h.exhaust("anthropic") + h.recover("openai", 80) + + h.run(t) + + proc := h.status(t, "quality") + if proc.BackendOverride != "codex" { + t.Errorf("BackendOverride = %q, want %q", proc.BackendOverride, "codex") + } + if proc.Paused { + t.Error("rotated agent must not be paused") + } +} + +// Exhausted provider and NO alternative with measured headroom: strand — the +// agent is paused with the rotation trigger so auto-resume can find it later. +func TestRunRotationCheck_NoHeadroomAnywhereStrands(t *testing.T) { + h := newRotationHarness(t) + h.exhaust("anthropic") + h.exhaust("openai") + + h.run(t) + + proc := h.status(t, "quality") + if !proc.Paused { + t.Fatal("agent with no headroom anywhere was not strand-paused") + } + if proc.PausedTrigger != rotationTrigger { + t.Errorf("PausedTrigger = %q, want %q (auto-resume keys off this)", proc.PausedTrigger, rotationTrigger) + } + if proc.BackendOverride != "" { + t.Errorf("stranded agent got BackendOverride %q, want none", proc.BackendOverride) + } +} + +// A probe error is fail-open: it must never be treated as exhaustion, so the +// agent is neither rotated nor stranded. +func TestRunRotationCheck_ProbeErrorNeverRotates(t *testing.T) { + h := newRotationHarness(t) + h.rotMgr.SetHeadroom(rotation.Headroom{Provider: "anthropic", Available: false, ProbeErr: context.DeadlineExceeded}) + h.recover("openai", 90) + + h.run(t) + + proc := h.status(t, "quality") + if proc.Paused || proc.BackendOverride != "" { + t.Errorf("failed probe treated as exhaustion: paused=%v override=%q", proc.Paused, proc.BackendOverride) + } +} + +// An operator pause (any non-rotation trigger) is sacrosanct: even with the +// provider exhausted and an alternative available, runRotationCheck must not +// touch the agent — no resume, no override, trigger unchanged. +func TestRunRotationCheck_OperatorPauseUntouched(t *testing.T) { + h := newRotationHarness(t) + if err := h.agentMgr.Pause("quality", "dashboard-api", "operator quiesce"); err != nil { + t.Fatalf("Pause: %v", err) + } + h.exhaust("anthropic") + h.recover("openai", 90) + + h.run(t) + + proc := h.status(t, "quality") + if !proc.Paused { + t.Fatal("operator-paused agent was resumed by rotation") + } + if proc.PausedTrigger != "dashboard-api" { + t.Errorf("PausedTrigger = %q, want %q (operator pause overwritten)", proc.PausedTrigger, "dashboard-api") + } + if proc.BackendOverride != "" { + t.Errorf("operator-paused agent got BackendOverride %q", proc.BackendOverride) + } +} + +// A rotation-stranded agent whose provider is still exhausted stays paused: +// StrandRecovered is false, and the strand pause must not be re-applied or +// escalated into a rotation. +func TestRunRotationCheck_StrandNotRecoveredStaysPaused(t *testing.T) { + h := newRotationHarness(t) + if err := h.agentMgr.Pause("quality", rotationTrigger, "no provider has headroom (RFC #3958)"); err != nil { + t.Fatalf("Pause: %v", err) + } + h.exhaust("anthropic") + + h.run(t) + + proc := h.status(t, "quality") + if !proc.Paused { + t.Fatal("stranded agent resumed while provider still exhausted") + } + if proc.PausedTrigger != rotationTrigger { + t.Errorf("PausedTrigger = %q, want %q", proc.PausedTrigger, rotationTrigger) + } + if proc.BackendOverride != "" { + t.Errorf("stranded agent got BackendOverride %q while unrecovered", proc.BackendOverride) + } +} + +// A rotation-stranded agent whose provider recovered headroom is auto-resumed. +// The agent is sandbox-enabled so Resume takes the no-tmux path — the wiring +// under test is the StrandRecovered → Resume decision, not the relaunch. +func TestRunRotationCheck_StrandRecoveredAutoResumes(t *testing.T) { + h := newRotationHarness(t) + enabled := true + agents := map[string]config.AgentConfig{ + "quality": { + Backend: "claude", + Enabled: true, + Sandbox: &config.AgentSandboxOverride{Enabled: &enabled}, + }, + } + h.cfg.Agents = agents + h.agentMgr = agent.NewManager(agents, rotationTestLogger(), agent.ProjectContext{}) + h.agentMgr.SetSandboxConfig(config.AgentSandboxConfig{Enabled: true}) + + if err := h.agentMgr.Pause("quality", rotationTrigger, "no provider has headroom (RFC #3958)"); err != nil { + t.Fatalf("Pause: %v", err) + } + h.recover("anthropic", 70) + + h.run(t) + + proc := h.status(t, "quality") + if proc.Paused { + t.Fatal("stranded agent not auto-resumed after provider recovery") + } + if proc.PausedTrigger != "" { + t.Errorf("PausedTrigger = %q after resume, want empty", proc.PausedTrigger) + } +} + +// A BackendOverride from an earlier rotation is the agent's effective backend: +// exhaustion checks must key off the override, not the configured backend. +// Here the agent was already moved to codex; anthropic (its configured +// provider) being exhausted is irrelevant while openai has headroom. +func TestRunRotationCheck_ExistingOverrideIsEffectiveBackend(t *testing.T) { + h := newRotationHarness(t) + if err := h.agentMgr.SetBackendOverride("quality", "codex"); err != nil { + t.Fatalf("SetBackendOverride: %v", err) + } + h.exhaust("anthropic") + h.recover("openai", 90) + + h.run(t) + + proc := h.status(t, "quality") + if proc.Paused { + t.Error("agent on healthy override backend was paused") + } + if proc.BackendOverride != "codex" { + t.Errorf("BackendOverride = %q, want %q (must judge the override, not the config backend)", proc.BackendOverride, "codex") + } +} + +// Reverse direction of the override test: the override backend (codex/openai) +// is exhausted while the configured one (claude/anthropic) recovered — the +// agent must rotate back onto claude. +func TestRunRotationCheck_OverrideExhaustedRotatesBack(t *testing.T) { + h := newRotationHarness(t) + if err := h.agentMgr.SetBackendOverride("quality", "codex"); err != nil { + t.Fatalf("SetBackendOverride: %v", err) + } + h.exhaust("openai") + h.recover("anthropic", 55) + + h.run(t) + + proc := h.status(t, "quality") + if proc.BackendOverride != "claude" { + t.Errorf("BackendOverride = %q, want %q", proc.BackendOverride, "claude") + } + if proc.Paused { + t.Error("rotated agent must not be paused") + } +} + +// Backend not fronted by any configured provider: Exhausted() is false by +// construction, so the agent is untouched (fail-open for unknown backends). +func TestRunRotationCheck_UnknownBackendUntouched(t *testing.T) { + h := newRotationHarness(t) + agents := map[string]config.AgentConfig{ + "quality": {Backend: "copilot", Enabled: true}, + } + h.cfg.Agents = agents + h.agentMgr = agent.NewManager(agents, rotationTestLogger(), agent.ProjectContext{}) + h.exhaust("anthropic") + h.exhaust("openai") + + h.run(t) + + proc := h.status(t, "quality") + if proc.Paused || proc.BackendOverride != "" { + t.Errorf("agent on unmapped backend mutated: paused=%v override=%q", proc.Paused, proc.BackendOverride) + } +} diff --git a/src/cmd/hive/shutdown_hooks.go b/src/cmd/hive/shutdown_hooks.go new file mode 100644 index 000000000..d2a1c50f9 --- /dev/null +++ b/src/cmd/hive/shutdown_hooks.go @@ -0,0 +1,95 @@ +package main + +import "sync" + +// shutdownHooks is the ordered set of functions the signal handler runs before +// the root context is canceled — while every WebSocket, tmux server and PVC +// mount is still live. +// +// WHY A SLICE (kubestellar/hive#5390). This began as a single +// atomic.Pointer[func()]. One pointer means one hook, and registration is +// therefore DESTRUCTIVE: whoever calls Store second silently discards whatever +// was there first. Nothing errors, no test fails, a shutdown side effect just +// stops happening. #4296's kick-log archive already held the slot, so adding +// the contributor-socket drain to it would have quietly deleted the archive on +// every pod roll — invisible, and only discovered when someone went looking for +// scrollback that was no longer being written. Appending cannot do that. +// +// Hooks run SERIALLY in registration order, on the signal goroutine, and each +// is responsible for bounding its own work: this type imposes no timeout, +// because a shared budget here would silently truncate a later hook when an +// earlier one ran long. The whole sequence is racing +// terminationGracePeriodSeconds (30s), so a hook that can block against a +// remote peer must carry its own deadline — see wsDrainBudget. +// +// A panic in one hook must not cost the others theirs: the sequence is +// best-effort cleanup on a process that is exiting regardless, so a hook that +// blows up is contained and the rest still run. +type shutdownHooks struct { + mu sync.Mutex + hooks []namedShutdownHook +} + +type namedShutdownHook struct { + name string + fn func() +} + +// add registers a hook to run at shutdown. The name is used only for panic +// reporting. A nil fn is ignored so a caller need not guard an optional +// subsystem at the registration site. +func (s *shutdownHooks) add(name string, fn func()) { + if fn == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.hooks = append(s.hooks, namedShutdownHook{name: name, fn: fn}) +} + +// addUrgent registers a hook to run BEFORE every hook registered so far. +// +// Registration order in main() is dictated by construction order — a hook +// cannot be registered before the subsystem it touches exists — and that has +// nothing to do with what should run first at shutdown. The contributor drain +// is constructed late (the dashboard server comes ~350 lines after the agent +// manager) but is the time-critical hook: it puts a Close frame on the wire +// that a relay is waiting on, while the kick-log archive it would otherwise +// queue behind does PVC I/O on NFS with nobody waiting. This lets a late +// registration still take the front of the queue. +func (s *shutdownHooks) addUrgent(name string, fn func()) { + if fn == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.hooks = append([]namedShutdownHook{{name: name, fn: fn}}, s.hooks...) +} + +// run executes every registered hook in registration order. It is safe to call +// on a zero value and with no hooks registered. +func (s *shutdownHooks) run() { + s.mu.Lock() + hooks := make([]namedShutdownHook, len(s.hooks)) + copy(hooks, s.hooks) + s.mu.Unlock() + + for _, h := range hooks { + runShutdownHook(h) + } +} + +// runShutdownHook isolates one hook's panic so a later hook still runs. +func runShutdownHook(h namedShutdownHook) { + defer func() { + _ = recover() + }() + h.fn() +} + +// len reports how many hooks are registered. Test-facing. +func (s *shutdownHooks) count() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.hooks) +} diff --git a/src/cmd/hive/shutdown_hooks_test.go b/src/cmd/hive/shutdown_hooks_test.go new file mode 100644 index 000000000..e1d54b932 --- /dev/null +++ b/src/cmd/hive/shutdown_hooks_test.go @@ -0,0 +1,207 @@ +package main + +import ( + "os" + "regexp" + "strings" + "sync" + "testing" +) + +// TestShutdownHooksRunsBothArchiveAndDrain is the regression guard for the trap +// kubestellar/hive#5390 walked into. +// +// The pre-shutdown slot used to be a single atomic.Pointer[func()]. Storing a +// second hook SILENTLY discarded the first — no error, no failing test, a +// shutdown side effect simply stopped happening. Since #4296's kick-log archive +// already held that slot, adding the contributor drain to it would have deleted +// the archive on every pod roll, invisibly. +// +// This asserts the OBSERVABLE consequence: after registering both, running the +// sequence produces both effects. +func TestShutdownHooksRunsBothArchiveAndDrain(t *testing.T) { + var ( + mu sync.Mutex + archived bool + drained bool + ) + + var hooks shutdownHooks + hooks.add("archive-kick-logs", func() { + mu.Lock() + defer mu.Unlock() + archived = true + }) + hooks.addUrgent("drain-contributor-websockets", func() { + mu.Lock() + defer mu.Unlock() + drained = true + }) + + if got := hooks.count(); got != 2 { + t.Fatalf("registered hook count = %d, want 2 — a hook was overwritten "+ + "rather than appended", got) + } + + hooks.run() + + mu.Lock() + if !archived { + t.Error("ArchiveAllKickLogs equivalent did NOT run — registering the " + + "WebSocket drain destroyed the #4296 kick-log archive") + } + if !drained { + t.Error("contributor drain did NOT run") + } + archived, drained = false, false + mu.Unlock() + + // Same assertion with BOTH registered via add(), so the guard does not + // depend on addUrgent's prepend happening to survive a destructive add. + var plain shutdownHooks + plain.add("archive-kick-logs", func() { + mu.Lock() + defer mu.Unlock() + archived = true + }) + plain.add("drain-contributor-websockets", func() { + mu.Lock() + defer mu.Unlock() + drained = true + }) + if got := plain.count(); got != 2 { + t.Fatalf("add() is destructive: count = %d after two registrations, want 2", got) + } + plain.run() + + mu.Lock() + defer mu.Unlock() + if !archived || !drained { + t.Errorf("add()-registered hooks did not both run (archived=%v drained=%v) — "+ + "the second registration discarded the first", archived, drained) + } +} + +// TestShutdownHooksAddUrgentRunsFirst pins the ordering the drain depends on. +// The drain is registered later than the archive (the dashboard server is +// constructed after the agent manager) but must run first: it puts a close +// frame on a wire a relay is waiting on, while the archive does PVC I/O on NFS +// that nobody is waiting on. +func TestShutdownHooksAddUrgentRunsFirst(t *testing.T) { + var ( + mu sync.Mutex + order []string + ) + record := func(name string) func() { + return func() { + mu.Lock() + defer mu.Unlock() + order = append(order, name) + } + } + + var hooks shutdownHooks + hooks.add("archive", record("archive")) + hooks.add("other", record("other")) + hooks.addUrgent("drain", record("drain")) + + hooks.run() + + mu.Lock() + defer mu.Unlock() + want := []string{"drain", "archive", "other"} + if strings.Join(order, ",") != strings.Join(want, ",") { + t.Errorf("hook order = %v, want %v", order, want) + } +} + +// TestShutdownHooksPanicDoesNotSkipRemaining pins that one failing hook cannot +// cost the others theirs. A panic inside the drain must not prevent the +// kick-log archive from running, and vice versa. +func TestShutdownHooksPanicDoesNotSkipRemaining(t *testing.T) { + var ( + mu sync.Mutex + ranAll []string + ) + + var hooks shutdownHooks + hooks.add("boom", func() { panic("hook exploded") }) + hooks.add("archive", func() { + mu.Lock() + defer mu.Unlock() + ranAll = append(ranAll, "archive") + }) + + // Must not propagate the panic out of run(). + hooks.run() + + mu.Lock() + defer mu.Unlock() + if len(ranAll) != 1 || ranAll[0] != "archive" { + t.Errorf("hooks after a panicking hook did not run: %v", ranAll) + } +} + +// TestShutdownHooksZeroValueAndNilAreSafe covers the signal handler firing +// before anything registered — a SIGTERM during early startup. +func TestShutdownHooksZeroValueAndNilAreSafe(t *testing.T) { + var hooks shutdownHooks + hooks.run() // must not panic + hooks.add("nil-fn", nil) + hooks.addUrgent("nil-urgent", nil) + if got := hooks.count(); got != 0 { + t.Errorf("nil hooks were registered: count = %d, want 0", got) + } + hooks.run() +} + +// TestMainRegistersBothPreShutdownHooks is a STRUCTURAL guard on the wiring in +// main.go, which the unit tests above cannot reach: main() is a 2000-line +// function that stands up the whole process and cannot be invoked from a test. +// +// It pins three things that a future edit could silently undo: +// 1. both hooks are still registered, +// 2. the mechanism is still additive (no atomic.Pointer Store on the slot), +// 3. the drain still runs BEFORE cancel() in the signal handler, while the +// connections are still live. +// +// It is deliberately narrow — it does not attempt to verify runtime behaviour +// from source text. +func TestMainRegistersBothPreShutdownHooks(t *testing.T) { + src, err := os.ReadFile("main.go") + if err != nil { + t.Fatalf("read main.go: %v", err) + } + body := string(src) + + for _, want := range []string{ + `preShutdownHooks.add("archive-kick-logs"`, + `preShutdownHooks.addUrgent("drain-contributor-websockets"`, + `DrainContributorsForShutdown()`, + } { + if !strings.Contains(body, want) { + t.Errorf("main.go no longer contains %q — a pre-shutdown hook was dropped", want) + } + } + + // The destructive single-slot mechanism must not come back. + if regexp.MustCompile(`preShutdownHook\b.*\.Store\(`).MatchString(body) { + t.Error("main.go stores into a single-slot preShutdownHook again — that " + + "mechanism silently discards previously registered hooks (#5390)") + } + + // Ordering: hooks must run before cancel() in the signal goroutine. + runIdx := strings.Index(body, "preShutdownHooks.run()") + if runIdx < 0 { + t.Fatal("signal handler no longer runs the pre-shutdown hooks") + } + cancelIdx := strings.Index(body[runIdx:], "cancel()") + if cancelIdx < 0 { + t.Fatal("could not locate cancel() after the hook run in the signal handler") + } + // Guard against the two being reordered: anything between them must be short. + if between := body[runIdx : runIdx+cancelIdx]; strings.Count(between, "\n") > 3 { + t.Errorf("cancel() is no longer immediately after preShutdownHooks.run(); "+ + "the drain must run while connections are still live. Between them:\n%s", between) + } +} diff --git a/src/cmd/hive/turn_loss_persist_test.go b/src/cmd/hive/turn_loss_persist_test.go index 741e4e42c..21e1b1861 100644 --- a/src/cmd/hive/turn_loss_persist_test.go +++ b/src/cmd/hive/turn_loss_persist_test.go @@ -5,7 +5,6 @@ import ( "time" "github.com/kubestellar/hive/pkg/agent" - "github.com/kubestellar/hive/pkg/snapshot" ) // The turn-loss measurement (#4002 open question 3) is worthless unless it @@ -103,5 +102,4 @@ func TestPersistedTurnLossIsJSONLegible(t *testing.T) { if out.Recent[0].SinceKickS != 90 { t.Errorf("Recent[0].SinceKickS = %v, want 90", out.Recent[0].SinceKickS) } - var _ *snapshot.AgentTurnLoss = out } diff --git a/src/cmd/hive/write_intent_verdicts_test.go b/src/cmd/hive/write_intent_verdicts_test.go new file mode 100644 index 000000000..312c1b86d --- /dev/null +++ b/src/cmd/hive/write_intent_verdicts_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "testing" + + "github.com/kubestellar/hive/pkg/config" + "github.com/kubestellar/hive/pkg/github" +) + +// writeIntentVerdicts must return an empty (never nil) map, and must not +// touch the GitHub client or the logger, when either cfg or actionable is +// nil. This is the "no config yet" / "no enumeration yet" boot-time case. +func TestWriteIntentVerdictsNilGuards(t *testing.T) { + logger := restoreTestLogger() + + got := writeIntentVerdicts(context.Background(), nil, nil, + &github.ActionableResult{}, nil, logger) + if got == nil || len(got) != 0 { + t.Fatalf("nil cfg: got %#v, want empty non-nil map", got) + } + + got = writeIntentVerdicts(context.Background(), &config.Config{}, nil, nil, nil, logger) + if got == nil || len(got) != 0 { + t.Fatalf("nil actionable: got %#v, want empty non-nil map", got) + } +} + +// A PR authored by someone other than the configured AI author takes the +// non-agent classification path entirely: no GitHub client call is made (a +// nil client would panic if fetchIntentPREvidence were reached), and the +// resulting verdict is keyed by "org/repo#number" with Enforced mirroring +// cfg.Intent.Enforce. +func TestWriteIntentVerdictsNonAgentPRSkipsEvidenceFetch(t *testing.T) { + cfg := &config.Config{ + Project: config.ProjectConfig{Org: "acme", AIAuthor: "hive-bot"}, + Intent: config.IntentConfig{ + Enforce: true, + }, + } + + actionable := &github.ActionableResult{ + PRs: github.PRResult{ + Items: []github.PullRequest{ + {Repo: "widgets", Number: 7, Title: "Human PR", Author: "a-human"}, + }, + }, + } + + // A nil *github.Client would panic if the agent-PR evidence-fetch branch + // were reached, so a clean return here proves the non-agent path was + // taken. + verdicts := writeIntentVerdicts(context.Background(), cfg, nil, actionable, nil, restoreTestLogger()) + + v, ok := verdicts["acme/widgets/7"] + if !ok { + t.Fatalf("verdicts = %#v, want key acme/widgets/7", verdicts) + } + if v.AgentPR { + t.Errorf("non-agent PR classified as AgentPR") + } +} + +// An agent-authored PR (author matches EffectiveAIAuthor) DOES reach the +// evidence-fetch branch; with a nil GitHub client that fetch fails, and the +// resulting verdict must be Tier1/unauthorized with the fetch error recorded +// as the reason, not silently dropped. +func TestWriteIntentVerdictsAgentPREvidenceFetchFailureDeniesTier1(t *testing.T) { + cfg := &config.Config{ + Project: config.ProjectConfig{Org: "acme", AIAuthor: "hive-bot"}, + } + + actionable := &github.ActionableResult{ + PRs: github.PRResult{ + Items: []github.PullRequest{ + {Repo: "widgets", Number: 9, Title: "Agent PR", Author: "hive-bot"}, + }, + }, + } + + verdicts := writeIntentVerdicts(context.Background(), cfg, nil, actionable, nil, restoreTestLogger()) + + v, ok := verdicts["acme/widgets/9"] + if !ok { + t.Fatalf("verdicts = %#v, want key acme/widgets/9", verdicts) + } + if v.Authorized { + t.Error("agent PR with unfetchable evidence must not be authorized") + } + if v.Reason == "" { + t.Error("denied verdict must carry a non-empty reason") + } +} diff --git a/src/deploy/data/wiki/agents.md b/src/deploy/data/wiki/agents.md index e19bd2300..9aac4304f 100644 --- a/src/deploy/data/wiki/agents.md +++ b/src/deploy/data/wiki/agents.md @@ -14,6 +14,22 @@ repository queue depth. The first-responder agent. Scans open issues and PRs for actionable items, triages new issues, and handles quick fixes. Runs frequently in all modes. +## Quality + +Owns test suites, coverage gates, and regression checks. Typically the first +agent an operator grants write access to when moving to L3. + +## Guide + +Audits documentation, onboarding material, and contributor experience, +identifying gaps that make the project harder to pick up. + +## Brainstorm + +Idea incubation. **Advisory only** — it files beads, never GitHub issues or +pull requests, at every level. Commonly left paused until an operator wants +its output. + ## CI Maintainer Monitors CI pipelines, fixes flaky tests, updates workflows, and ensures @@ -42,16 +58,19 @@ communications. Activated during idle periods. Security-focused agent. Reviews code for vulnerabilities, checks dependencies, and audits access patterns. Runs frequently across all modes. -## Tester - -Writes and maintains test suites. Activated when coverage gaps or test -failures are detected. - ## Strategist Long-horizon planning agent. Analyzes trends, proposes roadmap items, and evaluates technical debt. Only activated in idle mode. +## A note on testing + +There is no built-in `tester` agent. Test suites, coverage gates, and +regression checks are the `quality` lane's work. An operator who wants a +separate tester must define it as a custom agent with its own metadata and +policy template — see +[acmm-policy-matrix.md](https://github.com/kubestellar/hive/blob/v4/src/docs/acmm-policy-matrix.md). + ## Adding a New Agent To add an agent, define it in `hive.yaml` under `agents:` with at minimum: diff --git a/src/deploy/data/wiki/getting-started.md b/src/deploy/data/wiki/getting-started.md index 04f1e1fe9..51c7001a1 100644 --- a/src/deploy/data/wiki/getting-started.md +++ b/src/deploy/data/wiki/getting-started.md @@ -9,6 +9,15 @@ Hive is an autonomous multi-agent system that manages software repositories using AI-powered agents. Each agent has a specific role and operates within governance boundaries set by the **Governor**. +This page is the entry point. It gets a new operator from zero to a running +hive, points contributors at the build/test path, and links the reference +material for everything after that. + +> **Note on links.** This vault is copied to `/data/wiki/` when the container +> starts, so it does not sit inside a checkout of the repository. Links out of +> the vault are therefore absolute URLs to the `v4` branch on GitHub rather +> than relative paths, which would not resolve at runtime. + ## Core Concepts - **Governor** -- evaluates repository state (open issues, PRs, SLA breaches) @@ -19,12 +28,128 @@ governance boundaries set by the **Governor**. agent was asked, what it did, and the outcome. - **Knowledge Layer** -- a wiki of facts (patterns, gotchas, regressions) extracted from merged PRs and fed back to agents as primer context. +- **ACMM Level** -- six levels (L1-L6) that decide what agents are permitted to + do, from advisory-only observations up to opening and auto-merging pull + requests. You raise the level as you build trust in the output; the goal is + trust, not level. + +## Run a Hive (Docker Compose) + +Docker Compose is the default standalone runtime. Podman is a parallel +supported choice — see the +[Podman quick start](https://github.com/kubestellar/hive/blob/v4/README.md#quick-start-podman). + +**Prerequisites** + +- Docker Engine 24+ with the Compose v2 plugin (`docker compose`, not the + legacy `docker-compose`) +- Linux, macOS, or Windows (WSL2) on `amd64` or `arm64` +- `git`, `openssl`, and a GitHub token (PAT or App) for the org the hive will + work on + +```bash +git clone https://github.com/kubestellar/hive.git +cd hive + +cp src/hive.yaml.example src/hive.yaml +``` + +Edit `src/hive.yaml` and set at least `project.org`, `project.repos`, and +`project.ai_author` for the org and repositories the hive should manage. + +Now write the environment file. **It must be `src/.env`, not a `.env` at the +repository root.** Because `-f src/docker-compose.yaml` makes `src/` the +project directory, Compose reads `.env` from there — the same place the compose +file's own `./hive.yaml` and `./secrets` mounts resolve against. A root `.env` +is read by nothing, and since both paths are gitignored, neither git nor +Compose warns you: the hive starts and then 401s on every GitHub call, which +looks like a bad token rather than an unread file. + +```bash +# Replace with your own token. Never commit this file. +echo "HIVE_GITHUB_TOKEN=ghp_REPLACE_ME" > src/.env + +# REQUIRED. The dashboard's auth proxy enforces this token and refuses to +# start without one, so the gateway on :3001 would proxy to a port nothing is +# listening on. +printf 'HIVE_DASHBOARD_TOKEN=%s\n' "$(openssl rand -hex 32)" >> src/.env + +docker compose -f src/docker-compose.yaml up -d +``` + +A classic PAT needs `repo` scope; see +[github-app-setup.md](https://github.com/kubestellar/hive/blob/v4/src/docs/github-app-setup.md#personal-access-token-pat-scopes) +for the App path and the full scope list. + +## Verify Your Hive Is Healthy -## Quick Links +The gateway publishes port 3001 whether or not the proxy behind it came up, so +confirm the endpoint answers rather than assuming the port is enough: -- Dashboard: accessible on the configured port (default 3001) -- Policies: stored in the `policies` repo path and hot-reloaded -- Vaults: Obsidian-compatible markdown directories auto-indexed by Hive +```bash +curl -sf http://127.0.0.1:3001/api/health # -> {"status":"ok"} +``` + +If that returns nothing, check the containers and their logs — the two +services are named `hive` and `hive-gateway`: + +```bash +docker compose -f src/docker-compose.yaml ps +docker logs hive +docker logs hive-gateway +``` + +A gateway that is up while `hive` is unhealthy is almost always the missing +`HIVE_DASHBOARD_TOKEN` or a `.env` written to the wrong directory. For anything +else, see +[troubleshooting.md](https://github.com/kubestellar/hive/blob/v4/src/docs/troubleshooting.md). + +The dashboard is then at `http://localhost:3001`. + +## The Agents + +A hive deploys a roster of specialized agents, each with a distinct role: +`scanner`, `quality`, `guide`, `brainstorm`, `ci-maintainer`, `architect`, +`supervisor`, `outreach`, `sec-check`, and `strategist`. How many are active +depends on your ACMM level, and what each one is permitted to do depends on its +policy mode at that level. + +See [agents.md](agents.md) in this vault for what each agent does and how to +add a custom one. + +## For Contributors + +If you are here to change Hive itself rather than run it: + +- [Getting started as a first-time contributor](https://github.com/kubestellar/hive/blob/v4/docs/getting-started-contributing.md) + -- the end-to-end path for a first PR. +- [CONTRIBUTING.md](https://github.com/kubestellar/hive/blob/v4/CONTRIBUTING.md) + -- branches, DCO sign-off, and PR format. +- [Local development](https://github.com/kubestellar/hive/blob/v4/docs/development.md) + -- Go version, `go build ./...`, `go test ./...`, and the helper recipes. + +Use `v4` as the PR base for ordinary Hive development. Most changes need no +cluster: `cd src && go build ./...` is enough to iterate on Go code, and +docs-only fixes need no toolchain at all. + +## Key References + +- [Reference architecture](https://github.com/kubestellar/hive/blob/v4/src/docs/architecture.md) + -- how the governor, agents, and dashboard fit together. +- [ACMM policy matrix](https://github.com/kubestellar/hive/blob/v4/src/docs/acmm-policy-matrix.md) + -- the full per-level, per-agent permission table. +- [Zero to automation](https://github.com/kubestellar/hive/blob/v4/src/docs/getting-started.md) + -- the narrative guide to climbing the ACMM levels. +- [Agent configuration](https://github.com/kubestellar/hive/blob/v4/src/docs/agent-configuration.md) + -- every field of an `agents:` entry in `hive.yaml`. +- [Environment variables](https://github.com/kubestellar/hive/blob/v4/src/docs/env-vars.md) + -- the compiled reference for `HIVE_*` and related variables. +- [Operator reference](https://github.com/kubestellar/hive/blob/v4/src/docs/operator-reference.md) + -- runtime knobs, image provenance, and tags. +- [hivectl](https://github.com/kubestellar/hive/blob/v4/src/docs/hivectl.md) + -- the non-interactive CLI client (`hivectl system health`, `system status`). +- [Documentation index](https://github.com/kubestellar/hive/blob/v4/src/docs/README.md) + -- everything else: operations, snapshots, contributor relay, design notes. ## Editing This Wiki @@ -34,3 +159,7 @@ This vault is an Obsidian-compatible directory of markdown files. You can: 2. Open the vault in Obsidian and enable the **Obsidian Git** community plugin 3. Push changes to the configured git remote -- Hive will pull them automatically every 60 seconds + +The files shipped in the image are starter knowledge, not fixed product +documentation. Replace or extend them with the runbooks, gotchas, and policies +that your agents should be primed with. diff --git a/src/deploy/entrypoint.sh b/src/deploy/entrypoint.sh index c14af548d..124960e38 100644 --- a/src/deploy/entrypoint.sh +++ b/src/deploy/entrypoint.sh @@ -61,6 +61,201 @@ HIVE_CONFIG_PATH="${HIVE_CONFIG:-/etc/hive/hive.yaml}" HIVE_CONFIG_RUNTIME="/data/hive.yaml.runtime" HIVE_CONFIG_RUNTIME_LEGACY="/data/hive.yaml.bak" +# The uid/gid the hive process actually runs as after the privilege drop +# further down (setpriv/gosu to dev). 0600 is an OWNER-only mode, so every +# hardened config copy has to be owned by THIS user or the owner of the file +# is not the reader of the file — see hive_harden_runtime_config. +HIVE_RUNTIME_USER="dev" +HIVE_RUNTIME_GROUP="node" + +# hive_harden_runtime_config makes $1 readable by the user that reads it, and +# by nobody else: chown to dev:node, then chmod 0600. +# +# BOTH halves are load-bearing, and #5360 is what happens with only one. +# +# The mode half (#5342/#5331): every `cp` below that writes a PVC config copy +# creates the destination anew, and cp gives a newly created destination the +# SOURCE's mode — the ConfigMap seed and the bind-mounted hive.yaml are both +# 0644 — so the copy is re-widened to 0644 on every boot. Without the chmod +# the 0600 fix only holds from the first Config.Save() onward, and a hive that +# boots and never saves stays world-readable indefinitely. These files carry +# dashboard.auth_token (and github.token in PAT mode) and /data is +# world-traversable, so that is the dashboard owner credential readable by +# every unprivileged agent uid. +# +# The ownership half (#5360): those same `cp` calls run in the ROOT phase, so +# the destination is created root:root. chmod 600 on a root:root file grants +# access to root ALONE — and the hive process drops to dev (uid 1001) before +# it ever opens the config, so it reads back `permission denied` and startup +# aborts. The `chown -R dev:node /data` in the root-only block does NOT cover +# this: it is guarded by `[ "$DATA_OWNER" != "1001" ]` and src/Dockerfile +# already ships /data owned by dev:node, so on a fresh anonymous volume the +# guard is false and the recursive chown never runs at all. It is also far +# BELOW these call sites, so even when it does run it cannot help a file the +# config branch has not created yet. +# +# Do the chown FIRST and the chmod second. The reverse order leaves a window +# in which the file is 0644 and root-owned; chown does not clear the mode, so +# chown-then-chmod is never wider than 0600 for longer than the chown itself. +# +# Best-effort throughout: a read-only or foreign-owned PVC, or a container +# without CAP_CHOWN, must not abort boot. When the chown cannot be performed +# the chmod is deliberately skipped rather than applied to a file we do not +# own — locking a root-owned file to 0600 is precisely the #5360 failure, and +# a readable-but-wider file that boots beats a hardened one that cannot. +hive_harden_runtime_config() { + [ -f "$1" ] || return 0 + # Already owned by the runtime user (the steady state after Config.Save(), + # and every non-root boot) — just tighten the mode. + if [ "$(stat -c '%u' "$1" 2>/dev/null || echo)" = "1001" ]; then + chmod 600 "$1" 2>/dev/null || true + return 0 + fi + if chown "$HIVE_RUNTIME_USER:$HIVE_RUNTIME_GROUP" "$1" 2>/dev/null; then + chmod 600 "$1" 2>/dev/null || true + else + echo "[entrypoint] WARN: cannot chown $1 to $HIVE_RUNTIME_USER:$HIVE_RUNTIME_GROUP — leaving its mode alone so the runtime user can still read it (0600 on a foreign-owned file is #5360). Is CAP_CHOWN in the pod's capabilities.add?" + fi +} + +# ── /data ownership as an INVARIANT, not a boot-time snapshot (#5369) ── +# +# HIVE_DATA_ROOT_PHASE_PATHS is the closed list of paths the root phase of this +# script creates under /data. It exists because the recursive +# `chown -R dev:node /data` further down is guarded on `[ "$DATA_OWNER" != 1001 ]` +# and src/Dockerfile already ships /data owned by dev:node — so on a normal boot +# the guard is FALSE and that chown never runs. Everything the root phase then +# creates keeps root:root, and the hive process (uid 1001, after the setpriv/gosu +# drop) cannot read it. #5360 was one instance of that; this list closes the class. +# +# The guard is NOT the bug and must stay. A recursive chown over an NFS-backed +# PVC with thousands of files costs minutes of startup. This list is the targeted +# alternative: a fixed set of shallow paths, chowned by NAME, so the cost is +# O(number of entries here) rather than O(size of the PVC) — and the invariant is +# restored without reintroducing the walk. +# +# MAINTENANCE RULE: if you add a root-phase write under /data, add its path here. +# hive_assert_runtime_readable below fails the boot LOUDLY, naming the path, when +# something in this list is unreadable to the runtime uid — so a forgotten entry +# surfaces as a named error at startup instead of as a silent EACCES later. +# +# Deliberately NOT in this list: +# /data/agents/*, /data/beads/* — chowned to per-agent hive- UIDs by the +# per-agent loop, not to dev. Sweeping them to dev would undo that isolation. +# /data/secrets/bob_api_key — mode 440, chowned at its own site, and read +# by agent UIDs rather than by dev. +# /data/.hive/proxy-ca-key.pem — owner-only by design, chowned at its site. +HIVE_DATA_ROOT_PHASE_PATHS=" +/data/.hive +/data/secrets +/data/config +/data/config/github-copilot +/data/home +/data/home/.config +/data/home/.bashrc +/data/home/.profile +" + +# hive_sweep_root_phase_paths hands every existing entry of that list to the +# runtime user. Ownership ONLY — it never touches modes, because the modes are +# already deliberate at each site (2775 on /data/home, 710 on /data/secrets, +# 700 on /data/.hive) and re-deriving them here would be a second source of +# truth that silently drifts from the first. +# +# Non-recursive on purpose: `chown` without -R on a directory is a single +# syscall regardless of how many files are under it, which is what keeps the +# NFS cost bounded. Directory ENTRIES created by the root phase are named +# individually above; entries created later by dev or by an agent are already +# owned by their creator and are not ours to reassign. +# +# Fails OPEN, per #5368: a chown that cannot happen (no CAP_CHOWN, read-only or +# foreign-owned PVC) WARNs and continues. Nothing here is a security control — +# the modes set at each site are — so a failed chown must never abort a boot. +hive_sweep_root_phase_paths() { + _sweep_failed="" + for _p in $HIVE_DATA_ROOT_PHASE_PATHS; do + [ -e "$_p" ] || continue + # Already owned by the runtime user — the steady state on almost every + # boot. Skip the syscall entirely so the common path costs one stat. + [ "$(stat -c '%u' "$_p" 2>/dev/null || echo)" = "1001" ] && continue + chown "$HIVE_RUNTIME_USER:$HIVE_RUNTIME_GROUP" "$_p" 2>/dev/null \ + || _sweep_failed="$_sweep_failed $_p" + done + if [ -n "$_sweep_failed" ]; then + echo "[entrypoint] WARN: could not chown to $HIVE_RUNTIME_USER:$HIVE_RUNTIME_GROUP:$_sweep_failed — is CAP_CHOWN in the pod's capabilities.add? Continuing; the runtime user may hit EACCES on these paths." + fi + unset _p _sweep_failed +} + +# hive_assert_runtime_readable is the #5369 assertion: immediately BEFORE the +# privilege drop, prove that the paths the hive process must read are actually +# readable by the uid it is about to become — and if not, say WHICH path. +# +# This is option 3 from the issue, and it is worth having no matter how good the +# sweep above is. #5360 took four merges to diagnose because the symptom was a +# bare `permission denied` from the Go binary with no indication of which file or +# why. The same fault caught here prints the path, its owner and its mode, before +# the process that would fail on it has even started. +# +# Cheap by construction: it stats a short fixed list and, where a real read is +# possible, does ONE open() per path as the runtime user. No directory walk. +# +# Non-fatal by design. A hive that boots degraded and tells you which file is +# wrong beats one that refuses to boot on a check that may itself be wrong — and +# on hosts without a `dev` account or without root there is no way to perform the +# authoritative test at all. The Go binary still enforces what it genuinely needs; +# this exists to NAME the fault first. The one true hard failure (an unreadable +# config) already exits from the config block above. +hive_assert_runtime_readable() { + _assert_bad="" + for _p in "$@"; do + [ -e "$_p" ] || continue + _owner="$(stat -c '%u' "$_p" 2>/dev/null || echo '')" + # Owned by the runtime uid — readable by definition, no probe needed. + [ "$_owner" = "1001" ] && continue + # No usable stat (BSD stat has no -c; a stat-less image is conceivable). + # We cannot evaluate this path, and a check that cannot evaluate must stay + # SILENT rather than report a fault it did not observe. A warning that + # fires on every path on every boot trains the operator to ignore the one + # that is real — which is how #5360's actual signal got lost. + [ -n "$_owner" ] || continue + # Not owned by it. Try the real syscall as that user when we can, since + # a foreign-owned file may still be perfectly readable via group or other + # bits and flagging it on ownership alone would be a false alarm. + if [ "$(id -u)" = "0" ] && command -v gosu >/dev/null 2>&1 \ + && id -u "$HIVE_RUNTIME_USER" >/dev/null 2>&1; then + if [ -d "$_p" ]; then + gosu "$HIVE_RUNTIME_USER" test -r "$_p" -a -x "$_p" 2>/dev/null && continue + else + gosu "$HIVE_RUNTIME_USER" test -r "$_p" 2>/dev/null && continue + fi + else + # Cannot perform the authoritative test. Fall back to the mode bits: if + # the "other" class can read it, the runtime user can too. This is weaker + # than the open() above and is only used where the open() is impossible. + case "$(stat -c '%a' "$_p" 2>/dev/null || echo 000)" in + *[4567]) continue ;; + esac + fi + _assert_bad="$_assert_bad + $_p (owner uid=$_owner, mode=$(stat -c '%a' "$_p" 2>/dev/null || echo '?'))" + done + if [ -n "$_assert_bad" ]; then + echo "[entrypoint] WARN (#5369): these paths are NOT readable by the runtime user '$HIVE_RUNTIME_USER' (uid 1001) that this process is about to become:$_assert_bad" + echo "[entrypoint] WARN (#5369): they were created by the root phase and never handed over. Anything that reads them after the privilege drop will fail with EACCES. If a path above is a root-phase write, add it to HIVE_DATA_ROOT_PHASE_PATHS in this script." + fi + unset _p _owner _assert_bad +} + +# Tighten pre-existing PVC config copies at boot. Files written before the +# 0600 fix (#5331) are world-readable. Routed through the helper above so +# these get the same chown-then-chmod treatment as the ones the `cp` calls +# recreate — a pre-existing root-owned copy is just as unreadable to dev. +for _cfg in "$HIVE_CONFIG_RUNTIME" "$HIVE_CONFIG_RUNTIME_LEGACY" /data/hive.yaml.dashboard; do + hive_harden_runtime_config "$_cfg" +done +unset _cfg + # hive_runtime_config_read echoes the path to read the persisted runtime # config from: the new name when it is present and non-empty, else the # legacy name when that is, else empty. Read-only — it never creates, @@ -73,6 +268,108 @@ hive_runtime_config_read() { fi } +# hive_ghe_git_host echoes this hive's configured GitHub host when it is NOT +# public github.com (i.e. a GitHub Enterprise instance such as github.ibm.com), +# and nothing at all otherwise. Derived the same way the Go binary's +# GitHubConfig.HostLabel() derives it (pkg/config/config.go): prefer +# github.base_url, fall back to the host portion of github.api_url, strip the +# scheme and a trailing /api/v3. +# +# Defined here, at the top, because BOTH boot phases need it: the root phase +# writes /etc/gitconfig (which every agent UID reads) and the dev phase writes +# ~dev/.gitconfig. Deriving it once keeps the two from drifting apart, which is +# exactly the failure mode #5343 was. +hive_ghe_git_host() { + _hgh_cfg="${HIVE_CONFIG:-/etc/hive/hive.yaml}" + [ -f "$_hgh_cfg" ] || return 0 + python3 -c " +import sys, yaml +try: + with open(sys.argv[1]) as f: + cfg = yaml.safe_load(f) or {} +except Exception: + sys.exit(0) +gh = cfg.get('github') or {} +pick = (gh.get('base_url') or gh.get('api_url') or '').strip() +if pick.startswith('https://'): + pick = pick[len('https://'):] +elif pick.startswith('http://'): + pick = pick[len('http://'):] +pick = pick.rstrip('/') +if pick.endswith('/api/v3'): + pick = pick[: -len('/api/v3')] +host = pick.split('/', 1)[0] +if host and host.lower() != 'api.github.com' and host.lower() != 'github.com': + print(host) +" "$_hgh_cfg" 2>/dev/null || true +} + +# hive_write_system_gitconfig writes /etc/gitconfig — the SYSTEM-level git +# config, read by EVERY UID regardless of $HOME. +# +# WHY THIS EXISTS (#5343). The credential helper used to be installed with +# `git config --global`, which is per-$HOME. The entrypoint runs as dev +# (ENV HOME=/home/dev), so it landed in /home/dev/.gitconfig — while agents run +# under per-agent UIDs with HOME=/data/home/agents/. Measured on a hosted +# GHE spoke: `su -s /bin/sh hive-quality -c 'git config --get-regexp credential'` +# returned NOTHING for both --global and --system, because there was no +# /etc/gitconfig either. Agents committed branches they could never push, and +# the failure surfaced only as a line inside an otherwise-healthy session. +# +# /etc/gitconfig is the right home for it: the helper is already a single +# system-wide binary at /usr/local/bin/git-credential-hive.sh, and a system file +# sidesteps the per-UID ownership question that a shared /data/home/.gitconfig +# would introduce (multiple agent UIDs, one directory). +# +# NO SECRET LIVES HERE. This file names a helper PATH and a bot identity. The +# token is minted by the helper, per agent, from the per-agent scoped cache. So +# 0644 (world-readable) is correct and required — every agent UID must read it. +# +# PRECEDENCE is safe: git reads system < global < local. /home/dev/.gitconfig +# still exists for the dev user and for the contributor-relay/local-mode paths, +# and it sets the SAME helper for the SAME hosts, so it shadows nothing. Agents +# have no global config at all, so for them the system file is the only layer. +# +# HIVE_SYSTEM_GITCONFIG is a TEST SEAM (same convention as sharedAgentHome in +# pkg/agent). It is never set in production; the regression test points it at a +# temp file so it can exercise the real writer without touching /etc. +hive_write_system_gitconfig() { + _hwsg_path="${HIVE_SYSTEM_GITCONFIG:-/etc/gitconfig}" + _hwsg_host="$(hive_ghe_git_host)" + + # Refuse a planted symlink: /etc/gitconfig is read by every UID including + # root, so it must never be redirected somewhere agent-writable. + if [ -L "$_hwsg_path" ]; then + rm -f -- "$_hwsg_path" 2>/dev/null || true + fi + + { + echo "# Managed by the hive entrypoint (kubestellar/hive#5343). Regenerated on every boot." + echo "# System-level so EVERY agent UID reads it regardless of \$HOME. Contains no secret:" + echo "# it names a helper path; the helper mints the per-agent scoped token." + echo "[user]" + echo " name = kubestellar-hive" + echo " email = hive-bot@kubestellar.io" + echo "[credential]" + echo " helper = " + echo '[credential "https://github.com"]' + echo " helper = /usr/local/bin/git-credential-hive.sh" + if [ -n "$_hwsg_host" ]; then + echo "[credential \"https://${_hwsg_host}\"]" + echo " helper = /usr/local/bin/git-credential-hive.sh" + fi + } > "$_hwsg_path" 2>/dev/null || { + echo "[entrypoint] WARN: could not write $_hwsg_path — agents may be unable to push (see kubestellar/hive#5343)" + return 0 + } + chmod 0644 "$_hwsg_path" 2>/dev/null || true + if [ -n "$_hwsg_host" ]; then + echo "[entrypoint] git credential helper wired system-wide in $_hwsg_path (github.com + GHE host ${_hwsg_host}) — readable by every agent UID" + else + echo "[entrypoint] git credential helper wired system-wide in $_hwsg_path (github.com) — readable by every agent UID" + fi +} + # Detect Kubernetes vs Docker environment IS_KUBERNETES=false if [ -n "${KUBERNETES_SERVICE_HOST:-}" ] || [ -f /var/run/secrets/kubernetes.io/serviceaccount/token ]; then @@ -255,6 +552,7 @@ PYEOF # Write the (merged) config as the disaster-recovery snapshot. Always # under the new name; the legacy file is left untouched on the PVC. cp "$HIVE_CONFIG_PATH" "$HIVE_CONFIG_RUNTIME" 2>/dev/null || true + hive_harden_runtime_config "$HIVE_CONFIG_RUNTIME" echo "[entrypoint] K8s mode — ConfigMap is the seed, runtime config written to $HIVE_CONFIG_RUNTIME" else # Neither source exists: no runtime config on the PVC (checked first, @@ -278,6 +576,7 @@ else if [ -f "$HIVE_CONFIG_PATH" ] && [ -s "$HIVE_CONFIG_PATH" ] && [ -z "$HIVE_CONFIG_SOURCE" ]; then # First boot: config exists but no PVC runtime config yet — seed it cp "$HIVE_CONFIG_PATH" "$HIVE_CONFIG_RUNTIME" + hive_harden_runtime_config "$HIVE_CONFIG_RUNTIME" echo "[entrypoint] First boot — config seeded to PVC: $HIVE_CONFIG_RUNTIME" elif [ -f "$HIVE_CONFIG_PATH" ] && [ -s "$HIVE_CONFIG_PATH" ] && [ -n "$HIVE_CONFIG_SOURCE" ]; then # The PVC runtime config is the source of truth (updated by Save()). @@ -294,6 +593,7 @@ else # as the untouched fallback until Save() takes over writing the new one. if [ "$HIVE_CONFIG_SOURCE" = "$HIVE_CONFIG_RUNTIME_LEGACY" ]; then if cp "$HIVE_CONFIG_RUNTIME_LEGACY" "$HIVE_CONFIG_RUNTIME" 2>/dev/null; then + hive_harden_runtime_config "$HIVE_CONFIG_RUNTIME" echo "[entrypoint] Migration — seeded $HIVE_CONFIG_RUNTIME from legacy $HIVE_CONFIG_RUNTIME_LEGACY (legacy left in place)" fi fi @@ -661,13 +961,36 @@ fi case ":$PATH:" in *:/usr/local/go/bin:*) ;; *) export PATH="$PATH:/usr/local/go/bin" ;; esac BASHRC chmod 644 /data/home/.bashrc 2>/dev/null || true + # Written by root via `cat >` above, so it is created root:root. Hand it to + # dev at the point of creation (#5369): the recursive /data chown is guarded + # off on every normal boot and cannot be relied on to fix it later. + chown dev:node /data/home/.bashrc 2>/dev/null || true # Login shells (tmux default-command) read ~/.profile, not ~/.bashrc — chain # them so both shell flavors get the same environment. if [ ! -f /data/home/.profile ]; then printf '[ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc"\n' > /data/home/.profile 2>/dev/null || true chmod 644 /data/home/.profile 2>/dev/null || true + # Same as .bashrc above — created by root, so hand it over here (#5369). + chown dev:node /data/home/.profile 2>/dev/null || true fi + # ── #5369: targeted post-phase ownership sweep ──────────────────────── + # Everything the root phase creates under /data has now been created. Hand + # the closed list of those paths (HIVE_DATA_ROOT_PHASE_PATHS, defined at the + # top of this script) to the runtime user by NAME. + # + # This is what restores the invariant the DATA_OWNER guard turned into a + # boot-time snapshot. It is NOT a substitute for the guard and does not + # weaken it: the guard still prevents the recursive walk over an NFS PVC, + # and this sweep is deliberately non-recursive over a fixed list so its cost + # does not scale with the size of the volume. + # + # Most sites above already chown at the point of creation, and this sweep + # skips anything already dev-owned — so on a steady-state boot it is a + # handful of stat() calls and nothing else. It is the backstop for the site + # that forgets, which is the failure mode #5369 is actually about. + hive_sweep_root_phase_paths + # ── Per-agent UID isolation ────────────────────────────────────────── # Extract agent names from config + pack YAML, create system users, # write UID map, and set up iptables to force all outbound :443 @@ -1116,6 +1439,26 @@ with open('/var/run/hive/uid-map.json', 'w') as f: # than re-derived, since it can't have changed and the FATAL branch's exit # code already depends on the two staying the same value. + # ── System-wide git credential helper (#5343) ─────────────────────────── + # MUST happen here, in the root phase: /etc is root-owned, and the dev phase + # below cannot write it. This is the ONLY git config any per-agent UID reads + # — their $HOME (/data/home/agents/) has no .gitconfig of its own. + hive_write_system_gitconfig + + # ── #5369: last chance to name a handover we missed ─────────────────── + # This is the final instruction of the root phase. Every root-phase write + # under /data has happened and the sweep has run; the next statement execs + # as uid 1001 and can no longer chown anything. So verify HERE that the + # paths the hive process must read are readable by the user it is about to + # become, and if any is not, print WHICH ONE with its owner and mode. + # + # The config paths are included explicitly alongside the swept list because + # they are the ones whose failure is fatal — an unreadable + # /data/hive.yaml.runtime is #5360 verbatim, and it is the exact fault this + # assertion exists to name in one line instead of four merges. + hive_assert_runtime_readable $HIVE_DATA_ROOT_PHASE_PATHS \ + "$HIVE_CONFIG_RUNTIME" "$HIVE_CONFIG_RUNTIME_LEGACY" /data/hive.yaml.dashboard + # setpriv identity mirrors `gosu dev` exactly: reuid=dev (UID 1001), regid=node # (dev's PRIMARY login group, GID 1000 — there is NO group named `dev`), and # --init-groups to populate the supplementary groups from the user db for dev @@ -1210,7 +1553,25 @@ if [ -n "${HIVE_WIKI_GIT_URL:-}" ] && [ ! -d /data/vaults/hive-wiki/.git ]; then fi mkdir -p /data/vaults/hive-wiki -# Configure git identity and credential helper for GitHub App token +# Configure git identity and credential helper for GitHub App token. +# +# TWO LAYERS, DELIBERATELY (#5343): +# +# 1. /etc/gitconfig (SYSTEM) — written in the root phase above by +# hive_write_system_gitconfig. This is the layer that matters for AGENTS: +# every per-agent UID runs with its own $HOME (/data/home/agents/) +# which has no .gitconfig, so the system file is the ONLY config they read. +# +# 2. ~dev/.gitconfig (GLOBAL, this block) — the dev user's own config, which +# the contributor-relay / local-mode / `just contribute-*` paths and any +# interactive `docker exec` shell have always used. Kept because those +# paths are not agent-UID paths, and because a hive that could not become +# root (the "continuing as root" / already-non-root boot) never reaches +# layer 1 at all — this block is then the only wiring there is. +# +# These do not fight: git precedence is system < global < local, and both +# layers set the SAME helper for the SAME hosts, so the global layer shadows +# nothing. What went wrong before was having ONLY layer 2. git config --global user.name "kubestellar-hive" git config --global user.email "hive-bot@kubestellar.io" git config --global --replace-all credential.helper "" @@ -1229,41 +1590,34 @@ git config --global --replace-all "credential.https://github.com.helper" "/usr/l # and quality (which talk to the GitHub API, not git-over-HTTPS) worked fine # while guide's `git clone` could not authenticate at all. # -# The host is derived the same way the Go binary's GitHubConfig.HostLabel() -# derives it (pkg/config/config.go): prefer github.base_url, fall back to the -# host portion of github.api_url, strip scheme and a trailing /api/v3, default -# to github.com. Reading it here (from the same hive.yaml the Go binary reads) -# rather than hardcoding "github.ibm.com" keeps this general for ANY configured -# GHE host, and a no-op for a plain github.com hive (GHE_GIT_HOST resolves to -# "github.com", which already has its helper wired above). -GHE_GIT_HOST="" -if [ -f "${HIVE_CONFIG:-/etc/hive/hive.yaml}" ]; then - GHE_GIT_HOST=$(python3 -c " -import sys, yaml -try: - with open(sys.argv[1]) as f: - cfg = yaml.safe_load(f) or {} -except Exception: - sys.exit(0) -gh = cfg.get('github') or {} -pick = (gh.get('base_url') or gh.get('api_url') or '').strip() -if pick.startswith('https://'): - pick = pick[len('https://'):] -elif pick.startswith('http://'): - pick = pick[len('http://'):] -pick = pick.rstrip('/') -if pick.endswith('/api/v3'): - pick = pick[: -len('/api/v3')] -host = pick.split('/', 1)[0] -if host and host.lower() != 'api.github.com' and host.lower() != 'github.com': - print(host) -" "${HIVE_CONFIG:-/etc/hive/hive.yaml}" 2>/dev/null) || true -fi +# The host derivation lives in hive_ghe_git_host() at the top of this file so +# the system and global layers cannot drift apart. +GHE_GIT_HOST="$(hive_ghe_git_host)" if [ -n "$GHE_GIT_HOST" ]; then git config --global --replace-all "credential.https://${GHE_GIT_HOST}.helper" "/usr/local/bin/git-credential-hive.sh" echo "[entrypoint] git credential helper wired for GitHub Enterprise host: ${GHE_GIT_HOST}" fi +# ── Startup assertion: is the helper actually reachable from an AGENT UID? ── +# +# The whole point of #5343 is that the wiring LOOKED right (it was present in +# ~dev/.gitconfig) while being invisible to every agent. So assert the property +# that actually matters — "a process whose $HOME is not /home/dev resolves the +# helper" — rather than "we ran git config successfully". +# +# HOME=/nonexistent is the cheapest faithful stand-in for an agent UID here: +# it removes the global layer exactly the way an agent's own empty $HOME does, +# leaving only the system layer under test. GIT_CONFIG_NOSYSTEM is explicitly +# NOT set — the system layer is the thing being verified. +_cred_probe_host="${GHE_GIT_HOST:-github.com}" +_cred_probe="$(HOME=/nonexistent XDG_CONFIG_HOME=/nonexistent \ + git config --get-regexp "^credential\." 2>/dev/null | grep -c "git-credential-hive.sh" || true)" +if [ "${_cred_probe:-0}" -gt 0 ]; then + echo "[entrypoint] git credential helper VERIFIED reachable without a per-user .gitconfig (system layer, ${_cred_probe} host entries; agent UIDs will resolve it for ${_cred_probe_host})" +else + echo "[entrypoint] WARN: git credential helper is NOT reachable from a process without a per-user .gitconfig. Every per-agent UID will commit branches it cannot push, and hive-open-pr will report the branch as missing from the remote. Check that /etc/gitconfig exists and is mode 0644. See kubestellar/hive#5343." +fi + # Generate initial GitHub App token if credentials are available if [ -x /usr/local/bin/hive-config.sh ]; then . /usr/local/bin/hive-config.sh 2>/dev/null || true diff --git a/src/deploy/kustomize/overlays/standalone/README.md b/src/deploy/kustomize/overlays/standalone/README.md index 5dc4b49de..2e3fd2489 100644 --- a/src/deploy/kustomize/overlays/standalone/README.md +++ b/src/deploy/kustomize/overlays/standalone/README.md @@ -99,7 +99,7 @@ deliberate — there is no hub to auto-upgrade a standalone hive. publishes, and they are the only tags that are guaranteed to exist. - `vX.Y.Z` **image** tags are produced only by the automated [tagged-release workflow](../../../../docs/releases.md) - (`.github/workflows/release.yml`), which retags the just-published short-SHA + (`.github/workflows/tagged-release.yml`), which retags the just-published short-SHA images with the version it cut. That workflow landed *after* the `v4.0.0` **git** tag, so **there is no `ghcr.io/kubestellar/hive:v4.0.0` image** — a `newTag: v4.0.0` pin goes straight to `ImagePullBackOff`. Before pinning a diff --git a/src/deploy/kustomize/overlays/standalone/kustomization.yaml b/src/deploy/kustomize/overlays/standalone/kustomization.yaml index 0ceb2f299..62664008f 100644 --- a/src/deploy/kustomize/overlays/standalone/kustomization.yaml +++ b/src/deploy/kustomize/overlays/standalone/kustomization.yaml @@ -31,7 +31,7 @@ resources: # Do NOT assume `newTag: vX.Y.Z` works: ghcr.io/kubestellar/hive only carries # channel tags (stable/candidate/edge/v4-latest) plus short-SHA tags on every # merge, and a `vX.Y.Z` IMAGE tag exists only when the automated tagged-release -# workflow (.github/workflows/release.yml) has cut that version. In particular +# workflow (.github/workflows/tagged-release.yml) has cut that version. In particular # there is no `:v4.0.0` image — pinning it is an ImagePullBackOff. Check the # tag exists (README) before using one. # images: diff --git a/src/deploy/nginx.conf b/src/deploy/nginx.conf index 89b599ef5..1dc6a1d9d 100644 --- a/src/deploy/nginx.conf +++ b/src/deploy/nginx.conf @@ -67,6 +67,10 @@ http { proxy_pass http://hive_api; proxy_http_version 1.1; + # /api/contribute/ws is a WebSocket. nginx strips hop-by-hop + # headers, so both must be forwarded explicitly in this location. + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 86400s; diff --git a/src/deploy/probe_arm64_image_startup.sh b/src/deploy/probe_arm64_image_startup.sh index 9e89d2179..1b7ef79f1 100755 --- a/src/deploy/probe_arm64_image_startup.sh +++ b/src/deploy/probe_arm64_image_startup.sh @@ -41,6 +41,8 @@ # # --image REF image to probe (default: the hive ref in standalone-images.sh) # --arch ARCH architecture to require and pull (default arm64) +# --local the image is already in the local store; skip the manifest +# and pull cases and probe what is there (#5370) # --store DIR reuse a probe store instead of creating one (kept on exit) # --shared-store deliberately use the caller's default Podman store # --port PORT host port for the published API port (default 18402) @@ -61,6 +63,12 @@ IMAGE="${IMAGE:-$HIVE_STANDALONE_IMAGE_HIVE}" ARCH="arm64" STORE="" SHARED_STORE="false" +# --local (#5370): probe an image that already exists in the local store rather +# than one to be fetched from a registry. Needed because a PR's image is built +# on the runner and never published — docker.yml skips its build job on +# pull_request and only pushes from long-lived branches, so a PR head SHA has +# no published image to pull. See podman-arm64-lane.yml. +LOCAL_IMAGE="false" HOST_PORT="18402" HEALTH_TIMEOUT="180" OWN_STORE="false" @@ -72,6 +80,7 @@ while [[ $# -gt 0 ]]; do case "$1" in --image) IMAGE="${2:?--image needs a value}"; shift 2 ;; --arch) ARCH="${2:?--arch needs a value}"; shift 2 ;; + --local) LOCAL_IMAGE="true"; shift ;; --store) STORE="${2:?--store needs a value}"; shift 2 ;; --shared-store) SHARED_STORE="true"; shift ;; --port) HOST_PORT="${2:?--port needs a value}"; shift 2 ;; @@ -100,6 +109,17 @@ esac [[ "$host_arch" == "$ARCH" ]] || \ fail_prereq "this probe must run ON ${ARCH} (host is ${host_arch}); it measures the native ${ARCH} path, not emulation" +# A locally-built image lives in the caller's default store, so a throwaway +# store would not contain it and every case below would fail for a reason that +# has nothing to do with the image. Couple the two rather than letting the +# combination fail confusingly ten lines later. +if [[ "$LOCAL_IMAGE" == "true" ]]; then + SHARED_STORE="true" + if [[ -n "$STORE" ]]; then + fail_prereq "--local uses the caller's store and cannot be combined with --store" + fi +fi + WORK="$(mktemp -d)" # shellcheck disable=SC2329 # invoked through the EXIT trap below cleanup() { @@ -148,6 +168,37 @@ printf 'store=%s (throwaway=%s)\nimage=%s\n\n' "${STORE:-}" "$OW # silently steps aside when the image is not there is the same vacuous pass # #4211 warns about, and the whole point of this lane is that arm64 stopped # being unproven. +if [[ "$LOCAL_IMAGE" == "true" ]]; then + # #5370: a locally-built image has no registry manifest and nothing to pull, + # so cases 1 and 2 have no meaning here. They are SKIPPED, not faked green — + # and the two cases that carry this lane's real signal (the binary executes, + # the service starts) run exactly as they do on the published path. Those are + # the ones a change to entrypoint.sh or the Dockerfile can break, and they + # are the ones probing the published image could never test on a PR. + # + # The publisher-facing guarantee is unchanged: on a push the image is pulled + # and a missing arm64 manifest still fails, per #4336. + printf -- '--- cases: manifest + pull (SKIPPED: --local) ---\n' + printf ' %s is a local build, not a registry reference.\n' "$IMAGE" + printf ' A PR head SHA has no published image (docker.yml skips its build job\n' + printf ' on pull_request and pushes only from long-lived branches), so there is\n' + printf ' nothing to pull. Verifying the image exists locally instead.\n' + if ! pod image exists "$IMAGE"; then + printf ' RECORDED: %s is not in the local store.\n' "$IMAGE" + note_fail "local image ${IMAGE} does not exist (was the build step skipped or did it fail?)" + printf '\nSUMMARY: %d failure(s)\n' "$failures" + exit 1 + fi + local_arch="$(pod image inspect "$IMAGE" --format '{{.Architecture}}' 2>/dev/null)" + printf ' architecture: %s\n' "${local_arch:-?}" + if [[ "$local_arch" != "$ARCH" ]]; then + note_fail "local image reports architecture=${local_arch:-unknown}, expected ${ARCH}" + printf '\nSUMMARY: %d failure(s)\n' "$failures" + exit 1 + fi + note_ok "the local image is ${ARCH} and is present" +else + printf -- '--- case: manifest advertises linux/%s ---\n' "$ARCH" manifest="${WORK}/manifest.json" @@ -205,6 +256,8 @@ else note_fail "could not pull ${IMAGE} for ${ARCH}" fi +fi # end of the registry-vs-local branch opened before case 1 (#5370) + # ── Case 3: the shipped binary actually executes ─────────────────────────── # #3760: an image can pull cleanly and still carry a /usr/local/bin/hive that # the runtime presents as non-executable. Cheap to check, and precisely the diff --git a/src/deploy/test_arm64_lane_probes_pr_code.sh b/src/deploy/test_arm64_lane_probes_pr_code.sh new file mode 100755 index 000000000..2ffc8f155 --- /dev/null +++ b/src/deploy/test_arm64_lane_probes_pr_code.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# #5370: on a pull request the arm64 lane must probe the PR's OWN code. +# +# The fault this closes is a guard that cannot fail. The lane probed +# ghcr.io/kubestellar/hive:stable — the published release-channel image — so on +# a PR it validated code already on v4 rather than the change proposed. A PR +# that REPAIRS a startup bug ran against the still-broken published image and +# stayed red; a PR that INTRODUCED one ran against the still-good published +# image and went green. The signal was inverted exactly when it mattered. +# +# Measured: #5342 broke startup (#5360) and merged green; the lane then stayed +# red across four merges, and #5368 — the fix — was red too. +# +# These are structural assertions against the workflow and probe. The lane's +# real behaviour needs an arm64 runner with Podman, which is the lane itself; +# what is checkable here is that the wiring which makes it probe PR code exists +# and has not been quietly removed. +# +# Run: bash src/deploy/test_arm64_lane_probes_pr_code.sh +set -uo pipefail + +PASS=0 +FAIL=0 + +HERE="$(cd "$(dirname "$0")" && pwd)" +LANE="$(cd "$HERE/../../.github/workflows" && pwd)/podman-arm64-lane.yml" +PROBE="$HERE/probe_arm64_image_startup.sh" + +ok() { echo " PASS: $1"; PASS=$((PASS + 1)); } +bad() { echo " FAIL: $1"; [ -n "${2:-}" ] && echo " $2"; FAIL=$((FAIL + 1)); } + +echo "=== #5370: the arm64 lane probes PR code, not the published image ===" + +for f in "$LANE" "$PROBE"; do + [ -f "$f" ] || { bad "missing file: $f"; echo; echo "=== $PASS passed, $FAIL failed ==="; exit 1; } +done + +# ── The lane must build on a pull request ──────────────────────────────── +if grep -q "if: github.event_name == 'pull_request'" "$LANE" \ + && grep -qE '^\s+podman build' "$LANE"; then + ok "the lane builds the image from the checkout on a pull request" +else + bad "the lane does not build the PR's image" \ + "without this it probes the published (pre-PR) image and cannot gate the change under review" +fi + +# That build must NOT push. The lane holds `contents: read` and no secrets; +# a push would need credentials it deliberately does not have. +if grep -qE '^\s+podman push' "$LANE"; then + bad "the lane pushes an image" \ + "this lane has contents: read and no registry secrets — building is local-only by design" +else + ok "the PR build is local-only (no push, no registry credentials needed)" +fi + +# The permission block must stay minimal. A build step is not a reason to +# widen it, and quietly gaining packages: write would be a real regression. +if grep -qE '^\s+contents: read$' "$LANE" && ! grep -qE '^\s+packages: write' "$LANE"; then + ok "the lane still declares only contents: read" +else + bad "the lane's permissions changed" \ + "building the PR image locally must not require packages: write" +fi + +# ── A build failure must be loud ───────────────────────────────────────── +# Falling back to the published image when the PR build fails would restore +# the inverted signal: a PR whose image does not build would go green against +# someone else's working image. +build_block="$(awk '/Build the arm64 image from this PR/,/^ - name: Select/' "$LANE")" +if grep -qE 'continue-on-error|\|\| true' <<<"$build_block"; then + bad "the PR build step swallows failures" \ + "a PR that cannot build an image must fail the lane, not fall back to the published one" +else + ok "the PR build step has no continue-on-error and no || true" +fi + +# ── The probe must be told the image is local ──────────────────────────── +# A locally-built image has no registry manifest and nothing to pull, so the +# probe's first two cases must be skipped explicitly rather than left to fail. +# Match the ARGUMENT being appended, not the word appearing in a comment. +# A grep for bare '--local' passes on the comment alone, which would make this +# assertion unable to fail — the same defect as the lane it is guarding. +if grep -qE 'args="\$\{args\}[[:space:]]+--local"' "$LANE"; then + ok "the lane passes --local when probing a PR-built image" +else + bad "the lane never passes --local to the probe" \ + "the probe would try to pull a local-only reference and fail for the wrong reason" +fi + +if grep -q -- '--local) LOCAL_IMAGE="true"' "$PROBE"; then + ok "the probe accepts --local" +else + bad "the probe does not implement --local" +fi + +# --local must not silently weaken the lane: the cases that carry the signal +# (binary executes, service starts) must still run. Only manifest+pull skip. +if grep -q 'cases: manifest + pull (SKIPPED: --local)' "$PROBE"; then + ok "--local skips only the manifest and pull cases" +else + bad "could not find the --local skip block in the probe" +fi + +for c in 'the shipped binary executes' 'the service starts'; do + # These printf headers must sit OUTSIDE the registry-vs-local branch, i.e. + # they run on both paths. If one moved inside, --local would stop testing + # the very thing #5370 is about. + if grep -q -- "--- case: ${c}" "$PROBE" || grep -q -- "case: ${c}" "$PROBE"; then + ok "the '${c}' case is still present" + else + bad "the '${c}' case is gone from the probe" \ + "that case is the signal a PR-built image exists to produce" + fi +done + +# A missing local image must FAIL, never skip. An arm64 lane that steps aside +# when its image is absent reports green while proving nothing — the same +# vacuous-pass shape #4336 already refused for a missing manifest. +if grep -q 'does not exist (was the build step skipped or did it fail?)' "$PROBE"; then + ok "a missing local image fails the probe rather than skipping" +else + bad "the probe does not fail on a missing local image" +fi + +# ── The push path must be unchanged ────────────────────────────────────── +# #4336's stop condition still holds there: on a push the lane pulls the +# published image, and a missing arm64 manifest is a failure, not a skip. The +# fix for #5370 is scoped to pull_request and must not have relaxed that. +if grep -q 'no linux/${ARCH} image published for' "$PROBE" \ + || grep -q 'no linux/%s image is published' "$PROBE"; then + ok "a missing published arm64 manifest still fails on the push path (#4336)" +else + bad "the missing-manifest failure is gone" \ + "#4336: the fix for an absent arm64 image belongs in the publisher, not a lane that skips" +fi + +# ── The stale default must not come back ───────────────────────────────── +# The dispatch input said 'default ghcr.io/kubestellar/hive:v4-latest' and +# nothing defaulted to that: an empty input means the probe's own fallback, +# HIVE_STANDALONE_IMAGE_HIVE = ghcr.io/kubestellar/hive:stable. Documenting a +# tag the code never uses is how #5370's description misdescribed the bug. +if grep -q "description: 'Image to probe (default ghcr.io/kubestellar/hive:v4-latest)'" "$LANE"; then + bad "the workflow_dispatch input still claims a v4-latest default" \ + "an empty input resolves to ghcr.io/kubestellar/hive:stable via standalone-images.sh" +else + ok "the dispatch input no longer claims a v4-latest default" +fi + +# The actual fallback must still be the #4206 source of truth, not a literal. +if grep -q 'IMAGE="${IMAGE:-$HIVE_STANDALONE_IMAGE_HIVE}"' "$PROBE"; then + ok "the probe default still comes from standalone-images.sh (#4206)" +else + bad "the probe no longer defaults to HIVE_STANDALONE_IMAGE_HIVE" \ + "hardcoding a tag here breaks the single source of truth for image refs" +fi + +# ── #5339: never an empty ${{ }} ───────────────────────────────────────── +# An empty expression makes GitHub refuse to parse the workflow, and the +# parser does not honour shell comments — so it breaks even inside one. +if grep -qE '\$\{\{[[:space:]]*\}\}' "$LANE"; then + bad "the workflow contains an empty \${{ }} expression" \ + "#5339: GitHub refuses to parse the file, including inside comments" +else + ok "no empty \${{ }} expression in the workflow (#5339)" +fi + +echo +echo "=== $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ] diff --git a/src/deploy/test_attach_hint_runtime.sh b/src/deploy/test_attach_hint_runtime.sh new file mode 100755 index 000000000..fb50d2720 --- /dev/null +++ b/src/deploy/test_attach_hint_runtime.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# In-container attach hints must name the runtime that actually launched the +# container (kubestellar/hive#5145). +# Run: bash src/deploy/test_attach_hint_runtime.sh +# +# WHAT WENT WRONG. `just contribute-hive` (container mode) resolves docker OR +# podman. Both hints printed from INSIDE the container hardcoded docker, because +# a container cannot see its own launcher. Observed live on a podman launch, in +# one screen of output: +# +# Attach: podman exec -it hive-contributor-agy-... tmux attach -t contributor +# Tmux: docker exec -it hive-contributor-agy-... tmux attach -t contributor +# +# Two contradictory instructions for the same container. The operator pastes the +# second and gets "permission denied ... /var/run/docker.sock", or "no such +# container" if docker happens to be running too. +# +# The fix passes the resolved runtime in as HIVE_CONTAINER_RUNTIME, so this test +# pins the whole chain: the recipe PASSES it, the entrypoint READS it, and the +# host-side and in-container hints AGREE. Agreement is the assertion the bug +# report is actually about — either hint alone can be self-consistently wrong. +# +# The relay's own banner (the worse of the two sites: it fires when a human MUST +# attach to complete a login) is covered behaviourally next door, in +# bin/contributor-relay.test.js — it is JavaScript, and loading it to read the +# value it prints is strictly stronger than asserting on its source text here. +# +# Every string under test is READ FROM ITS SHIPPED SOURCE and evaluated, never +# restated: a copy would keep passing after the real one regressed. +set -uo pipefail + +PASS=0 +FAIL=0 +pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $1"; [ $# -gt 1 ] && echo " $2"; FAIL=$((FAIL + 1)); } + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +JUSTFILE="${ROOT}/Justfile" +AGENT="${ROOT}/bin/contributor-agent.sh" +RELAY="${ROOT}/bin/contributor-relay.sh" + +echo "=== in-container attach hints name the real runtime (#5145) ===" + +# ── 1. The recipe passes the resolved runtime in ──────────────────────────── +# +# It must ride along on the SAME container-run invocation that already passes +# HIVE_CONTAINER_NAME, not merely appear somewhere in a 2,000-line Justfile. +RUN_BLOCK="$(awk '/"\$RUNTIME" run -d/{inblock=1} inblock{print} inblock && /hive_image/{exit}' "$JUSTFILE")" +if ! grep -qF -- '-e HIVE_CONTAINER_NAME=' <<<"$RUN_BLOCK"; then + fail "locate the contributor container-run invocation in the Justfile" \ + "the anchors moved; this test cannot verify what the recipe passes" +elif grep -qF -- '-e HIVE_CONTAINER_RUNTIME="${RUNTIME}"' <<<"$RUN_BLOCK"; then + pass "the Justfile passes the resolved runtime as HIVE_CONTAINER_RUNTIME" +else + fail "the Justfile passes HIVE_CONTAINER_RUNTIME" \ + "without it the container has no way to know its launcher and guesses docker" +fi + +# ── 2. Neither in-container script hardcodes a runtime ────────────────────── +for f in "$AGENT" "$RELAY"; do + name="${f#"${ROOT}/"}" + if hits="$(grep -n 'docker exec' "$f")"; then + fail "$name has no hardcoded 'docker exec'" "$(head -3 <<<"$hits")" + else + pass "$name has no hardcoded 'docker exec'" + fi +done + +# ── 3. The entrypoint's hint renders the runtime it is given ──────────────── +HINT_LINE="$(grep -F 'echo " Tmux:' "$AGENT" | head -1)" + +# render_hint — evaluates the shipped echo +# with the same defaulting the entrypoint applies around it. env -i so a +# HIVE_CONTAINER_RUNTIME exported by whoever runs this suite cannot leak in. +render_hint() { + env -i CONTAINER="$1" RT="${2-}" HINT="$HINT_LINE" bash -c ' + CONTAINER_NAME="${CONTAINER}" + CONTAINER_RUNTIME="${RT:-docker}" + TMUX_SESSION="contributor" + eval "$HINT" + ' +} + +if [ -z "$HINT_LINE" ]; then + fail "extract the Tmux hint from bin/contributor-agent.sh" \ + "the anchor moved — this test cannot verify the real hint" +else + pass "Tmux hint extracted from bin/contributor-agent.sh (not restated here)" + + GOT="$(render_hint hive-contributor-agy-5b4f podman)" + WANT=' Tmux: podman exec -it hive-contributor-agy-5b4f tmux attach -t contributor' + if [ "$GOT" = "$WANT" ]; then + pass "a podman launch is told to run podman" + else + fail "a podman launch is told to run podman" "got: $GOT + want: $WANT" + fi + + # An older image, or a launch by anything that does not pass the variable, + # must print exactly what it printed before the fix. + GOT="$(render_hint hive-contributor '')" + WANT=' Tmux: docker exec -it hive-contributor tmux attach -t contributor' + if [ "$GOT" = "$WANT" ]; then + pass "with no runtime passed the hint is unchanged (docker)" + else + fail "with no runtime passed the hint is unchanged (docker)" "got: $GOT + want: $WANT" + fi +fi + +# ── 4. The two hints for the same container agree ────────────────────────── +# +# The reported bug in one assertion: the host-side hint the recipe prints and +# the in-container hint the entrypoint prints must be the same command. +HOST_TPL="$(grep -F 'ATTACH_CMD=' "$JUSTFILE" | head -1)" +if [ -z "$HOST_TPL" ] || [ -z "$HINT_LINE" ]; then + fail "extract both hints for comparison" "an anchor moved" +else + HOST_RENDERED="$(env -i TPL="$HOST_TPL" bash -c ' + RUNTIME=podman + CONTAINER_NAME=hive-contributor-agy-5b4f + eval "$TPL" + echo "$ATTACH_CMD" + ')" + # Strip the status block's label and column padding: that is layout, not the + # command an operator pastes. + CONTAINER_RENDERED="$(render_hint hive-contributor-agy-5b4f podman | sed 's/^ *Tmux: *//')" + if [ "$HOST_RENDERED" = "$CONTAINER_RENDERED" ]; then + pass "host-side and in-container hints are the same command" + else + fail "host-side and in-container hints are the same command" "host: $HOST_RENDERED + container: $CONTAINER_RENDERED" + fi +fi + +echo "" +echo "=== Results: $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ] diff --git a/src/deploy/test_contribute_k8s_workload.sh b/src/deploy/test_contribute_k8s_workload.sh index e6ed2f007..6c083d16a 100755 --- a/src/deploy/test_contribute_k8s_workload.sh +++ b/src/deploy/test_contribute_k8s_workload.sh @@ -62,9 +62,15 @@ EOF cat > "$FAKE_HOME/.config/hive/gh-auth.env" <<'EOF' GH_TOKEN=placeholder-gh-token EOF +# A logged-in Claude credential file (#5103): the generator refuses to emit a +# claude workload without one (or ANTHROPIC_API_KEY), so the happy path seeds +# a placeholder. Never a real credential. +mkdir -p "$FAKE_HOME/.claude" +printf '%s' '{"claudeAiOauth":{"accessToken":"placeholder-claude-oauth","refreshToken":"placeholder-claude-refresh"}}' \ + > "$FAKE_HOME/.claude/.credentials.json" # ── Supported backend (claude): full workload on stdout ── -OUT="$(cd "$REPO_ROOT" && HOME="$FAKE_HOME" just contribute-k8s hive-contributor 2>/dev/null)" +OUT="$(cd "$REPO_ROOT" && HOME="$FAKE_HOME" ANTHROPIC_API_KEY="" just contribute-k8s hive-contributor 2>/dev/null)" contains "emits a Deployment" "$OUT" "kind: Deployment" contains "workload runs headless (#2660)" "$OUT" "CONTRIBUTOR_MODE: \"headless\"" @@ -97,6 +103,83 @@ else echo " SKIP: PyYAML unavailable; skipping structural parse" fi +# ── Backend credential delivery (#5103) ── +# The workload used to carry NO credential for the agent CLI itself: the pod +# authenticated to the hub and GitHub, then launched a backend with nothing to +# authenticate with. These pin the three claude outcomes: OAuth file shipped, +# explicit API key preferred, and an honest refusal when neither exists. +contains "Secret ships the operator's Claude credential (#5103)" "$OUT" "HIVE_CLAUDE_CREDENTIALS_B64:" +# Round-trip, not just presence: the Secret value, decoded once by Kubernetes +# (envFrom) and once by the entrypoint, must reproduce the credential file +# byte-for-byte. Presence-only checking passed an EMPTY value during +# development (the b64 helper was called before its definition), so this +# assertion is the one that actually guards the mechanism. +CRED_VAL="$(printf '%s' "$OUT" | grep 'HIVE_CLAUDE_CREDENTIALS_B64:' | awk '{print $2}')" +ROUNDTRIP="$(printf '%s' "$CRED_VAL" | base64 -d 2>/dev/null | base64 -d 2>/dev/null || true)" +check "credential round-trips byte-for-byte through Secret + entrypoint decode" \ + "$(cat "$FAKE_HOME/.claude/.credentials.json")" "$ROUNDTRIP" +if printf '%s' "$OUT" | grep -q "placeholder-claude-oauth"; then + echo " FAIL: raw Claude credential bytes leaked into the YAML unencoded" + FAIL=$((FAIL + 1)) +else + echo " PASS: Claude credential travels encoded, never as raw bytes" + PASS=$((PASS + 1)) +fi +contains "credential note covers the backend credential" "$OUT" "#5103" + +# An explicit ANTHROPIC_API_KEY beats the file — operator intent wins. +KEYED="$(cd "$REPO_ROOT" && HOME="$FAKE_HOME" ANTHROPIC_API_KEY="placeholder-anthropic-key" just contribute-k8s hive-contributor 2>/dev/null)" +contains "explicit ANTHROPIC_API_KEY is shipped" "$KEYED" "ANTHROPIC_API_KEY:" +if printf '%s' "$KEYED" | grep -q "HIVE_CLAUDE_CREDENTIALS_B64:"; then + echo " FAIL: API key set, but the OAuth file was shipped anyway" + FAIL=$((FAIL + 1)) +else + echo " PASS: API key takes precedence over the OAuth file" + PASS=$((PASS + 1)) +fi + +# No credential at all: refuse at generation, naming the fix, exiting nonzero — +# a refusal here beats a manifest that deploys cleanly and cannot work. +NOCRED_HOME="$(mktemp -d)" +mkdir -p "$NOCRED_HOME/.config/hive" +cp "$FAKE_HOME/.config/hive/contributor.env" "$NOCRED_HOME/.config/hive/" +cp "$FAKE_HOME/.config/hive/gh-auth.env" "$NOCRED_HOME/.config/hive/" +if (cd "$REPO_ROOT" && HOME="$NOCRED_HOME" ANTHROPIC_API_KEY="" just contribute-k8s hive-contributor >/dev/null 2>"$NOCRED_HOME/err"); then + echo " FAIL: generation succeeded with no claude credential to ship" + FAIL=$((FAIL + 1)) +else + echo " PASS: generation refuses when the claude CLI would have no credential" + PASS=$((PASS + 1)) +fi +contains "refusal names the missing credential" "$(cat "$NOCRED_HOME/err")" "no credential to ship" +contains "refusal points at the tracking issue" "$(cat "$NOCRED_HOME/err")" "#5103" + +# The escape hatch emits anyway (credentials provided out of band), warning on +# stderr and keeping stdout clean YAML for kubectl. +HATCH_OUT="$(cd "$REPO_ROOT" && HOME="$NOCRED_HOME" ANTHROPIC_API_KEY="" HIVE_K8S_ALLOW_MISSING_BACKEND_CREDENTIALS=1 just contribute-k8s hive-contributor 2>"$NOCRED_HOME/hatch-err")" +contains "escape hatch still emits the Deployment" "$HATCH_OUT" "kind: Deployment" +contains "escape hatch warns on stderr" "$(cat "$NOCRED_HOME/hatch-err")" "NO claude credential" +if printf '%s' "$HATCH_OUT" | grep -q "WARNING:"; then + echo " FAIL: escape-hatch warning leaked into stdout" + FAIL=$((FAIL + 1)) +else + echo " PASS: escape-hatch stdout stays clean YAML" + PASS=$((PASS + 1)) +fi + +# copilot: OAuth state directory, unverified in an unattended pod — refused +# with a pointer rather than emitted broken. +sed -i.bak 's/^AGENT_BACKEND=.*/AGENT_BACKEND=copilot/' "$NOCRED_HOME/.config/hive/contributor.env" +if (cd "$REPO_ROOT" && HOME="$NOCRED_HOME" just contribute-k8s hive-contributor >/dev/null 2>"$NOCRED_HOME/copilot-err"); then + echo " FAIL: copilot generation succeeded despite unverified pod auth" + FAIL=$((FAIL + 1)) +else + echo " PASS: copilot refuses with its credential path unverified" + PASS=$((PASS + 1)) +fi +contains "copilot refusal names the alternative" "$(cat "$NOCRED_HOME/copilot-err")" "contribute-hive copilot" +rm -rf "$NOCRED_HOME" + # ── Unsupported headless backend (bob): warn on STDERR, keep stdout clean. ── # bob drives an interactive TUI with no known one-shot entry point. goose used # to be the example here, but it is headless-capable via `goose run` (#2828). diff --git a/src/deploy/test_entrypoint_data_ownership.sh b/src/deploy/test_entrypoint_data_ownership.sh new file mode 100755 index 000000000..9f59eed85 --- /dev/null +++ b/src/deploy/test_entrypoint_data_ownership.sh @@ -0,0 +1,351 @@ +#!/usr/bin/env bash +# #5369: /data ownership must be an INVARIANT, not a boot-time snapshot. +# +# The fault this closes: entrypoint.sh's `chown -R dev:node /data` is guarded on +# `[ "$DATA_OWNER" != "1001" ]`, and src/Dockerfile already ships /data owned by +# dev:node — so on a normal boot the guard is FALSE and the recursive chown never +# runs. Everything the root phase creates under /data afterwards keeps root:root, +# and the hive process (uid 1001, after the setpriv/gosu drop) cannot read it. +# +# #5360 was one instance: /data/hive.yaml.runtime, chmod 600 with no chown, in +# the root phase, on a /data that was already uid 1001. #5368 fixed that one +# file. This tests the CLASS. +# +# The standard here is #5368's, and it is deliberate: assert READABILITY BY THE +# READING USER, not permission bits. #5342 asserted only the mode and passed +# while the product was broken — mode 0600 is perfectly correct and perfectly +# unreadable when the owner is not the reader. +# +# Run: bash src/deploy/test_entrypoint_data_ownership.sh +set -uo pipefail + +PASS=0 +FAIL=0 + +ENTRYPOINT="$(cd "$(dirname "$0")" && pwd)/entrypoint.sh" +RUNTIME_UID=1001 + +check() { + local label="$1" want="$2" got="$3" + if [ "$want" = "$got" ]; then + echo " PASS: $label" + PASS=$((PASS + 1)) + else + echo " FAIL: $label" + echo " want: '$want'" + echo " got: '$got'" + FAIL=$((FAIL + 1)) + fi +} + +ok() { echo " PASS: $1"; PASS=$((PASS + 1)); } +bad() { echo " FAIL: $1"; [ -n "${2:-}" ] && echo " $2"; FAIL=$((FAIL + 1)); } + +# ── Skipping is a result, and where it is wrong it must be fatal (#5380) ── +# +# The behavioural block below needs root and a `dev` account. On a bare +# ubuntu-latest runner or a laptop it has neither, so it skips LOUDLY rather +# than faking a pass — that stays, and this suite remains runnable anywhere. +# +# But a loud skip that nothing acts on is still a guard that cannot fail, and +# that is #5380: the assertions which would catch a regression never executed +# on any PR. So when the caller KNOWS the preconditions are met — the podman +# arm64 lane runs this inside the image, as root, where `dev` exists — it sets +# HIVE_TEST_REQUIRE_BEHAVIOURAL=1 and a skip becomes a FAILURE. There, a skip +# does not mean "unsuitable environment", it means the test is broken. +REQUIRE_BEHAVIOURAL="${HIVE_TEST_REQUIRE_BEHAVIOURAL:-0}" + +skip() { + if [ "$REQUIRE_BEHAVIOURAL" = "1" ]; then + bad "$1" \ + "HIVE_TEST_REQUIRE_BEHAVIOURAL=1 — the caller asserts root and a 'dev' account are present, so this is a BROKEN TEST, not an unsuitable environment (#5380)" + else + echo " SKIP: $1" + [ -n "${2:-}" ] && echo " $2" + fi + return 0 +} + +echo "=== #5369: /data ownership invariant ===" + +# ── Structural: the guard must SURVIVE ─────────────────────────────────── +# +# The fix must not be "delete the guard". A recursive chown over an NFS-backed +# PVC with thousands of files costs minutes of startup; removing the guard +# trades a permissions bug for a boot-time one. Assert the protection is still +# there, so a future "simplification" that removes it fails here. +if grep -q 'DATA_OWNER=\$(stat -c' "$ENTRYPOINT" \ + && grep -q 'if \[ "\$DATA_OWNER" != "1001" \]; then' "$ENTRYPOINT"; then + ok "the DATA_OWNER guard still gates the recursive chown (NFS protection intact)" +else + bad "the DATA_OWNER guard is gone" \ + "removing it reintroduces multi-minute NFS startup delays; the fix for #5369 is a targeted sweep, not a walk" +fi + +# The sweep that replaces it must NOT itself be a recursive walk, or it has +# reintroduced exactly the cost the guard exists to avoid. +SWEEP="$(sed -n '/^hive_sweep_root_phase_paths() {/,/^}/p' "$ENTRYPOINT")" +if [ -z "$SWEEP" ]; then + bad "could not extract hive_sweep_root_phase_paths from $ENTRYPOINT" +elif grep -qE 'chown[[:space:]]+-R|chown[[:space:]]+-[a-zA-Z]*R' <<<"$SWEEP"; then + bad "the ownership sweep recurses" \ + "a recursive chown here costs the same multi-minute NFS walk the DATA_OWNER guard prevents" +else + ok "the ownership sweep is non-recursive (cost does not scale with PVC size)" +fi + +# ── Structural: the path list must be wired to BOTH consumers ──────────── +for fn in hive_sweep_root_phase_paths hive_assert_runtime_readable; do + if grep -q "^${fn}() {" "$ENTRYPOINT"; then + ok "$fn is defined" + else + bad "$fn is not defined in $ENTRYPOINT" + fi +done + +if grep -q '^HIVE_DATA_ROOT_PHASE_PATHS="' "$ENTRYPOINT"; then + ok "HIVE_DATA_ROOT_PHASE_PATHS is defined" +else + bad "HIVE_DATA_ROOT_PHASE_PATHS is not defined" +fi + +# The sweep must actually be CALLED in the root phase. A helper that is defined +# and never invoked is the #5369 shape all over again — code that looks like a +# fix and runs on no boot at all. +sweep_calls="$(grep -cE '^[[:space:]]*hive_sweep_root_phase_paths[[:space:]]*$' "$ENTRYPOINT" || true)" +check "the sweep is invoked exactly once" "1" "$sweep_calls" + +# ── Structural: the assertion must run BEFORE the privilege drop ───────── +# +# After the exec we are uid 1001 and can no longer chown or meaningfully +# diagnose. An assertion placed after the drop would never run at all. +assert_line="$(grep -n 'hive_assert_runtime_readable \$HIVE_DATA_ROOT_PHASE_PATHS' "$ENTRYPOINT" | head -1 | cut -d: -f1)" +drop_line="$(grep -nE '^[[:space:]]*exec (setpriv|gosu) ' "$ENTRYPOINT" | head -1 | cut -d: -f1)" +if [ -z "$assert_line" ]; then + bad "hive_assert_runtime_readable is never called on the path list" +elif [ -z "$drop_line" ]; then + bad "could not locate the privilege drop (exec setpriv/gosu)" +elif [ "$assert_line" -lt "$drop_line" ]; then + ok "the readability assertion runs before the privilege drop (line $assert_line < $drop_line)" +else + bad "the readability assertion runs AFTER the privilege drop" \ + "at that point the process is already uid 1001 — the check cannot fire or fix anything" +fi + +# The assertion must cover the config paths, which are the ones whose failure +# is fatal. #5360 was exactly /data/hive.yaml.runtime. +if grep -q 'hive_assert_runtime_readable \$HIVE_DATA_ROOT_PHASE_PATHS' "$ENTRYPOINT" \ + && sed -n "${assert_line},$((assert_line + 2))p" "$ENTRYPOINT" | grep -q 'HIVE_CONFIG_RUNTIME'; then + ok "the assertion covers the runtime config paths (the #5360 file)" +else + bad "the assertion does not cover HIVE_CONFIG_RUNTIME" \ + "that is the file whose unreadability was #5360; it is the one that must be named" +fi + +# ── Structural: every root-phase /data creator is in the list ──────────── +# +# This is the maintenance rule enforced mechanically. Extract the root phase +# (from `if [ "$(id -u)" = "0" ]` to the privilege drop) and find every mkdir +# that creates a path under /data. Each such path must either appear in +# HIVE_DATA_ROOT_PHASE_PATHS or be explicitly chowned at its own site. +# +# Without this, the list silently goes stale the first time someone adds a +# write — which is precisely the failure mode the issue describes. +echo +echo "=== every root-phase /data creator is covered ===" + +root_start="$(grep -n 'if \[ "\$(id -u)" = "0" \]; then' "$ENTRYPOINT" | head -1 | cut -d: -f1)" +if [ -z "$root_start" ] || [ -z "$drop_line" ]; then + bad "could not delimit the root phase" +else + ROOT_PHASE="$(sed -n "${root_start},${drop_line}p" "$ENTRYPOINT")" + LIST="$(sed -n '/^HIVE_DATA_ROOT_PHASE_PATHS="/,/^"$/p' "$ENTRYPOINT")" + + # Paths intentionally excluded, with the reason recorded in the entrypoint: + # /data/agents/*, /data/beads/* -> chowned to per-agent hive- UIDs + # /data/vaults -> created in the DEV phase, already dev-owned + uncovered="" + # shellcheck disable=SC2016 + mkdir_paths="$(printf '%s' "$ROOT_PHASE" \ + | grep -oE 'mkdir -p [^&|;]*' \ + | tr ' ' '\n' \ + | grep -E '^/data/[A-Za-z0-9._/-]+$' \ + | grep -vE '^/data/(agents|beads|vaults)(/|$)' \ + | sort -u)" + + for p in $mkdir_paths; do + # Covered if it is in the list... + if grep -qxF "$p" <<<"$LIST"; then + continue + fi + # ...or if the site chowns that exact path... + if grep -qE "chown( -R)? dev:node [^&|;]*${p}( |$|/)" <<<"$ROOT_PHASE"; then + continue + fi + # ...or if an ANCESTOR is chowned RECURSIVELY, which does cover it. E.g. + # `mkdir -p /data/home/.claude/session-env` is covered by + # `chown -R dev:node /data/home/.claude`. Only -R counts here: a + # non-recursive chown of the parent does NOT reach the child. + covered_by_ancestor="" + anc="$p" + while [ "$anc" != "/data" ] && [ "$anc" != "/" ]; do + anc="$(dirname "$anc")" + [ "$anc" = "/data" ] && break + if grep -qE "chown -R dev:node [^&|;]*${anc}( |$)" <<<"$ROOT_PHASE"; then + covered_by_ancestor=yes + break + fi + done + [ -n "$covered_by_ancestor" ] && continue + uncovered="$uncovered $p" + done + + if [ -n "$uncovered" ]; then + bad "root-phase mkdir under /data not covered by the sweep list or an inline chown:$uncovered" \ + "add each to HIVE_DATA_ROOT_PHASE_PATHS, or chown it at its creation site (#5369)" + else + ok "every root-phase mkdir under /data is swept or chowned at its site" + fi + + # The two files the root phase WRITES (not mkdirs) under /data. These were + # the concrete gaps found for #5369: created by `cat >` / `printf >` as root, + # chmod 644 applied, no chown — so root:root on every boot. + for f in /data/home/.bashrc /data/home/.profile; do + if grep -qE "chown dev:node ${f}( |$)" <<<"$ROOT_PHASE"; then + ok "$f is chowned at its creation site" + elif grep -qxF "$f" <<<"$LIST"; then + ok "$f is covered by the sweep list" + else + bad "$f is written by root and never handed to dev" \ + "agent shells source it; a root-owned copy is the #5369 class" + fi + done +fi + +# ── Behavioural: the part that mode-checking could never catch ─────────── +# +# Everything above is structure. This is the product property: a root-created +# path, run through the sweep, must be genuinely OPENABLE by uid 1001 — proven +# by really opening it as that uid, which is the syscall the hive binary makes. +echo +echo "=== behavioural: swept paths are readable by the runtime user ===" + +if [ "$(id -u)" != "0" ]; then + skip "not root — cannot create root-owned files or drop to another uid" \ + "(this is the case a container lane must run; see #5360/#5369)" +elif ! id -u dev >/dev/null 2>&1; then + skip "no 'dev' account on this host — cannot exercise the drop" +elif ! stat -c '%u' / >/dev/null 2>&1; then + skip "no GNU stat -c on this host — the helpers require it" +else + SWEEP_FN="$SWEEP" + ASSERT_FN="$(sed -n '/^hive_assert_runtime_readable() {/,/^}/p' "$ENTRYPOINT")" + + tmpd="$(mktemp -d)" + trap 'rm -rf "$tmpd"' EXIT + chmod 755 "$tmpd" + + # Reproduce the failing shape: root creates a directory and a file under it + # exactly as the root phase does, and never chowns them. + mkdir -p "$tmpd/home" + printf 'export SSL_CERT_FILE=/data/proxy-ca.pem\n' > "$tmpd/home/.bashrc" + chown -R root:root "$tmpd/home" + chmod 700 "$tmpd/home" # root-only dir: dev cannot even traverse it + chmod 600 "$tmpd/home/.bashrc" + + HIVE_RUNTIME_USER="dev" + HIVE_RUNTIME_GROUP="node" + export HIVE_RUNTIME_USER HIVE_RUNTIME_GROUP + + # shellcheck disable=SC1090 + eval "$ASSERT_FN" + # shellcheck disable=SC1090 + eval "$SWEEP_FN" + + # 1. BEFORE the sweep, the runtime user must NOT be able to read it. If this + # fails the fixture is wrong and every assertion below is vacuous — the + # way a test can pass while proving nothing. + if su -s /bin/sh dev -c "cat '$tmpd/home/.bashrc' >/dev/null 2>&1"; then + bad "fixture invalid: dev could already read the root-owned file before the sweep" \ + "the rest of this block would pass vacuously" + else + ok "fixture: the runtime user cannot read the root-created path (the #5369 fault)" + fi + + # 2. The assertion must NAME that path while it is still broken. This is the + # diagnostic #5360 lacked: a silent EACCES from the Go binary versus a + # line of output identifying the file. + assert_out="$(HIVE_RUNTIME_USER=dev hive_assert_runtime_readable "$tmpd/home" "$tmpd/home/.bashrc" 2>&1 || true)" + if grep -qF "$tmpd/home" <<<"$assert_out"; then + ok "the assertion names the unreadable path before the privilege drop" + else + bad "the assertion did not name the unreadable path" \ + "got: $assert_out" + fi + + # 3. Run the sweep over that list, then re-check. THE ASSERTION THAT MATTERS: + # a real open() as uid 1001, not a stat of the mode bits. + HIVE_DATA_ROOT_PHASE_PATHS="$tmpd/home +$tmpd/home/.bashrc" + hive_sweep_root_phase_paths >/dev/null 2>&1 + + check "swept directory is owned by the runtime uid" \ + "$RUNTIME_UID" "$(stat -c '%u' "$tmpd/home" 2>/dev/null)" + check "swept file is owned by the runtime uid" \ + "$RUNTIME_UID" "$(stat -c '%u' "$tmpd/home/.bashrc" 2>/dev/null)" + + if su -s /bin/sh dev -c "cat '$tmpd/home/.bashrc' >/dev/null 2>&1"; then + ok "the runtime user can actually read the swept path (real open() as uid 1001)" + else + bad "the runtime user STILL cannot read the swept path" \ + "this is #5369 — root-phase writes stay unreadable after the privilege drop" + fi + + # 4. The sweep must not have bought readability by WIDENING the mode. #5331 + # exists because a file holding dashboard.auth_token was world-readable; + # the fix for #5369 is ownership, never permissions. + check "the sweep did not widen the file mode" \ + "600" "$(stat -c '%a' "$tmpd/home/.bashrc" 2>/dev/null)" + check "the sweep did not widen the directory mode" \ + "700" "$(stat -c '%a' "$tmpd/home" 2>/dev/null)" + + if id -u nobody >/dev/null 2>&1; then + if su -s /bin/sh nobody -c "cat '$tmpd/home/.bashrc' >/dev/null 2>&1"; then + bad "an unrelated uid can read the swept file" \ + "readability must come from ownership, not from a widened mode (#5331)" + else + ok "an unrelated uid still cannot read the swept file" + fi + fi + + # 5. After the sweep the assertion must fall SILENT. A check that warns even + # once things are correct is noise, and noise is what makes the real + # warning ignorable. + assert_out2="$(hive_assert_runtime_readable "$tmpd/home" "$tmpd/home/.bashrc" 2>&1 || true)" + if [ -z "$assert_out2" ]; then + ok "the assertion is silent once ownership is correct" + else + bad "the assertion still warns after a successful sweep" \ + "got: $assert_out2" + fi + + # 6. FAIL OPEN, not closed (#5368's lesson). When the chown is impossible the + # sweep must WARN and continue — never abort the boot, and never leave a + # foreign-owned file locked down. A hive that boots degraded and says so + # beats one that will not boot. + HIVE_DATA_ROOT_PHASE_PATHS="/proc/1/mem-does-not-exist +$tmpd/home" + if hive_sweep_root_phase_paths >/dev/null 2>&1; then + ok "the sweep fails open (returns success when a path cannot be chowned)" + else + bad "the sweep returned non-zero" \ + "under 'set -e' in the entrypoint this aborts the boot — chown must fail open (#5368)" + fi + + rm -rf "$tmpd" + trap - EXIT +fi + +echo +echo "=== $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ] diff --git a/src/deploy/test_entrypoint_runtime_config.sh b/src/deploy/test_entrypoint_runtime_config.sh index ca46ca1b4..c35974d63 100755 --- a/src/deploy/test_entrypoint_runtime_config.sh +++ b/src/deploy/test_entrypoint_runtime_config.sh @@ -31,6 +31,34 @@ check() { fi } +# ── Skipping is a result, and where it is wrong it must be fatal (#5380) ── +# +# The behavioural block below needs root and a `dev` account. On a bare +# ubuntu-latest runner or a laptop it has neither, so it skips LOUDLY rather +# than faking a pass — that stays, and this suite remains runnable anywhere. +# +# But a loud skip that nothing acts on is still a guard that cannot fail, and +# that is #5380: the assertions which would catch a regression never executed +# on any PR. So when the caller KNOWS the preconditions are met — the podman +# arm64 lane runs this inside the image, as root, where `dev` exists — it sets +# HIVE_TEST_REQUIRE_BEHAVIOURAL=1 and a skip becomes a FAILURE. There, a skip +# does not mean "unsuitable environment", it means the test is broken. +REQUIRE_BEHAVIOURAL="${HIVE_TEST_REQUIRE_BEHAVIOURAL:-0}" + +skip() { + if [ "$REQUIRE_BEHAVIOURAL" = "1" ]; then + echo " FAIL: $1" + echo " HIVE_TEST_REQUIRE_BEHAVIOURAL=1 — the caller asserts root and a" + echo " 'dev' account are present, so this is a BROKEN TEST, not an" + echo " unsuitable environment (#5380)." + FAIL=$((FAIL + 1)) + else + echo " SKIP: $1" + [ -n "${2:-}" ] && echo " $2" + fi + return 0 +} + echo "=== entrypoint runtime-config migration tests ===" # Extract the resolver verbatim from the entrypoint so this tests the shipped @@ -114,6 +142,120 @@ check "both PVC writes target the new name" "2" "$writes_new" writes_legacy="$(grep -c 'cp "\$HIVE_CONFIG_PATH" "\$HIVE_CONFIG_RUNTIME_LEGACY"' "$ENTRYPOINT" || true)" check "no PVC write targets the legacy name" "0" "$writes_legacy" +# ── #5360: hardening must leave the file READABLE BY THE READING USER ── +# +# The regression this closes: #5342 asserted only that the mode was 0600 and +# passed while the product was broken. 0600 is OWNER-only, the `cp` calls that +# create the file run as root, and the hive process drops to dev (uid 1001) +# before it opens the config — so a root:root 0600 file is mode-correct and +# unreadable, and startup died with `permission denied` on the arm64 lane. +# +# So the assertion is not "mode is 0600". It is "the mode is 0600 AND uid 1001 +# can actually open it" — the property the product needs, checked by really +# opening the file as that uid rather than by inspecting metadata. + +echo +echo "=== #5360: hardened config is readable by the runtime user ===" + +# Every site that creates or hardens a PVC config copy must route through the +# helper, so a new `cp` cannot reintroduce the bug by hardening inline. +inline_chmod="$(grep -cE '^[[:space:]]*chmod 600 "\$(HIVE_CONFIG_RUNTIME|_cfg)' "$ENTRYPOINT" || true)" +check "no site chmods a PVC config copy outside the helper" "0" "$inline_chmod" + +# The helper must chown, not merely chmod. A helper that only chmods is +# exactly the #5360 shape. +HARDEN="$(sed -n '/^hive_harden_runtime_config() {/,/^}/p' "$ENTRYPOINT")" +if [ -z "$HARDEN" ]; then + echo " FAIL: could not extract hive_harden_runtime_config from $ENTRYPOINT" + FAIL=$((FAIL + 1)) +elif ! printf '%s' "$HARDEN" | grep -q 'chown'; then + echo " FAIL: hive_harden_runtime_config does not chown — 0600 on a root-owned" + echo " file is unreadable to the dev uid that reads it (#5360)" + FAIL=$((FAIL + 1)) +else + echo " PASS: hive_harden_runtime_config chowns as well as chmods" + PASS=$((PASS + 1)) +fi + +# The behavioural test. Requires root (to own a file as root and then drop to +# another uid) and a uid-1001 account. CI's arm64/container lanes have both; +# a developer laptop generally has neither, so skip loudly rather than fake a +# pass — a silent skip here is how the original gap shipped. +RUNTIME_UID=1001 +if [ "$(id -u)" != "0" ]; then + skip "not root — cannot exercise the root-creates/dev-reads path" \ + "(this is the case CI must run; see #5360)" +elif ! id -u dev >/dev/null 2>&1; then + skip "no 'dev' account on this host — cannot exercise the drop" +else + tmpd="$(mktemp -d)" + trap 'rm -rf "$tmpd"' EXIT + # /data is world-traversable in the image; mirror that so the only thing + # under test is the file's own mode and ownership. + chmod 755 "$tmpd" + + target="$tmpd/hive.yaml.runtime" + # Reproduce the failing shape exactly: root creates the file 0644 (the mode + # `cp` inherits from the 0644 ConfigMap seed / bind-mounted hive.yaml). + printf 'dashboard:\n auth_token: probe-not-a-real-token\n' > "$target" + chown root:root "$target" + chmod 644 "$target" + + HIVE_RUNTIME_USER="dev" + HIVE_RUNTIME_GROUP="node" + export HIVE_RUNTIME_USER HIVE_RUNTIME_GROUP + # shellcheck disable=SC1090 + eval "$HARDEN" + hive_harden_runtime_config "$target" >/dev/null + + mode="$(stat -c '%a' "$target" 2>/dev/null)" + owner="$(stat -c '%u' "$target" 2>/dev/null)" + + # 1. Still owner-only. The security fix must not be weakened to buy back + # readability — the file holds dashboard.auth_token (#5331). + check "hardened config is still mode 0600" "600" "$mode" + + # 2. Owned by the uid that reads it. This is the half #5342 was missing. + check "hardened config is owned by the runtime uid" "$RUNTIME_UID" "$owner" + + # 3. THE ASSERTION THAT MATTERS: really open it as that uid. Mode and owner + # are metadata; this is the syscall the hive binary makes at startup, + # and it is what returned EACCES in #5360. + if su -s /bin/sh dev -c "cat '$target' >/dev/null 2>&1"; then + echo " PASS: the runtime user can actually read the hardened config" + PASS=$((PASS + 1)) + else + echo " FAIL: the runtime user CANNOT read the hardened config" + echo " this is #5360 — hive aborts with 'permission denied' at startup" + FAIL=$((FAIL + 1)) + fi + + # 4. And nobody else can. Confirms we bought readability with ownership, + # not by widening the mode. 'nobody' exists on every image variant here. + if id -u nobody >/dev/null 2>&1; then + if su -s /bin/sh nobody -c "cat '$target' >/dev/null 2>&1"; then + echo " FAIL: an unrelated uid can read the hardened config" + echo " the token in this file must not be world-readable (#5331)" + FAIL=$((FAIL + 1)) + else + echo " PASS: an unrelated uid cannot read the hardened config" + PASS=$((PASS + 1)) + fi + fi + + # 5. A file already owned by the runtime user still gets tightened. This is + # the steady state after Config.Save() and every non-root boot. + printf 'dashboard:\n auth_token: probe-not-a-real-token\n' > "$target" + chown dev:node "$target" + chmod 644 "$target" + hive_harden_runtime_config "$target" >/dev/null + check "already dev-owned config is still tightened to 0600" \ + "600" "$(stat -c '%a' "$target" 2>/dev/null)" + + rm -rf "$tmpd" + trap - EXIT +fi + echo echo "=== $PASS passed, $FAIL failed ===" [ "$FAIL" -eq 0 ] diff --git a/src/deploy/test_entrypoint_system_gitconfig.sh b/src/deploy/test_entrypoint_system_gitconfig.sh new file mode 100644 index 000000000..40125afe5 --- /dev/null +++ b/src/deploy/test_entrypoint_system_gitconfig.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Regression test for kubestellar/hive#5343 — agents cannot push because the +# git credential helper was only in /home/dev/.gitconfig. +# +# WHAT THIS ASSERTS, and why it is not a grep test: +# +# The #5343 defect was invisible to a grep test by construction. The entrypoint +# DID run `git config --global credential.https://github.com.helper ...` and it +# DID succeed — a grep for that line would have passed on the broken build. The +# defect was that `--global` is per-$HOME and the entrypoint runs with +# HOME=/home/dev, while every per-agent UID runs with a different $HOME and no +# .gitconfig of its own. There was no /etc/gitconfig, so agents resolved NO +# helper at all. +# +# So this test EXECUTES the entrypoint's system-config writer against real git, +# with $HOME pointed at an empty directory — the faithful stand-in for an agent +# UID — and asserts that git still resolves the helper. That is the invariant: +# not "the config was written", but "an agent can reach it". +# +# Run: bash src/deploy/test_entrypoint_system_gitconfig.sh +set -uo pipefail + +PASS=0 +FAIL=0 + +pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $1"; [ -n "${2:-}" ] && echo " $2"; FAIL=$((FAIL + 1)); } + +ENTRYPOINT="$(cd "$(dirname "$0")" && pwd)/entrypoint.sh" +HELPER_PATH="/usr/local/bin/git-credential-hive.sh" + +echo "=== Entrypoint system gitconfig (#5343) ===" + +if ! command -v git >/dev/null 2>&1; then + echo " SKIP: git not available" + exit 0 +fi + +TMP="$(mktemp -d)" +cleanup() { rm -rf "$TMP"; } +trap cleanup EXIT + +# ── Extract the two functions under test out of the entrypoint and source them +# in isolation. Running the whole entrypoint is not possible (it drops +# privileges and execs the hive binary); running the real function is the +# entire point, so we take the function bodies verbatim rather than +# reimplementing them here — a reimplementation could not catch a drift bug. +sed -n '/^hive_ghe_git_host() {/,/^}/p;/^hive_write_system_gitconfig() {/,/^}/p' \ + "$ENTRYPOINT" > "$TMP/funcs.sh" + +if grep -q '^hive_write_system_gitconfig() {' "$TMP/funcs.sh" \ + && grep -q '^hive_ghe_git_host() {' "$TMP/funcs.sh"; then + pass "entrypoint defines hive_ghe_git_host and hive_write_system_gitconfig" +else + fail "entrypoint defines hive_ghe_git_host and hive_write_system_gitconfig" \ + "extraction from $ENTRYPOINT produced no matching function" + echo "=== $PASS passed, $FAIL failed ===" + exit 1 +fi + +# The writer targets the literal path /etc/gitconfig, which a test must not +# touch. Redirect it to a temp path by overriding the one variable it uses. +# shellcheck disable=SC1090 +. "$TMP/funcs.sh" + +run_writer() { + # $1 = destination path, $2 = optional hive.yaml. HIVE_SYSTEM_GITCONFIG is + # the writer's own test seam, so the REAL function runs — no reimplementation + # that could drift from the code being tested. + HIVE_SYSTEM_GITCONFIG="$1" HIVE_CONFIG="${2:-}" \ + bash -c ". '$TMP/funcs.sh'; hive_write_system_gitconfig" >/dev/null 2>&1 +} + +# ── Case 1: plain github.com hive (no GHE host configured) ───────────────── +SYS1="$TMP/gitconfig-plain" +run_writer "$SYS1" + +if [ -f "$SYS1" ]; then + pass "writer produced a system gitconfig" +else + fail "writer produced a system gitconfig" "no file at $SYS1" + echo "=== $PASS passed, $FAIL failed ===" + exit 1 +fi + +# THE INVARIANT: an empty $HOME (an agent UID) still resolves the helper. +AGENT_HOME="$TMP/agent-home" +mkdir -p "$AGENT_HOME" + +resolved="$(HOME="$AGENT_HOME" XDG_CONFIG_HOME="$AGENT_HOME" GIT_CONFIG_SYSTEM="$SYS1" \ + git config --get-urlmatch credential.helper https://github.com/o/r 2>/dev/null)" +if [ "$resolved" = "$HELPER_PATH" ]; then + pass "agent UID with an empty \$HOME resolves the helper for github.com" +else + fail "agent UID with an empty \$HOME resolves the helper for github.com" \ + "got '${resolved:-}', want '$HELPER_PATH'" +fi + +# The file must be parseable by git at all — a malformed system config makes +# EVERY git invocation in the container fail, which would be far worse than +# the bug being fixed. +if HOME="$AGENT_HOME" GIT_CONFIG_SYSTEM="$SYS1" git config --list >/dev/null 2>&1; then + pass "generated system gitconfig parses cleanly" +else + fail "generated system gitconfig parses cleanly" "git config --list rejected $SYS1" +fi + +# Identity must be present system-wide too: an agent whose $HOME has no +# .gitconfig cannot commit without user.name/user.email either. +name="$(HOME="$AGENT_HOME" XDG_CONFIG_HOME="$AGENT_HOME" GIT_CONFIG_SYSTEM="$SYS1" \ + git config --get user.name 2>/dev/null)" +if [ -n "$name" ]; then + pass "git identity is set system-wide (user.name='$name')" +else + fail "git identity is set system-wide" "user.name unset with an empty \$HOME" +fi + +# NO SECRET. The file names a helper; the helper mints the token. A token +# committed here would be readable by every UID in the container. +if grep -qiE 'ghs_|ghp_|github_pat_|BEGIN [A-Z ]*PRIVATE KEY|password[[:space:]]*=' "$SYS1"; then + fail "system gitconfig contains no credential material" \ + "a token-shaped or password entry appears in the generated file" +else + pass "system gitconfig contains no credential material" +fi + +# ── Case 2: GHE hive — the helper must be wired for the enterprise host too. +if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' 2>/dev/null; then + cat > "$TMP/hive.yaml" <<'YAML' +github: + base_url: https://github.example-enterprise.com +YAML + SYS2="$TMP/gitconfig-ghe" + run_writer "$SYS2" "$TMP/hive.yaml" + + ghe_resolved="$(HOME="$AGENT_HOME" XDG_CONFIG_HOME="$AGENT_HOME" GIT_CONFIG_SYSTEM="$SYS2" \ + git config --get-urlmatch credential.helper \ + https://github.example-enterprise.com/o/r 2>/dev/null)" + if [ "$ghe_resolved" = "$HELPER_PATH" ]; then + pass "agent UID resolves the helper for the configured GHE host" + else + fail "agent UID resolves the helper for the configured GHE host" \ + "got '${ghe_resolved:-}', want '$HELPER_PATH'" + fi + + # github.com must keep working on a GHE hive (the App may still reach it). + gh_resolved="$(HOME="$AGENT_HOME" XDG_CONFIG_HOME="$AGENT_HOME" GIT_CONFIG_SYSTEM="$SYS2" \ + git config --get-urlmatch credential.helper https://github.com/o/r 2>/dev/null)" + if [ "$gh_resolved" = "$HELPER_PATH" ]; then + pass "GHE hive still wires github.com as well" + else + fail "GHE hive still wires github.com as well" \ + "got '${gh_resolved:-}', want '$HELPER_PATH'" + fi +else + echo " SKIP: python3+pyyaml unavailable — GHE host derivation not exercised" +fi + +# ── Case 3: the writer must be invoked from the ROOT phase. /etc is root-owned; +# calling it after the drop to dev would silently fail to write and leave every +# agent exactly as broken as before. Assert the call site precedes the exec. +root_call_line="$(grep -n '^ hive_write_system_gitconfig' "$ENTRYPOINT" | head -1 | cut -d: -f1)" +drop_line="$(grep -n 'exec gosu dev' "$ENTRYPOINT" | head -1 | cut -d: -f1)" +if [ -n "$root_call_line" ] && [ -n "$drop_line" ] && [ "$root_call_line" -lt "$drop_line" ]; then + pass "hive_write_system_gitconfig runs in the root phase, before the drop to dev" +else + fail "hive_write_system_gitconfig runs in the root phase, before the drop to dev" \ + "call at line ${root_call_line:-}, privilege drop at line ${drop_line:-}" +fi + +# ── Case 4: the dev-phase global config must NOT have been removed. The +# contributor-relay / local-mode paths and interactive shells read it, and a +# non-root boot never reaches the system writer at all. +if grep -q 'git config --global --replace-all "credential.https://github.com.helper"' "$ENTRYPOINT"; then + pass "dev-user global credential wiring is retained (contributor-relay / non-root boot)" +else + fail "dev-user global credential wiring is retained (contributor-relay / non-root boot)" \ + "the --global helper line is gone; local-mode and non-root boots lose their credential" +fi + +echo "=== $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ] diff --git a/src/deploy/test_quadlet_config_contract.sh b/src/deploy/test_quadlet_config_contract.sh index cd41ad62b..cbdbec8f6 100755 --- a/src/deploy/test_quadlet_config_contract.sh +++ b/src/deploy/test_quadlet_config_contract.sh @@ -327,7 +327,14 @@ fi # And the gateway must still carry the stop-direction half, or stopping hive # would leave an orphaned gateway serving 502s. -if [[ -f "${ROOT}/${GATEWAY_UNIT}" ]]; then +# +# need_file, not a bare `[[ -f ]]`: GATEWAY_UNIT is not covered by need_file +# anywhere else in this file (UNIT is, in sections 1-4), so a bare existence +# test made this the one section that could vanish silently. Deleting +# hive-gateway.container dropped the run from 14 assertions to 13 and still +# exited 0 — the guard reported PASS for a repo with no gateway unit at all +# (#5388). need_file turns that into a failure. +if need_file "${GATEWAY_UNIT}"; then if grep -qE '^Requires=hive\.service[[:space:]]*$' "${ROOT}/${GATEWAY_UNIT}" \ && grep -qE '^After=hive\.service[[:space:]]*$' "${ROOT}/${GATEWAY_UNIT}"; then ok diff --git a/src/docs/README.md b/src/docs/README.md index edbb7422d..184fc14f7 100644 --- a/src/docs/README.md +++ b/src/docs/README.md @@ -26,12 +26,15 @@ Start with [Architecture](architecture.md) for the system overview, then use the - [Audit log format](audit-log.md) — the JSONL schema of `/data/audit.jsonl`: the five fields, how to parse the flat `detail` string (and why `repo` is not first-class), the pseudo-users, and why size-triggered rotation means the effective lookback varies per hive rather than being 90 days. - [Delegation chains](delegation-chain.md) — the cryptographically verifiable record of which authorizations composed to produce an action: the RFC 8693-shaped `act` nesting, the five identity situations and their chain shapes, why a root is never fabricated, how a tenant verifies independently against the anonymously-published Ed25519 keys with no hive credentials, and the rotation story. **Observe-only** — chains are minted and published but gate nothing, and enforcement is a separate future decision. - [`hive-open-pr`](hive-open-pr.md) — how agents open pull requests as the App bot instead of via `gh pr create`: the flags, the UID-ownership anchor that makes the request forge-resistant, and the asynchronous contract (exit `0` means requested, not opened). +- [`hive-merge`](hive-merge.md) — how agents merge pull requests as the App bot instead of the GitHub MCP `merge_pull_request` tool: the flags, the F4 target-binding (pinned head SHA + governor merge-eligible list), and the retry/re-engagement behavior when required checks are still red. +- [`hive-open-issue`](hive-open-issue.md) — how agents create issues, post comments, and claim issues as the App bot instead of `gh issue create`/`gh issue comment`: the three request shapes, exact-title dedupe, and the exponential-backoff retry contract. - [Network and port requirements](https://github.com/kubestellar/hive/blob/v4/src/docs/network-requirements.md) — inbound ports, proxy paths, egress, and firewall guidance. - [TLS, HTTPS, and certificates](https://github.com/kubestellar/hive/blob/v4/src/docs/tls-setup.md) — termination patterns and certificate ownership. - [Security notes](https://github.com/kubestellar/hive/blob/v4/src/docs/security.md) — log scrubbing and secret redaction guarantees/limits. - [Token collection and usage tracking](https://github.com/kubestellar/hive/blob/v4/src/docs/token-tracking.md) — session JSONL, `/api/cost`, and hub usage rollups. - [Notifications](https://github.com/kubestellar/hive/blob/v4/src/docs/notifications.md) — ntfy, Slack, and Discord alert channels, plus the two-way [Discord bot](https://github.com/kubestellar/hive/blob/v4/discord/README.md). - [State-triggered hooks](hooks.md) — declarative `transition → action` rules, the transition catalog, the vetted action set, and the security model (RFC #4001). +- [CEL-based agent triggers](cel-triggers.md) — the `triggers:` config key: declarative CEL rules that kick an agent on a normalized source-control event, additive to built-in label/governor triggering, the `event.*` field reference, and the fail-closed compile/runtime contract. - [Public snapshots](https://github.com/kubestellar/hive/blob/v4/src/docs/snapshots.md) — read-only `/snapshot`, custom CSS, and frame-ancestor sharing. - [hivectl](hivectl.md) — command-line client for the dashboard API. - [`bd` beads CLI](https://github.com/kubestellar/hive/blob/v4/src/docs/beads-cli.md) — work-ledger and knowledge command reference for operators and contributors. @@ -59,14 +62,17 @@ Start with [Architecture](architecture.md) for the system overview, then use the - [Advisory digest staleness](advisory-staleness.md) — when the hub raises the stale-advisory pill and alert, the gates that deliberately suppress it (undelivered App, App cannot write, all agents quiet), and the admin diagnostics that measure hidden staleness. - [Governor mode thresholds](https://github.com/kubestellar/hive/blob/v4/src/docs/governor-thresholds.md) — how idle/quiet/busy/surge thresholds scale with repo count, the `threshold_scaling` curves, and when explicit thresholds win. - [Supervisor agent](https://github.com/kubestellar/hive/blob/v4/src/docs/supervisor.md) — supervisor policy modes, bead roles, and when to enable the orchestration lane. +- [Telemetry agent](https://github.com/kubestellar/hive/blob/v4/src/docs/telemetry.md) — the L5/L6-only opt-in observability agent, ACMM level gating, and the `project_observability` opt-in flow. +- [Operations agent](https://github.com/kubestellar/hive/blob/v4/src/docs/operations.md) — the L5/L6-only opt-in operational-readiness agent (health checks, SLOs, runbooks), ACMM level gating, and the `project_observability` opt-in flow. - [Custom dashboard stylesheets](https://github.com/kubestellar/hive/blob/v4/src/docs/custom-stylesheets.md) — operator-supplied CSS for the dashboard and public snapshot. - [Portable AgentDefinition format](https://github.com/kubestellar/hive/blob/v4/src/AGENT-DEFINITION.md) — standalone YAML schema for importing/exporting agent definitions. - [Knowledge curator](https://github.com/kubestellar/hive/blob/v4/src/docs/knowledge-curator.md) — automatic fact extraction and promotion knobs, plus `knowledge.git_sources`: indexing a remote repo, layer semantics, private-repo auth (unsupported), and diagnosing a failed source. - [Skill registry](skills.md) — the `/data/skills/` file format and front-matter fields. **Loaded and counted on the dashboard, but not yet delivered to agents**: populating it changes no agent's behaviour today. Use the knowledge curator for knowledge that actually reaches agents. -- [AGENTS.md repo instructions](agents-md.md) — the per-repo `AGENTS.md` file format Hive's parser (`pkg/agentsmd`) understands, including front-matter `skills:` and inline `## Skill:` sections. **Parsed and tested, but not wired into kicks**: the one call site's repo-root lookup unconditionally returns empty, so an `AGENTS.md` you add today has no effect on any agent's prompt. +- [AGENTS.md repo instructions](agents-md.md) — the per-repo `AGENTS.md` file format Hive's parser (`pkg/agentsmd`) understands, including front-matter `skills:` and inline `## Skill:` sections. **Wired into kicks, but needs a checkout**: Hive agents keep no clones, so set `project.checkouts_dir` to a directory holding one checkout per repo. Without it there is no root to read and injection stays a no-op, which is the default. - [Agent peer-awareness logging (pluk)](https://github.com/kubestellar/hive/blob/v4/src/docs/agent-logging.md) — pluk log format, `hive-panes`, availability, and retention. - [Strategy Lab (Nous)](https://github.com/kubestellar/hive/blob/v4/src/docs/strategy-lab.md) — experiment lifecycle, dashboard/API configuration, fast-fail bounds, and the gate-decision flow. No `nous:` block in `hive.yaml`. - [GitHub App setup](https://github.com/kubestellar/hive/blob/v4/src/docs/github-app-setup.md) — the Forge App on GitHub and GitHub Enterprise: app creation, permissions, Setup URL, and `/gh-setup`. +- [Forge setup: GitLab, Gitea, and Forgejo](forge-app-setup.md) — the non-GitHub forges. **Adapters exist and are tested, but are not wired into any running code path**: a hive cannot run against GitLab, Gitea, or Forgejo today, and `project.forge` only changes what the dashboard displays. Covers the `gitlab:`/`gitea:` config surface that does parse, why the `gh`-CLI agent path is GitHub-only, and how `project.forge` differs from `github.forge`. - [ACMM policy matrix](acmm-policy-matrix.md) — capability levels and policy modes. - [ACMM level-up advisor](acmm-advisor.md) — the advisory-only `pkg/acmmadvisor` computation behind `GET /api/acmm-recommendation`: the signals it measures, per-level thresholds, and why it never changes the applied level. - [Inception](https://github.com/kubestellar/hive/blob/v4/src/docs/inception.md) — operator guide to the L1 brainstorm/inception workflow: phases, API, and template variables. diff --git a/src/docs/acmm-advisor.md b/src/docs/acmm-advisor.md index fedf5a103..faed52105 100644 --- a/src/docs/acmm-advisor.md +++ b/src/docs/acmm-advisor.md @@ -12,7 +12,7 @@ The advisor evaluates a fixed set of six signals (`Signals` struct, `src/pkg/acm |---|---| | `CurrentLevel` | The ACMM level the hive is applying right now (1–6). | | `CoveragePct` | Current test-coverage percentage (0–100). | -| `GreenStreak` | Count of consecutive green CI runs with no red. **Not yet measured — always zero.** See below. | +| `GreenStreak` | Count of consecutive green CI runs with no red, measured from default-branch Actions history. See below. | | `MergeSuccessRate` | Fraction (0.0–1.0) of recent PRs that merged cleanly. | | `ActionableIssues` | Count of open actionable issues the hive has surfaced but not yet resolved. | | `HoldCount` | Count of open PRs still carrying a `hold` label awaiting human review. | @@ -28,7 +28,7 @@ The dashboard assembles `Signals` for the running hive in `buildACMMStatusInputs - `MergeSuccessRate` is read from the fleet-stats collector's cached 90-day merged/rejected counts (`mergeSuccessRateFromFleetStats`, `src/pkg/dashboard/api_acmm_recommendation.go:135-141`) — no fresh GitHub call is made on the request path. - `ActionableIssues` and `HoldCount` come from the most recent published status snapshot (`status.Governor.Issues`, `status.Hold.Total`). - `CoveragePct` is read from `status.AgentMetrics["ci-maintainer"]["coverage"]`, the value the coverage badge collector populates (`coverageFromAgentMetrics`, `src/pkg/dashboard/api_acmm_recommendation.go:148-166`). -- `GreenStreak` is **always zero** — the hive does not yet track a real green-CI streak as a first-class signal. The code comment is explicit that this must never be fabricated (`src/pkg/dashboard/api_acmm_recommendation.go:52-59`), and a zero streak only ever makes the advisor *more* conservative (it will never propose a raise on the strength of a made-up streak). +- `GreenStreak` is the count of consecutive non-red completed runs on the primary repo's **default branch**, counting back from the most recent run (`Client.GreenCIStreak`, `src/pkg/github/health.go`). It is measured on the status-build path — the same pass that already fetches workflow health — and cached, so no fresh GitHub call is made on the request path. When no measurement has ever succeeded (no GitHub client, an Actions API failure, or a repo with **no CI history at all**) the signal stays at zero and reads as *unknown / not yet earned* rather than as a measured zero — a repo with no CI must never read as green. A failed refresh leaves the last real reading in place rather than clobbering it with an unknown. All of the above is nil-safe: a freshly-booted hive with no config, no status snapshot yet, or a nil fleet-stats collector collapses to conservative zero-value signals rather than panicking or fabricating data (comment at `src/pkg/dashboard/api_acmm_recommendation.go:41-45`, confirmed by `TestHandleACMMRecommendationEmpty` in `src/pkg/dashboard/api_acmm_recommendation_test.go`). @@ -61,10 +61,10 @@ A recommendation (`Recommendation` struct, `src/pkg/acmmadvisor/acmmadvisor.go:1 ## What this does NOT do - **It never changes the applied ACMM level.** The package comment states this explicitly: "This package is ADVISORY ONLY. It never changes the applied ACMM level... A human always approves the actual level change." (`src/pkg/acmmadvisor/acmmadvisor.go:12-17`). The only code path that changes a hive's level is `PUT /api/packs/level` → `handlePackSetLevel` → `ApplyPack`, documented in [ACMM policy matrix — Changing a hive's ACMM level](acmm-policy-matrix.md#changing-a-hives-acmm-level). Nothing in `pkg/acmmadvisor` or the `/api/acmm-recommendation` handler calls that path. -- **It is a pure function with no I/O.** `Recommend` and `RecommendFromStatus` take already-collected signals and return a value; they do not read config, call GitHub, or touch the clock (`src/pkg/acmmadvisor/wire.go:1-12`). -- **It is not currently rendered anywhere in the dashboard UI.** As of this writing there are no references to `acmm-recommendation` or the advisor under `src/dashboard`. The only way to obtain a recommendation today is to call the API endpoint directly (see below); there is no dashboard pill, tab, or card that displays it. A `TODO(acmm2-wiring)` comment in `src/pkg/acmmadvisor/wire.go` describes wiring `RecommendFromStatus` into the status payload as still-outstanding follow-up work — treat any documentation or issue text implying the dashboard already surfaces a recommendation pill as **not yet true**. +- **It is a pure function with no I/O.** `Recommend` and `RecommendFromStatus` take already-collected signals and return a value; they do not read config, call GitHub, or touch the clock. Signal *collection* does talk to GitHub, but only on the status-build path, and its results are cached — neither the API endpoint nor the status payload triggers a GitHub call of its own. +- **It never auto-applies a recommendation.** The recommendation now travels on the status payload as `acmmAdvice` (#5225) in addition to the API endpoint, but both are read-only advice. Nothing consumes them to change a level. - **Thresholds are not configurable.** All coverage/streak/rate/backlog numbers are Go constants in `pkg/acmmadvisor`; there is no `hive.yaml` field or environment variable to adjust them. -- **`GreenStreak` is not measured yet, and its zero is deliberate rather than a bug.** The hive does not track a real green-CI streak, and the code refuses to fabricate one. Because a zero streak can only ever *withhold* a recommendation, the advisor stays conservative rather than proposing a raise on a fabricated signal — the safe direction to fail in. The practical effect is that any criterion gated on streak thresholds (L3 and above) will not pass until that wiring lands, so the advisor currently under-recommends rather than over-recommends. +- **It does not measure streak quality, only streak length.** `GreenStreak` resets on **any** red, including a flake unrelated to code quality, and a repo that rarely commits can hold a stale streak indefinitely because the signal is count-based rather than time-based. The scan also reads at most one page of runs (`ciStreakRunsLimit`), so a very long streak is reported as "at least N" — the cap sits above every advisor threshold, so it can only ever under-report. ## The API endpoint @@ -72,5 +72,5 @@ A recommendation (`Recommendation` struct, `src/pkg/acmmadvisor/acmmadvisor.go:1 ## Open questions -- The exact date/PR that will wire `RecommendFromStatus` into the dashboard status payload and UI is not yet known — `src/pkg/acmmadvisor/wire.go` marks it `TODO(acmm2-wiring)` with no linked issue found in this repo at the time of writing. -- Whether `GreenStreak` will be sourced from CI history or another signal once implemented is left open by the `TODO(acmm-signals)` comment in `src/pkg/dashboard/api_acmm_recommendation.go:58-59`. +- Whether the streak should become time-based ("days since last red") rather than count-based is unresolved. A count-based streak lets a quiet repo hold a stale value; a time-based one would change the meaning of the existing `greenStreakL3..L6` thresholds, so it is deliberately not attempted here. +- Whether a "green" streak should require the run to have actually executed gates is unresolved: `skipped` runs are currently transparent to the streak (matching `ciPassRate`), so a repo whose workflows are entirely skipped on the default branch could accumulate a streak without any gate ever running. diff --git a/src/docs/adr/0003-acmm-autonomy-levels.md b/src/docs/adr/0003-acmm-autonomy-levels.md index ef67761a3..af7b719f4 100644 --- a/src/docs/adr/0003-acmm-autonomy-levels.md +++ b/src/docs/adr/0003-acmm-autonomy-levels.md @@ -8,7 +8,7 @@ Hive needs one operator-facing control for how autonomous agents may be. The reference architecture describes ACMM as the human-selected dial that maps to per-agent modes, and the policy matrix defines which agents may only advise, file issues, open hold-gated PRs, or auto-merge at each level -([architecture §6](../architecture.md#6-acmm-controlling-agent-autonomy), +([architecture §6](../architecture.md#6-acmm--controlling-agent-autonomy), [ACMM policy matrix](../acmm-policy-matrix.md)). The same modes feed the layered guardrails in [architecture §5](../architecture.md#5-layered-guardrails-defense-in-depth). diff --git a/src/docs/adr/0004-beads-work-ledger.md b/src/docs/adr/0004-beads-work-ledger.md index 574881f1b..54f69eb35 100644 --- a/src/docs/adr/0004-beads-work-ledger.md +++ b/src/docs/adr/0004-beads-work-ledger.md @@ -7,7 +7,7 @@ Status: Accepted (retroactive) Hive agents need durable coordination state that is separate from GitHub's issue queue. The reference architecture describes beads as a git-backed JSON ledger per agent, with typed work items, priorities, dependencies, metadata, and -actor-scoped ready queues ([architecture §7](../architecture.md#7-beads-the-work-ledger)). +actor-scoped ready queues ([architecture §7](../architecture.md#7-beads--the-work-ledger)). The deterministic pipeline can turn GitHub work into internal artifacts before agents act ([architecture §4](../architecture.md#4-the-deterministic-pipeline)). diff --git a/src/docs/advisory.md b/src/docs/advisory.md index e6e57d1a5..e0462ac90 100644 --- a/src/docs/advisory.md +++ b/src/docs/advisory.md @@ -72,6 +72,70 @@ the staleness window. - The close is recorded as `close_reason: auto-closed: a merged pull request addresses this finding`. +### Evidence provenance + +The digest footer stamps one commit — `Analyzed at owner/repo@` — across +every finding it renders. That stamp describes when the digest was *built*, not +when each finding's evidence was *computed*, and the two drift apart: findings +persist as open beads and are re-rendered verbatim every cycle, so a finding +whose evidence was gathered several commits ago is republished under today's +commit without anything re-checking it. + +That drift is not just noise. A finding that was accurate when computed, then +fixed in the target repo, kept appearing under a commit at which it no longer +reproduced — and a re-verifying agent that checked it against the stamped commit +concluded the evidence had been fabricated when it was merely stale (#5130). + +So the digest now tracks the commit a finding's evidence came from, and captions +any finding that was **not** computed at the analyzed commit: + +> - **[coverage-gap]** contrib/aib has no test coverage ⚠️ _(evidence computed at +> `c9546a8`, not re-verified at the analyzed commit)_ _quality_ + +The digest cannot re-run a finding's own evidence — that evidence is arbitrary +(a `grep`, a workflow-file read, a coverage run), and a coverage-specific refresh +would still republish the rest. What it can do is stop asserting a freshness it +never checked, so the caption says exactly that and the footer no longer implies +otherwise. + +A finding's provenance commit is read from, in order: + +1. `provenance_sha` in the finding's advisory JSONL, or the same key in the + bead's metadata. This is the **explicit** form, and the one to prefer. +2. The finding's own prose, when it already names its provenance — wordings like + `revision `, `commit `, `computed at ` or `as of ` are + recognised. A bare hex run with no such keyword is never read as a commit: + log ids and digests appear in finding text far too often. + +A finding that names no provenance at all is left **unmarked**. Silence about +provenance is not a freshness claim in either direction, so the digest neither +captions it nor implies it was verified. + +#### Provenance and the staleness clock + +Provenance also fixes a hole in [staleness auto-close](#staleness-auto-close). +The re-report of a finding is what refreshes its `last_seen_at`, on the reasoning +that an agent only re-files a finding while its condition holds. But agents +re-report from **cached prior findings**, not from re-verification — so a +finding that had already been fixed kept re-stamping itself and survived every +prune window. + +A re-report that carries the **same** `provenance_sha` the bead already records +is therefore a restatement of evidence computed once, not fresh confirmation +that the condition still holds. It no longer refreshes `last_seen_at`, so the +staleness clock keeps running and `staleness_days` retires the finding on the +normal schedule. A re-report computed at a *different* commit is a genuine +re-check: it refreshes the stamp and records its new provenance. + +Two deliberate limits keep this from retiring findings that still hold: + +- Only an **explicit** `provenance_sha` gates the refresh, never the prose- + inferred one. Misreading "regressed in commit ``" as provenance would age + out a live finding, so inference is trusted for captions only. +- A finding that records no provenance behaves exactly as it did before — every + re-report still counts as a confirmation. Nothing starts ageing out merely + because its producer does not report a commit. + ## How much the digest shows By default the digest renders the **top 10** findings, ranked by severity diff --git a/src/docs/agent-configuration.md b/src/docs/agent-configuration.md index 29a4bbd82..b9d7580fd 100644 --- a/src/docs/agent-configuration.md +++ b/src/docs/agent-configuration.md @@ -31,7 +31,7 @@ You almost never write a full roster by hand: applying an ACMM level (below) gen Hive's config is layered, and the layering is the point: **a file's location says who owns the setting.** ``` -/etc/hive/hive.yaml ← ConfigMap seed (Kubernetes) or bind mount (Docker/LXC). +/etc/hive/hive.yaml ← ConfigMap seed (Kubernetes) or bind mount (Docker, Podman, LXC). │ The operator/platform layer. Re-seeded on every pod │ boot; authoritative for acmm_level and hub.is_public. ├── /data/hive.yaml.dashboard ← Dashboard overlay on the PVC. Every save from the @@ -43,7 +43,7 @@ Hive's config is layered, and the layering is the point: **a file's location say │ Merged over the agents: map at load time. ├── /data/hive.yaml.runtime ← Persisted runtime config (was hive.yaml.bak). Never │ edit. On K8s a post-merge snapshot the entrypoint -│ restores from if the seed is lost; on Docker/LXC the +│ restores from if the seed is lost; outside Kubernetes the │ boot-time source of truth. The legacy name is still │ read as a fallback during the migration. ├── /data/secrets/ ← Secret VALUES written by the dashboard (writable PVC). @@ -332,6 +332,8 @@ Two rules of thumb: - **CLI methods are subscriptions.** You log in once per method from the dashboard, and every agent using that method shares the login. For `claude`, sharing is not instantaneous: the OAuth token is shared immediately through the per-agent home bridge, while the session identity (`~/.claude.json`, which is what decides whether the CLI shows a login menu) is adopted from an already-signed-in agent the next time each other agent launches or is restarted. So on a fresh install, expect the remaining agents to clear their 🔑 badge on their next start rather than the moment you finish logging in. - **Inference methods are endpoints.** You configure a base URL and a key *reference* (env var name or key-file path — the value goes in `/data/secrets/`, never in YAML). Agents on `vllm`/`llm-d`/`litellm` launch the Claude CLI routed through hive's inference translator, so there is no separate login. + Only `POST /v1/messages` is translated into an OpenAI `/v1/chat/completions` call. The Claude CLI also talks to its Anthropic host for housekeeping — telemetry batches (`/api/event_logging/...`), error reports, `POST /v1/messages/count_tokens` — and none of that has a meaning to an OpenAI-compatible gateway; forwarding it used to cost a gateway `400 Missing required parameter: 'messages'` per call, charged against the provider's request rate limit (roughly two failures per real completion in practice). The translator and the MITM reroute now answer those locally: `count_tokens` returns a chars-based estimate, anything under `/api/` returns `{}`, and any other path is a 404 in Anthropic error shape with a `WARN` log line naming the method and path, so a new CLI endpoint shows up in the hive log rather than as gateway noise. Inference-routed `claude` sessions are additionally launched with `DISABLE_TELEMETRY=1`, `DISABLE_ERROR_REPORTING=1`, and `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`; subscription sessions are not. + Every Model Gateway (and the bob backend) also accepts an optional `key_name` — a human-chosen LABEL for the configured key, e.g. `key_name: openrouter-prod-key`. It is safe-to-show metadata, not a secret: the dashboard's gateway row displays it as "Using key: ``", or "(unnamed)" when no label is set, so operators can tell keys apart without ever seeing the value. See [`inference-backends.md`](../../docs/inference-backends.md) for a full YAML example. Kubernetes manifests for deploying inference backends (vllm Deployment, EPP RBAC, kustomization) are in [`deploy/inference/`](../deploy/inference/). @@ -385,6 +387,12 @@ governor: Set `stale_timeout` with your cadences in mind: an agent kicked every 4h with a 30-minute stale timeout will look dead between kicks. The shipped packs use "longest cadence × 2". +Cadences are not the only way an agent gets kicked: the additive `triggers:` +config key declares CEL rules that kick a named agent directly off a +source-control event (issue opened, PR opened, a label applied, a comment +posted), independent of the governor's queue-depth cadence. See +[CEL-based agent triggers](cel-triggers.md). + ## ACMM levels: agent rosters as packs You don't have to design a roster. Hive ships six **ACMM packs** (`level-1.yaml` … `level-6.yaml`, embedded in the binary and forkable) that pair a curated agent roster with governor cadences and a merge policy: @@ -528,7 +536,7 @@ This is the property operators most need to understand before enabling the featu - A dashboard save cannot turn `allow_github_prompt` on if the seed has it off. - A dashboard save cannot add a repo slug to `github_prompt_allowlist`. -- A compromised or malicious dashboard overlay can neither widen the set of readable repos nor repoint an agent's `definition_source` at an arbitrary repo — only a seed edit (ConfigMap in Kubernetes, bind-mounted file in Docker/LXC) can do either. +- A compromised or malicious dashboard overlay can neither widen the set of readable repos nor repoint an agent's `definition_source` at an arbitrary repo — only a seed edit (ConfigMap in Kubernetes, bind-mounted file under Docker, Podman or LXC) can do either. An empty allowlist denies every repo even with `allow_github_prompt: true` — the allowlist is required, not merely advisory. @@ -613,7 +621,7 @@ Both polarities are enforced at **enumeration** — the point where GitHub issue - **[Documentation index](README.md)** — what hive is, setup, and the full topic-guide surface. - **[Architecture](architecture.md)** — process model, deterministic pipeline, governor loop, guardrails, and hub/spoke design. - **[Portable AgentDefinition format](../AGENT-DEFINITION.md)** — standalone YAML schema for agent imports, exports, and overlays. -- **[AGENTS.md repo instructions](agents-md.md)** — the per-repo instruction file format Hive's parser understands. **Not wired into kicks today** — see the page for why. +- **[AGENTS.md repo instructions](agents-md.md)** — the per-repo instruction file format Hive's parser understands. Injected into kicks once `project.checkouts_dir` gives Hive a checkout to read it from — see the page. - **[Dashboard route and health checks](health-checks.md)** — listener probes and alert behavior for stuck sessions and restart loops. - **[Troubleshooting](troubleshooting.md)** — stuck sessions, login expiry, restart loops, and notification checks. - **[ACMM policy matrix](acmm-policy-matrix.md)** — the full per-level, per-agent policy table. diff --git a/src/docs/agents-md.md b/src/docs/agents-md.md index 876becb2d..09f7aee95 100644 --- a/src/docs/agents-md.md +++ b/src/docs/agents-md.md @@ -1,42 +1,59 @@ # AGENTS.md repo instructions -> **⚠️ Parsed and tested, but NOT wired into kicks.** Hive ships a complete -> `AGENTS.md` parser (`src/pkg/agentsmd`) and a call site that would prepend its -> output to every kick prompt — but the call site is permanently disabled. The -> single function that supplies it a repo checkout path, -> `Scheduler.agentsRepoRoot()`, unconditionally returns `""` -> (`src/pkg/scheduler/scheduler.go:1385-1390`), and `primeAgentsMd` treats an -> empty root as "nothing to inject" (`scheduler.go:1394-1396`). **An `AGENTS.md` -> file you add to a monitored repo today is never read by Hive and has no -> effect on any agent's kick prompt.** This page documents the file format Hive -> already understands, for when the wiring lands — not a feature you can rely -> on to change agent behavior now. - -## Why it doesn't run today +> **⚠️ Wired, but off unless you supply a checkout.** Hive ships a complete +> `AGENTS.md` parser (`src/pkg/agentsmd`) and a call site that prepends its +> output to every kick prompt. That call site used to be permanently disabled — +> `Scheduler.agentsRepoRoot()` returned `""` unconditionally, so no `AGENTS.md` +> was ever read. It now resolves a real per-repo checkout root +> ([#5227](https://github.com/kubestellar/hive/issues/5227)), but Hive agents +> work over the API and keep no clones, so **there is still no root unless you +> configure one**. Set `project.checkouts_dir` (below). Without it, an +> `AGENTS.md` you add to a monitored repo has no effect on any agent's prompt — +> the same as before, and still the default. + +## Turning it on Hive agents work over the GitHub API — they do not keep a local git checkout of -the repos they monitor. `agentsRepoRoot()` has no local path to hand back, so -returning `""` is the deliberate, conservative choice rather than a bug. The -comment on the function says so directly: +the repos they monitor. So the file has to already be on the hive host, and you +tell Hive where: + +```yaml +project: + org: your-org + repos: [repo-one, repo-two] + primary_repo: repo-one + checkouts_dir: /data/checkouts # repo-one is at /data/checkouts/repo-one +``` + +Each repo is looked up at `/` — the bare name from +`repos`, without the org — so a multi-repo hive gets each repo's own file and +never another repo's. The kick reads the **primary repo's** `AGENTS.md`, since +that is the repo the kick's instructions are about. + +How you populate that directory is yours: a mounted volume, a sidecar that +clones and pulls, an NFS export. Hive only reads it. -> "Hive agents operate over GitHub rather than local clones, so this is -> usually empty today; the hook exists so that when a checkout root becomes -> available (e.g. a git source's `LocalDir`) the AGENTS.md convention is -> honored without further wiring." +One other source is used when it happens to be the same repo: +`policies.local_dir`, the checkout of `policies.repo`, is a genuine checkout +root that Hive already reads policy files from. If `policies.repo` names the +repo being asked about, it supplies the root. It is ignored otherwise, so a +config repo's `AGENTS.md` never leaks into work on an unrelated repo. +`checkouts_dir` wins when both apply. -The one call site, in `Scheduler.buildKickForAgent`-adjacent code, is guarded -and additive — it only prepends when `primeAgentsMd` returns non-empty text — -and carries an explicit `TODO(agentsmd)` marking the missing piece: threading a -per-repo checkout root through, and preferring `agentsmd.ParseNearest` for -closest-wins nested files once file-level targeting exists -(`scheduler.go:242-248`). +Everything stays fail-open: an absent directory, a missing `AGENTS.md`, or a +blank one all yield no injection and never fail a kick. When nothing is +injected, the scheduler logs at debug which root came up empty — so a +wired-but-empty repo is now distinguishable from an unconfigured hive, which it +was not before. -This is the same shape as two other recently-documented gaps in Hive: -[`enqueue-approval`](https://github.com/kubestellar/hive/issues/4911) and the -[skill registry](skills.md) (`pkg/skillreg`) — a complete, tested package that -nothing in the runtime calls yet. +### Still deferred -## What Hive's parser supports (the format, once wired) +The call site keeps a `TODO(agentsmd)`, narrowed to its second half: preferring +`agentsmd.ParseNearest` for closest-wins nested `AGENTS.md`. That needs +file-level targeting — nothing on the kick path knows which *file* an agent will +touch — so `Parse` (repo root only) is what runs. + +## What Hive's parser supports `src/pkg/agentsmd` (`agentsmd.go:119`, `Parse`) reads a plain Markdown `AGENTS.md` file at a repo root, with two additive extensions on top of @@ -89,53 +106,52 @@ is the "body" — general instructions that would always apply `ParseNearest(repoRoot, relPath, logger)` (`agentsmd.go:127`) walks from a target file's directory up to the repo root and uses the first `AGENTS.md` it finds; the root file is the required baseline nested files override for their -own subtree. This exists in the package today but has no caller — the -scheduler's `TODO(agentsmd)` names it as the intended future entry point once -file-level targeting exists; the disabled call in `scheduler.go` currently -only ever calls the flat `Parse`, and even that call never runs because its -root argument is always `""`. +own subtree. This still has no caller: the scheduler's `TODO(agentsmd)` names +it as the intended entry point once file-level targeting exists, and nothing on +the kick path knows which file an agent will touch. The live call uses the flat +`Parse` against the repo root. -### Rendered injection text (if it ran) +### Rendered injection text -`AgentsConfig.InjectionText(requestedSkills)` (`agentsmd.go:330`) is what would -be prepended to a kick: a `# Repository Agent Instructions (AGENTS.md)` +`AgentsConfig.InjectionText(requestedSkills)` (`agentsmd.go:330`) is what gets +prepended to a kick: a `# Repository Agent Instructions (AGENTS.md)` header, the body, and (if any skills resolve) a `## Requested Skills` subsection with each skill's text under a `### ` heading. It returns `""` — and therefore injects nothing — when both the body and the resolved skills are empty. -## Parsing is tolerant, wiring is not the risk +## Parsing is tolerant Everything about the *parser* is designed to fail safe: a missing file yields an empty, non-nil config (`agentsmd.go:171-174`); an unreadable file logs and returns empty (`agentsmd.go:175-178`); malformed front-matter logs and falls -back to using the body alone. None of that tolerance is why the feature is -inert today — the parser is simply never invoked, because `agentsRepoRoot()` -never has anything to hand it. +back to using the body alone. Combined with the guarded call site — which +prepends only non-empty text — a bad `AGENTS.md` degrades to no injection +rather than a failed kick. ## Connection to the skill registry The `skills:` front-matter key names skills by the same kind of identifier the -[skill registry](skills.md) (`/data/skills/`, `pkg/skillreg`) would resolve — -but the two systems are **separately unwired** from each other and from the -runtime: - -- `pkg/agentsmd` can resolve a skill name to text from an inline `## Skill:` - section or a `skills/.md` file **in the same repo** — this is - self-contained and does not consult the registry at all. -- The skill registry is a **different, hive-wide** store, loaded and counted - on the dashboard but — per its own page — not delivered into any agent's - context either. - -Neither pipeline reaches a live kick prompt today. Don't read the shared -`skills:` vocabulary as evidence that authoring one wires up the other; they -are independent, parallel gaps. +[skill registry](skills.md) (`/data/skills/`, `pkg/skillreg`) resolves. There +are two request paths with different precedence: + +- `pkg/agentsmd` resolves a skill name to text from an inline `## Skill:` + section or a `skills/.md` file **in the same repo** — self-contained, + and it does not consult the registry at all. +- An agent's own `skills:` config is resolved by `pkg/skillreg`: it checks the + **hive-wide** `/data/skills/` store first, then falls back to the primary + repo's inline or adjacent definition when that repo has a configured checkout. + Registry definitions therefore retain precedence when both sources use the + same name, while a missing checkout does not affect registry-only operation. + +Both reach a live kick prompt. A repo's front-matter request stays self-contained +inside `pkg/agentsmd`; registry precedence applies to names requested by the +agent config through `pkg/skillreg.ResolveRequested`. ## What to read next -- **[Skill registry](skills.md)** — the sibling not-yet-wired feature: file - format, where it's loaded from, and what "loaded and counted" actually means - today. +- **[Skill registry](skills.md)** — the sibling injection path: file format, + where it's loaded from, and how an agent declares which skills it wants. - **[Agent configuration](agent-configuration.md)** — the `definition_source` and `channels`/`tools`/`connections` fields that *are* live. - **[ADR-0012: Skill registry](adr/0012-skill-registry.md)** — the design diff --git a/src/docs/api-reference.md b/src/docs/api-reference.md index c2edea7b7..4e94ee8a1 100644 --- a/src/docs/api-reference.md +++ b/src/docs/api-reference.md @@ -117,7 +117,8 @@ Auth levels are derived from dashboard middleware (`isPublicPath`, dashboard tok | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `POST` | `/api/kick/{agent}` | Dashboard auth/session | Kick | `pkg/dashboard/api.go:67` | +| `POST` | `/api/kick/{agent}` | Dashboard auth/session | Kick — asynchronous; answers `202` once queued (see below) | `pkg/dashboard/api.go:67` | +| `GET` | `/api/kick/{agent}/status` | Dashboard auth/session | Outcome of the most recent kick | `pkg/dashboard/api.go:71` | | `POST` | `/api/switch/{agent}/{backend}` | Dashboard auth/session | Switch | `pkg/dashboard/api.go:68` | | `POST` | `/api/model/{agent}/{model}` | Dashboard auth/session | Model Set | `pkg/dashboard/api.go:69` | | `POST` | `/api/pause/{agent}` | Dashboard auth/session | Pause | `pkg/dashboard/api.go:70` | @@ -135,6 +136,20 @@ Auth levels are derived from dashboard middleware (`isPublicPath`, dashboard tok | `POST` | `/api/agents/import` | Dashboard auth/session | Agent Import | `pkg/dashboard/api.go:162` | | `DELETE` | `/api/agents/{name}` | Dashboard auth/session | Agent Delete | `pkg/dashboard/api.go:163` | +### Kick is asynchronous + +`POST /api/kick/{agent}` queues the message and returns immediately with `202`; it is not a delivery confirmation. + +Delivery has to wait for the agent's CLI to present its input prompt, which is bounded by `inputPromptTimeout` (120s). Doing that wait on the request path made the handler outlive a typical 60s ingress idle timeout, so a proxy answered `504` for kicks that had in fact succeeded — the prompt was typed, the agent ran the session, and the operator was told it failed (kubestellar/hive#5325). Retrying a false failure delivered the prompt twice. + +The contract is now: + +- **`400`** — a genuine, deterministic precondition failure evaluated inline: unknown agent, paused, stopped, no tmux session, sandbox kick rejected, prompt over 10000 chars. +- **`202` with `status: "queued"`** — accepted; a background delivery started. +- **`202` with `status: "in-flight"`** — a delivery for this agent was already running, so this call was deduplicated. Delivery is exactly-once per agent, which is what makes an operator's retry harmless. + +Read the result from `GET /api/kick/{agent}/status`, which returns `status` of `unknown`, `in-flight`, `delivered`, or `failed`, plus a `pending` boolean. While `pending` is true the outcome is **indeterminate** — the prompt may still be delivered — and clients must not render it as a failure. A CLI that never reaches its input prompt within `inputPromptTimeout` settles as `failed` with a reason. + ## Packs and ACMM | Method | Path | Auth | Purpose | Source | diff --git a/src/docs/beads-cli.md b/src/docs/beads-cli.md index a561423c2..b37d719b6 100644 --- a/src/docs/beads-cli.md +++ b/src/docs/beads-cli.md @@ -17,6 +17,35 @@ | `bd remember "fact"` | Quick-add an advisory bead authored by `system`. | | `bd init` | No-op compatibility command; opening the store creates it. | | `bd dolt push` | No-op compatibility command; bead data is already persisted on disk. | +| `bd decompose [--plan ] [--actor ] [--auto-approve] [--print-prompt]` | Decompose an epic bead into a DAG of child task beads. See below. | + +## Decomposing an epic + +`bd decompose` turns one epic bead into a graph of child task beads, wiring +their dependencies so `bd ready` only offers a child once its predecessors +close. + +```bash +bd decompose --plan plan.txt +cat plan.txt | bd decompose # or read the plan from stdin +``` + +| Flag | Effect | +|---|---| +| `--plan ` | File holding the planner's task list. Defaults to stdin. | +| `--actor ` | Override the child beads' actor/lane. Defaults to `classifier`/`architect`. | +| `--auto-approve` | Approve the plan immediately — children are claimable at once, with no review gate. | +| `--print-prompt` | Print the architect decomposition prompt for this epic and exit without writing anything. | + +**This does not run an agent.** The task breakdown is read from `--plan` or +stdin, so the trigger stays manual and the planning package carries no +agent/tmux dependency. Wiring the architect lane's live output in place of the +file/stdin source is later-phase work — see `cmdDecompose` in +`src/cmd/bd/decompose.go`. + +Without `--auto-approve` the children land behind a review gate, so use +`--print-prompt` first when you want to see what an epic would expand into +before committing anything to the ledger. Examples: diff --git a/src/docs/cel-triggers.md b/src/docs/cel-triggers.md new file mode 100644 index 000000000..b5ff42320 --- /dev/null +++ b/src/docs/cel-triggers.md @@ -0,0 +1,189 @@ +# CEL-based agent triggers + +The `triggers:` config key lets an operator declare CEL ([Common Expression +Language](https://github.com/google/cel-spec)) rules that decide when an +agent should be kicked for an incoming event — issue opened, issue labeled, +PR opened, a comment posted, and so on — without writing code. + +```yaml +triggers: + - name: bug-triage + expr: event.kind == "issue.opened" && hasLabel(event.labels, "bug") + agent: triager + priority: 10 +``` + +This is implemented by `pkg/celtrigger`. + +## How it composes with existing triggering + +`triggers:` is **additive**. It runs *alongside* — not in place of — hive's +built-in label/governor triggering: the governor's eval cycle evaluates the +compiled rule set against each enumerated actionable item and **unions** the +matched agent names into the due-agents set, after its own pause/budget/ +on-demand gates already apply. An empty (or absent) `triggers:` list compiles +to a valid engine that never matches anything, so leaving it out is +byte-identical to today's behavior — nothing about default triggering +changes because this feature exists. + +## The `TriggerRule` schema + +Each entry under `triggers:` is one `TriggerRule` (`pkg/config/config.go`): + +| YAML field | Type | Required | Notes | +| --- | --- | --- | --- | +| `name` | string | yes (in practice) | Human-readable identifier for logs/diagnostics. Not enforced non-empty by the schema, but an empty name is rendered as `rule[]` in error messages, so name your rules. | +| `expr` | string | **yes** | The CEL expression. Must compile and must evaluate to a `bool`. An empty expression is a compile-time error. | +| `agent` | string | **yes** | The agent to trigger when `expr` evaluates `true`. A rule with an empty `agent` compiles fine but is silently skipped when collecting matched agents. | +| `priority` | int | no (default `0`) | Orders competing rules when several match the same event — higher sorts first. Ties preserve declaration order. | + +Source: `TriggerRule` struct, `pkg/config/config.go` (`Name`, `Expr`, `Agent`, +`Priority` fields with their `yaml:"..."` tags), and `pkg/celtrigger/wire.go`'s +`CompileFromConfig`, which maps each `TriggerRule` 1:1 onto a +`celtrigger.Rule`. + +## What the CEL expression binds to + +Every rule is evaluated against exactly one variable: **`event`**, a +`celtrigger.NormalizedEvent` — hive's forge-neutral representation of a +source-control event. `event` is the *only* activation exposed to the +expression; there is no other variable, no access to config, no access to +agent state. + +The reachable fields, taken from the `cel:"..."` struct tags on +`NormalizedEvent` (`pkg/celtrigger/celtrigger.go`), are: + +| CEL field | Go type | Meaning | +| --- | --- | --- | +| `event.kind` | string | Event kind — see the `Kind*` constants below. | +| `event.repo` | string | Fully-qualified repository, e.g. `"org/name"`. | +| `event.labels` | list of string | Labels on the issue/PR. | +| `event.title` | string | Issue/PR title. | +| `event.author` | string | Login/handle of the actor that produced the event. | +| `event.body` | string | Issue/PR/comment body. | +| `event.is_draft` | bool | Whether a PR is a draft. | +| `event.number` | int | Issue/PR number. | +| `event.state` | string | Current state, e.g. `"open"`/`"closed"`. | +| `event.base_branch` | string | Target branch for a PR. | +| `event.head_branch` | string | Source branch for a PR. | +| `event.assignees` | list of string | Currently-assigned logins. | +| `event.comment` | string | Body of the triggering comment (for `comment.created`). | + +Field access is type-checked at compile time against this registered native +type — an expression that references a field not in this table (e.g. +`event.does_not_exist`) is rejected at `Compile`, not left to fail silently +at runtime. + +`event.kind` takes one of these forge-neutral values (`pkg/celtrigger/celtrigger.go`): + +``` +issue.opened +issue.labeled +issue.closed +issue.reopened +pr.opened +pr.labeled +pr.ready_for_review +pr.closed +pr.merged +comment.created +``` + +### Available functions + +Besides the standard CEL string/list operators (`in`, `.contains(...)`, +`.startsWith(...)`, `.matches(...)`, `&&`, `||`, `!`, `==`, comprehensions +like `.exists(...)`/`.all(...)`), one custom helper is registered: + +- `hasLabel(event.labels, "bug")` — convenience for + `"bug" in event.labels`; returns `false` (not an error) for non-list or + non-string arguments. + +No other custom functions exist. Do not invent one — an expression calling +an unregistered function is a compile-time error. + +### Evaluation cost budget + +Each rule's evaluation is bounded (`maxEvalCost = 10_000` in +`pkg/celtrigger/celtrigger.go`) to stop pathological nested comprehensions +(e.g. a triple-nested `.all()` over labels) from burning runtime per event. +A rule that exceeds the budget is treated as **no-match** for that +evaluation (not an error, not a crash) and logs a warning naming the rule +and the budget so you can diagnose why it stopped firing. Realistic +operator rules — field checks, string predicates, a label +`.exists(...)`/`.contains(...)` — comfortably fit inside the budget. + +## Fail-closed semantics + +This is a safety property, stated plainly: + +- **Compile time (config load):** every rule's `expr` is parsed, + type-checked, and confirmed to return `bool`. If *any* rule in `triggers:` + is malformed — a syntax error, a reference to a field that doesn't exist, + a call to an unknown function, an expression that returns something other + than `bool`, or an empty expression — the **entire config load fails** + with an error. No partial engine is produced; the bad rule cannot reach a + running fleet. +- **Runtime (event evaluation):** if a compiled rule still errors while + evaluating a specific event (including exceeding the cost budget above), + that is treated as **"no match,"** not a crash and not an error + propagated to the caller. One misbehaving rule cannot take down + evaluation for the other rules or for the event pipeline. + +In short: a bad rule is caught early and loudly (at config load); a rule +that merely fails to match at runtime does so quietly and safely. + +## Worked example + +This rule set compiles and matches, verified against +`pkg/celtrigger/wire_test.go`'s `TestCompileFromConfig_ValidAndMatch`: + +```yaml +triggers: + - name: bug + expr: hasLabel(event.labels, "bug") + agent: triager + priority: 10 + - name: pr + expr: event.kind == "pr.opened" + agent: reviewer + priority: 5 +``` + +An `issue.labeled` event carrying the label `bug` matches the `bug` rule and +kicks `triager`. A more selective example, combining a kind check, a +non-draft check, and a target-branch check — verified against +`pkg/celtrigger/celtrigger_test.go`'s `TestMatch_BooleanCombos`: + +```yaml +triggers: + - name: ready-pr + expr: event.kind == "pr.opened" && !event.is_draft && event.base_branch == "main" + agent: reviewer +``` + +This matches a non-draft PR opened against `main`, and does not match a +draft PR against the same branch. + +A slash-command-style comment trigger — verified against +`TestMatch_CommentEvent`: + +```yaml +triggers: + - name: slash-cmd + expr: event.kind == "comment.created" && event.comment.startsWith("/hive") + agent: cmd +``` + +## Relationship to hook predicates (`when:`) + +[State-triggered hooks](hooks.md) use the same CEL engine and the same +fail-closed posture for their optional `when:` predicate, but bind a +different, unrelated variable (`t`, the transition payload) — see +[Predicates (`when:`)](hooks.md#predicates-when) in that doc. `triggers:` +and hooks' `when:` are two separate surfaces that happen to share an +evaluation engine: `triggers:` decides whether to *kick an agent* for a +normalized source-control event, while hooks' `when:` decides whether to +*fire an action* on a state transition. Don't mix `event.*` fields into a +hook's `when:` or `t.*` fields into a `triggers:` rule — the field sets are +disjoint and referencing the wrong one is a compile error. diff --git a/src/docs/config-layering.md b/src/docs/config-layering.md index 7e5eba282..e50cad01d 100644 --- a/src/docs/config-layering.md +++ b/src/docs/config-layering.md @@ -91,7 +91,7 @@ rational response to having one road, not duplicated effort. > and only ever *adds*, so a deleted agent reappears on the next config reload. > Tracked in #2361. -## `hive.yaml.runtime` — a snapshot on Kubernetes, an input on Docker/LXC +## `hive.yaml.runtime` — a snapshot on Kubernetes, an input everywhere else This file was called `hive.yaml.bak` until the rename. The old name implied "the restorable backup", which is true of only half its behaviour, and the @@ -102,7 +102,7 @@ and *reads* it only when the ConfigMap is missing or empty — the disaster fallback. A minority of older hives run a `copy-config` init container variant that does restore from it first; that variant is not what new hives get. -**On Docker/LXC it is a live boot input, and the source of truth.** There is no +**Outside Kubernetes it is a live boot input, and the source of truth.** There is no ConfigMap and no overlay in that mode, so the entrypoint restores this file over the config path on every boot (or points `HIVE_CONFIG` straight at it, and pins the same path into the launch argv as `--config`, when the config path is @@ -113,19 +113,73 @@ outside Kubernetes because this file already plays that role. > An earlier version of this section was headed "`hive.yaml.bak` is a backup, > not an input" and mentioned Docker only in passing. That was wrong for every -> Docker/LXC hive, where the file is precisely an input. +> non-Kubernetes hive, where the file is precisely an input. + +### Which mode am I in? + +There is no Docker-specific or Podman-specific code path. The entrypoint and +`config.IsKubernetesPod()` both ask one question — *is this a Kubernetes pod?* +— and everything that is not one takes the same branch: + +```sh +# src/deploy/entrypoint.sh +if [ -n "${KUBERNETES_SERVICE_HOST:-}" ] || [ -f /var/run/secrets/kubernetes.io/serviceaccount/token ]; then + IS_KUBERNETES=true +``` + +So **Docker, Podman (rootful or rootless) and LXC all behave identically here**, +as does any other container runtime and a bare binary on a host. This document +says "outside Kubernetes" rather than naming runtimes, because naming a subset +of them is how a Podman operator concludes the section is about someone else's +deployment ([#5220](https://github.com/kubestellar/hive/issues/5220)). + +Ask the running hive which file actually decides a field: + +```bash +# Same endpoint as above; use whichever port this hive serves the dashboard on +# (3002 direct, or 3001 when an nginx gateway fronts it). +curl -s localhost:3002/api/config/provenance | jq '.fields[] | select(.field=="acmm_level")' +``` + +```jsonc +{ + "field": "acmm_level", + "layer": "configmap-seed", // the layer's stable contract name… + "path": "/data/hive.yaml.runtime", // …but outside Kubernetes this is the real file (#4971) + "writer": "spoke (Config.Save)", + "writable": true, + "value": "4" +} +``` + +Note the `layer` name stays `configmap-seed` on a host that has no ConfigMap: +the layer identity is a stable part of the provenance contract, while `path` +and `writer` are the environment-aware fields that tell you where to write. +Trust `path`, not the layer name. + +Two consequences worth stating outright for a non-Kubernetes hive: + +- **The bind-mounted seed at `/etc/hive/hive.yaml` goes stale and stays stale.** + A dashboard change to `acmm_level` is written to `/data/hive.yaml.runtime`; + nothing writes it back to the seed. Reading the seed to find out what level a + hive is running will mislead you. This is by design (issue #1856 — letting + the seed win reverted a hive to its provisioned level on every restart), not + drift to be repaired. +- **`/data` is the only copy of the live config.** Losing that volume reverts + the hive to whatever the seed was provisioned with, silently downgrading + every hold-gated agent. Back up the volume, not just the seed file. ### Migration The rename is **copy-forward, never destructive**. Writers emit `/data/hive.yaml.runtime`; readers prefer it and fall back to `/data/hive.yaml.bak` when it is absent. Nothing renames or deletes the legacy -file on a PVC — on Docker/LXC it is the single copy of the live config, so +file on a PVC — outside Kubernetes it is the single copy of the live config, so mutating it at boot could lose owner customisations with no warning. A hive booting new code with only the legacy file present therefore boots normally from that file, and gains the new name on the next config save (on -Docker/LXC, the entrypoint also copies it forward immediately). Backups capture +non-Kubernetes, the entrypoint also copies it forward immediately). Backups capture both names for the same reason. The legacy fallback can be removed one release after every live hive has written the new name. diff --git a/src/docs/contributor-relay.md b/src/docs/contributor-relay.md index 30ed01c68..078fb67e2 100644 --- a/src/docs/contributor-relay.md +++ b/src/docs/contributor-relay.md @@ -21,7 +21,7 @@ sequenceDiagram ``` - The **work queue** is built from the hive's monitored repos: open, actionable issues that pass the admin's filters. The current depth is visible on the Hub tab and at `GET /api/contribute/status` (as `actionable_items`). -- The **relay** authenticates with a registration token, receives one task at a time, drives the local CLI inside a tmux session, injects a short-lived GitHub token for the PR, and reports the result. It heartbeats every 30 s and reconnects with exponential backoff; a task is abandoned if it exceeds 30 minutes. +- The **relay** authenticates with a registration token, receives one task at a time, drives the local CLI inside a tmux session, injects a short-lived GitHub token for the PR, and reports the result. It heartbeats every 30 s and reconnects with exponential backoff; a task is abandoned if the relay observes no forward progress for 30 minutes, or if it crosses an absolute 4-hour backstop. The GitHub token is valid for 55 minutes and is re-minted by the hub before it expires, so a task may outlive any single token ([below](#the-github-token-outlives-the-task-because-the-hub-re-mints-it)). - Every contributor has a **trust tier** with per-tier rate limits. See [Contributor trust tiers and delegated agent roles](contributor-trust-and-roles.md). ## Basic setup @@ -46,6 +46,7 @@ just contribute-hive claude local # host mode — relay + CLI directly on your ``` Containerized mode auto-detects the runtime — docker first, then podman — and can be forced with `export HIVE_CONTAINER_RUNTIME=podman`. +The resolved runtime is passed into the container, so the "attach to the CLI" hints printed from inside it (the status line, and the banner shown when the CLI needs a login) name the engine that actually launched it ([#5145](https://github.com/kubestellar/hive/issues/5145)). In host mode there is no container, and those hints are a plain `tmux attach -t `. Use `just contribute-check ` before registering to catch missing CLIs or obvious auth gaps. @@ -69,7 +70,7 @@ Important environment variables: | --- | --- | --- | | `HIVE_HUB` | value from `contributor.env`, else public hub default | WebSocket hub(s) to subscribe to. Use comma-separated URLs for multi-hub mode. Direct Compose reads the registered value from the mounted config file. | | `HIVE_REGISTRATION_TOKEN` | value from `contributor.env` | Registration token(s), positional with `HIVE_HUB` when multiple hubs are listed. Required; run `just contribute-setup` first. | -| `AGENT_BACKEND` | `claude` | CLI/backend to run (`claude`, `copilot`, `goose`, `bob`, `codex`, `pi`, `aider`, `litellm`, `agy`, depending on image support and credentials). `agy` is not in the contributor image and cannot inherit a sign-in, so run it with `just contribute-hive agy local`. | +| `AGENT_BACKEND` | `claude` | CLI/backend to run (`claude`, `copilot`, `goose`, `bob`, `codex`, `pi`, `aider`, `litellm`, `agy`, `opencode`, `kilo`, depending on image support and credentials). `agy` has no OS-level sandbox of its own, so run it containerized (`just contribute-hive agy`) — the contributor image ships the `agy` binary; local mode refuses to launch it without `HIVE_AGY_DANGEROUSLY_RUN_UNCONFINED=1`. `opencode` and `kilo` only run headless (`CONTRIBUTOR_MODE=headless`) — it has no interactive-tmux wiring. | | `AGENT_MODEL` | unset (backend default) | Optional model override passed to the contributor agent (e.g. `claude-sonnet-4-6`, `gpt-4o`, `gemini-2.5-pro`). Declared to the hive when the relay connects. | | `AGENT_REASONING_EFFORT` | unset | Reasoning effort override. Consumed by `codex` (`-c model_reasoning_effort`) and by `agy` (`--effort low\|medium\|high`, required whenever a model is set, else agy ignores the model). Ignored by other backends. | | `CONTRIBUTOR_MODE` | `interactive` | `interactive` keeps a tmux/TTY session. `headless` is for one-shot/no-TTY task delivery. | @@ -77,6 +78,14 @@ Important environment variables: | `HIVE_CODEX_APPROVALS_REVIEWER` | `auto_review` | Codex reviewer for boundary requests. The default prevents Hive-delivered work from waiting on an interactive operator while retaining `workspace-write`; set `user` only for an intentionally attended contributor. Set it to the **empty string** to omit the `-c approvals_reviewer=` key entirely — the escape hatch if a Codex release rejects that config key at startup. Doing so keeps the sandbox posture; it is not the same as the dangerous bypass. | | `HIVE_CLAUDE_DANGEROUSLY_ALLOW_HOST_STATE` | unset | Drops the defense-in-depth Claude command denylist. In local mode the native filesystem sandbox still applies, so this does not grant host writes. | | `HIVE_CLAUDE_DANGEROUSLY_BYPASS_APPROVALS_AND_SANDBOX` | unset | Restores the pre-#4918 unconfined Claude/LiteLLM local posture. Use only on a disposable or externally sandboxed host. | +| `HIVE_COPILOT_DANGEROUSLY_BYPASS_SANDBOX` | unset | Restores the unconfined Copilot local posture. Also the automatic fallback (with a warning) when the installed `copilot` CLI predates `--sandbox` (copilot-cli < 1.0.60). | +| `HIVE_OPENCODE_DANGEROUSLY_ALLOW_HOST_STATE` | unset | Drops opencode's host-state command deny-list (`permission.bash`). opencode has no filesystem sandbox to fall back to either way — this only removes the command-name floor. | +| `HIVE_GOOSE_DANGEROUSLY_RUN_UNCONFINED` | unset | **Required** for `just contribute-hive goose local` to launch at all. goose has no sandbox, filesystem allowlist, or command deny-list hive can wire; local mode refuses to launch without this. | +| `HIVE_AGY_DANGEROUSLY_RUN_UNCONFINED` | unset | **Required** for `just contribute-hive agy local` to launch at all. Same reasoning as goose above — agy's execution modes govern approval only, not filesystem confinement. Container mode (the default) needs no such flag: it now ships the `agy` binary and runs it inside the container boundary. | +| `HIVE_BOB_DANGEROUSLY_RUN_UNCONFINED` | unset | **Required** for `just contribute-hive bob local` to launch at all. Bob Shell documents no sandbox or path-restriction mechanism of any kind. | +| `HIVE_PI_DANGEROUSLY_RUN_UNCONFINED` | unset | **Required** for `just contribute-hive pi local` to launch at all. pi ships with no sandbox by default; directory confinement exists only via a third-party extension hive does not depend on. | +| `HIVE_AIDER_DANGEROUSLY_RUN_UNCONFINED` | unset | **Required** for `just contribute-hive aider local` to launch at all. aider has no sandbox or OS isolation option of any kind. | +| `HIVE_KILO_DANGEROUSLY_RUN_UNCONFINED` | unset | **Required** for `just contribute-hive kilo local` to launch at all. kilo's `--auto` is an unattended auto-approve flag, not a boundary; kilo has no verified sandbox, filesystem allowlist, or command deny-list hive can wire. | ### Where each backend reads its instructions @@ -95,6 +104,8 @@ mode fixed for Goose in [#2393](https://github.com/kubestellar/hive/issues/2393) | `pi` | `AGENTS.md`, `CLAUDE.md` | | `bob` | `.bob/AGENTS.md`, `CLAUDE.md` (compatibility) | | `agy` | `CLAUDE.md` | +| `opencode` | `AGENTS.md`, `CLAUDE.md` | +| `kilo` | `AGENTS.md`, `CLAUDE.md` | | anything else | `CLAUDE.md` only — the `*` fallback | A backend that reads neither `CLAUDE.md` nor one of the names above falls into @@ -134,11 +145,19 @@ loudly named `HIVE_CLAUDE_DANGEROUSLY_BYPASS_APPROVALS_AND_SANDBOX=1` restores the old unconfined local posture for an externally isolated/disposable host. `just contribute-hive` still defaults to **container** mode, the stronger -backend-independent boundary. In local mode Claude/LiteLLM now use the native -sandbox and Codex retains `workspace-write`; other backends remain unconfined -and the launch banner says so. The `agent_sandbox` Podman path documented in -[sandbox-isolation.md](sandbox-isolation.md) remains **hub-side only** — nothing -on the contributor path reads it. +backend-independent boundary. In local mode: Claude/LiteLLM and Codex use +their own OS-enforced sandboxes; Copilot now uses its own `--sandbox` (also +OS-enforced — Seatbelt/bubblewrap/ProcessContainer depending on platform), +gated on the installed CLI actually supporting the flag; opencode gets a +command-name deny-list via its own `permission.bash` config (a floor, not a +filesystem boundary — opencode has no OS sandbox); goose, agy, bob, pi, and +aider have no confinement mechanism this repo can wire at all, and local mode +for them **refuses to launch** unless the operator sets that backend's own +`HIVE__DANGEROUSLY_RUN_UNCONFINED=1`. See +[sandbox-isolation.md](sandbox-isolation.md)'s per-backend confinement matrix +for the authoritative, up-to-date state. The `agent_sandbox` Podman path +documented there remains **hub-side only** — nothing on the contributor path +reads it. Codex config-key compatibility: `approvals_reviewer` is passed with `-c`, so it depends on the installed Codex release accepting that key. If a version rejects @@ -165,7 +184,9 @@ The relay speaks to whatever backend you set up — pass it to `contribute-setup | `aider` | Aider | | `bob` | Bob shell (needs `BOBSHELL_API_KEY`) | | `litellm` | Claude Code pointed at **your own LiteLLM proxy**: `export HIVE_LITELLM_ENDPOINT=… HIVE_LITELLM_API_KEY=…` (exported locally, never sent to the hive) | -| `agy` | Antigravity — host mode only; it signs in through an interactive Google OAuth flow with no API-key mode, so a container cannot inherit its credentials | +| `agy` | Antigravity — no OS-level sandbox of its own, so container mode (default) is its only mode with any host boundary; local mode refuses without `HIVE_AGY_DANGEROUSLY_RUN_UNCONFINED=1`. Signs in through an interactive Google OAuth flow with no API-key mode: sign in once inside the container, or on the host first (`just contribute-hive agy` stages a signed-in `~/.gemini` into the container — unverified whether that alone re-authenticates an unattended run) | +| `opencode` | Provider-agnostic (75+ providers); `opencode auth login` writes a credential to `~/.local/share/opencode/auth.json`. Headless-only: `opencode run ""` is its one-shot entry point, wired via `CONTRIBUTOR_MODE=headless`; there is no interactive-tmux launch path for it | +| `kilo` | Headless-only: `kilo run "" --auto`; set `KILO_AUTH_CONTENT` / `KILO_CONFIG_CONTENT` or `KILO_API_KEY` (optional `KILO_ORG_ID`). Hive forwards only those values and never mounts a Kilo home/config directory. `--auto` is approval, not a sandbox. | ## Choosing a model @@ -327,7 +348,7 @@ kubectl apply -f relay.yaml kubectl -n my-namespace rollout status deploy/hive-contributor ``` -The generated pod sets `CONTRIBUTOR_MODE=headless` because Kubernetes pods have no TTY; interactive tmux mode would stall. Headless mode is currently verified for `claude`, `litellm`, `copilot`, `codex`, `goose`, and `agy` (`agy -p`, verified on 1.1.13) — but **`agy` is host-only**: it signs in through an interactive Google OAuth flow with no API-key mode, so a pod cannot authenticate and `just contribute-k8s` deliberately keeps warning for it. Headless `agy` works on a host that has already signed in. The Deployment has one replica per registered contributor identity and uses readiness/liveness probes that read the relay's headless status file (`waiting`, `working`, `done` pass; missing/failed state fails). +The generated pod sets `CONTRIBUTOR_MODE=headless` because Kubernetes pods have no TTY; interactive tmux mode would stall. Headless mode is currently verified for `claude`, `litellm`, `copilot`, `codex`, `goose`, and `agy` (`agy -p`, verified on 1.1.13) — but **`agy` stays out of `just contribute-k8s`'s `HEADLESS_BACKENDS` allowlist regardless**: it signs in through an interactive Google OAuth flow with no API-key mode, and a pod has no way to complete that sign-in even once (unlike the container path, where an operator can attach and run `agy` interactively, or the relay can stage an already-signed-in `~/.gemini`). Headless `agy` is verified only on a host that has already signed in. `opencode` has a verified one-shot invocation (`opencode run ""`, [#4970](https://github.com/kubestellar/hive/issues/4970)) but is **not yet** in `just contribute-k8s`'s `HEADLESS_BACKENDS` allowlist: whether `opencode auth login`'s credential file supports non-interactive, unattended use in a fresh pod is unverified, so it currently runs headless on a host that has already signed in, the same posture as `agy`. The Deployment has one replica per registered contributor identity and uses readiness/liveness probes that read the relay's headless status file (`waiting`, `working`, `done` pass; missing/failed state fails). The generated Secret contains the registration token and `GH_TOKEN` as Kubernetes Secret data. Treat it as sensitive cluster-readable material and prefer a pinned image tag/digest for repeatable operation. @@ -389,7 +410,79 @@ That last line types a fresh prompt into a pane whose CLI is still mid-turn, int A resume that is genuinely refused — an operator yanked the task, or the relay stopped reporting for longer than the lease window — still ends in `task_revoke`, and that is correct. The relay clears its task and asks for new work. -**A dropped socket is not a failed issue.** The disconnect books a short cooldown on the issue so a second session cannot pick it up during the reconnect window and file a duplicate PR ([#2356](https://github.com/kubestellar/hive/issues/2356)). That cooldown no longer counts toward the consecutive-failure quarantine: three drops on a flaky connection used to park a perfectly workable issue for six hours with nothing having actually failed. Real failures — `task_failed`, the relay's own 30-minute watchdog giving up, the wedged-task backstop — still count, and still quarantine. +**A dropped socket is not a failed issue.** The disconnect books a short cooldown on the issue so a second session cannot pick it up during the reconnect window and file a duplicate PR ([#2356](https://github.com/kubestellar/hive/issues/2356)). That cooldown no longer counts toward the consecutive-failure quarantine: three drops on a flaky connection used to park a perfectly workable issue for six hours with nothing having actually failed. Real failures — `task_failed`, the relay's own progress watchdog giving up, the wedged-task backstop — still count, and still quarantine. + +### The relay's max-duration ceiling is a progress lease + +[#5321](https://github.com/kubestellar/hive/issues/5321). `MAX_TASK_DURATION_MS` (30 minutes) bounds how long a task may go **without observed forward progress**, not how long it may take. Every progress tick that sees new pane output re-arms it from now, so an agent that is working keeps its lease indefinitely. `ABSOLUTE_TASK_DEADLINE_MS` (4 hours, `HIVE_ABSOLUTE_TASK_DEADLINE_MS`) is the backstop that nothing re-arms, for the pathological case of a process that prints forever without finishing. + +It was previously a flat wall-clock kill, armed once at task start and never re-armed. That made any task whose honest duration exceeded 30 minutes impossible rather than merely slow. Observed live on 2026-08-31 it killed an agent that had already committed and pushed and was blocked on a full `go test` run; the hub booked the task `failed` 57 seconds before that task's own PR was opened, and returned the issue to the failure cooldown. The work survived only because the agent chose, unprompted, to finish and file the PR anyway. + +This aligns the relay with the hub, which has been progress-driven since [#4260](https://github.com/kubestellar/hive/issues/4260): `leaseTTL` is re-stamped on every accepted `task_progress`, and `reclaimExpiredLeases` never reclaims a task that keeps reporting. The relay's blind timer was the only remaining wall-clock kill. + +Crossing either ceiling is reported with `failure_kind: environment`. It is a statement about this runtime — the relay could not see the work finish — not a judgement that the agent failed its task. The old path passed no options at all, so an infrastructure ceiling was recorded as a plain task failure. + +The hang case these ceilings nominally guard is covered better and sooner by the pane-stall detector above: 20 minutes of byte-identical output, confirmed over `PANE_STALL_CONFIRM_TICKS` ticks. The headless path has no pane to scrape and therefore no progress signal, so its one-shot child is bounded by the absolute backstop directly (`HIVE_HEADLESS_TASK_TIMEOUT_MS`). + +### The GitHub token outlives the task, because the hub re-mints it + +The scoped GitHub token the relay pushes with is valid for **55 minutes** +(`wsTokenTTL`), which is shorter than the 4-hour absolute backstop above. A task +is therefore allowed to run for longer than any single token lives. That gap is +covered, not ignored: the hub re-mints ahead of expiry, so a task running to the +backstop is expected to use several tokens in succession. + +**Minting.** The token is minted per task and scoped to that task's repository +and the contributor's trust tier. It is delivered *after* the task's acceptance +decision, on the `token_refresh` wire shape rather than inside `task_assign` +itself — under the default auto-accept this is immediate, and under the opt-in +explicit-acceptance mode it waits for the human. The relay's handler writes it +to a single `0600` file (`GH_TOKEN_CACHE`, overridable with +`HIVE_GH_TOKEN_CACHE`); that file is the only place the token lives. + +**Refresh.** On every heartbeat the hub checks whether the active task's token +was minted at least **50 minutes** ago (`wsTokenRefreshPeriod`) and, if so, +re-mints and pushes a fresh `token_refresh`. The relay overwrites the cache file +in place. The five-minute gap before the 55-minute expiry absorbs clock skew and +any `gh` command already in flight, so push access does not lapse between the +old token dying and the new one landing. Refresh is unconditional on task +duration: it re-arms each time it fires, so a task at the 4-hour backstop has +been refreshed roughly four times. + +Two things follow from refresh being driven by the hub's heartbeat: + +- **It requires a live socket and an active task.** A task with no assignment, + or a connection whose socket has dropped, is not refreshed. A reconnect that + re-adopts a task through the server-issued lease re-mints immediately and + re-arms the cycle — without that step the resumed session's mint time would + stay zero and refresh would never fire again for the life of the connection + ([#2610](https://github.com/kubestellar/hive/issues/2610)). +- **A failed re-mint is not fatal and is not announced.** If the mint errors, or + the hive has no App auth to mint from, the hub logs it and leaves the relay's + existing token in place, retrying on the next heartbeat. The relay is told + nothing. So the observable failure mode is not a "token expired" message: it + is a push or `gh` call that starts returning an authentication error partway + through a long task, with the previous 55 minutes having worked normally. + +**Expiry is advertised but not enforced by the relay.** Each `token_refresh` +carries a `token_expires_at` timestamp, and the relay records it — but it never +checks it. Nothing in the relay warns as expiry approaches, refuses to start a +push against a stale token, or asks the hub for a new one. The relay finds out +that a token has died the same way it finds out about any other GitHub error: +the command fails. If you see an authentication failure on a task that has been +running for around an hour, a re-mint that quietly failed on the hub side is the +first thing to check, and the hub's log is the only place that records it. + +**Removal.** The token is unlinked on **every** task-exit path, before the agent +is interrupted, so a turn that survives the stop cannot keep pushing against an +issue the hub has already offered to someone else +([#5353](https://github.com/kubestellar/hive/issues/5353), +[#5373](https://github.com/kubestellar/hive/issues/5373)). It is deliberately +*not* dropped when the relay declines an offered task: a decline is not an exit, +and dropping the credential there would destroy the token belonging to the task +still being worked. Unlinking the file does not revoke the token — it stays +valid at GitHub for the remainder of its 55 minutes — so removal bounds *this +relay's* use of it, not the credential's lifetime. ## Troubleshooting: the backend dies seconds after every task diff --git a/src/docs/design/README.md b/src/docs/design/README.md index 6ef19e56b..f7e6057c9 100644 --- a/src/docs/design/README.md +++ b/src/docs/design/README.md @@ -68,6 +68,17 @@ that status is the thing to check before treating a page as current behaviour: decision. Read it before steps 3-4, particularly for the unresolved fork: backend-specific resume envelopes versus an API-shaped backend hive owns. +- [Evaluating a handoff path for the re-entrant turn model](agent-turn-handoff.md) + — **spike / investigation, no decision taken.** Step 3 of the same RFC. Its + finding is that hive has already built handoff's two hard mechanisms twice and + wired neither: `pkg/convergence/mutation` (#4255) holds an epoch-fenced claim + ledger and an idempotent operation journal, `pkg/turn` holds a second journal, + and nothing imports either. No single store has all three properties handoff + needs — atomic claim, cross-process serialization, corruption-resistant + persist — and the three partial implementations each hold a different two. + Recommends **no queue, and not yet**, with ordered prerequisites, and narrows + the beads-checkpointing challenge to the one variable still undecided. + - [Copilot per-repo cost capture at the MITM proxy](copilot-cost-capture.md) — **investigation, no decision taken.** Phase 4 of epic #4836, which asked whether Copilot token usage can be captured per request with repo context and diff --git a/src/docs/design/agent-host-confinement.md b/src/docs/design/agent-host-confinement.md index 734d300f2..d7dfb2541 100644 --- a/src/docs/design/agent-host-confinement.md +++ b/src/docs/design/agent-host-confinement.md @@ -1,11 +1,30 @@ # Agent host confinement on the default launch path (#4918) -Status: **historical investigation; contributor-local Claude confinement is now -implemented.** This page records the evidence and options as assessed before -Claude Code's native sandbox was wired into `contribute-hive ... local`. -Claude/LiteLLM local launches now use that OS-enforced sandbox with hard-fail -startup and no unsandboxed retry; Codex retains `workspace-write`. The hub-side -Podman default and local backends without a native sandbox remain open concerns. +Status: **historical investigation; contributor-local confinement is now +implemented for every backend that has a real mechanism, and the rest refuse +to launch unconfined.** This page records the evidence and options as +assessed before that work landed (#5011, then this follow-up). Claude/LiteLLM +and Codex local launches use their own OS-enforced sandboxes with hard-fail +startup and no unsandboxed retry; Copilot local launches now use Copilot +CLI's own `--sandbox` (OS-enforced, same underlying technology class); opencode +gets a command-name deny-list (a floor, not a boundary — it has no OS sandbox); +goose, agy, bob, pi, and aider have **no confinement mechanism this repo can +wire at all**, verified against each CLI's own current docs, and local mode +for them now refuses to launch without an explicit per-backend operator +opt-in rather than launching silently unconfined. See +`src/docs/sandbox-isolation.md`'s per-backend confinement matrix for the +current, authoritative state — the analysis below is left as the historical +record of how each decision was reached, not a live status report. The +hub-side Podman sandbox's double gate (`agent_sandbox.enabled` + +per-agent `sandbox.enabled`) is unchanged — collapsing it is deliberately +not done, since a sandboxed agent has no tmux fallback and an image-less +opt-in would fail every kick outright (`config.AgentSandboxGateWarnings`'s +doc comment). What *is* now fixed is the gate's silence: the dashboard's +Security tab previously let an owner enable the global flag and believe the +fleet was sandboxed with no per-agent opt-in and no error anywhere in the +UI. `GET /api/config/governor`'s `security.sandboxWarnings` now carries the +same diagnosis boot/reload already logged at WARN, and the Security tab +renders it inline under the toggle and in the page's coherence-warnings box. All citations are against `origin/v4` at `1b54c69e` unless noted. @@ -332,6 +351,13 @@ estimated from reading the code, not measured. ## Recommendation +> **Update:** this recommendation is about the hub-side Podman sandbox +> (`agent_sandbox`), which is a separate axis from the contributor-local +> per-backend work this page's status line now describes — that work closed +> the specific incident path (`contribute-hive ... local`) without touching +> the Podman double-gate discussed here. The recommendation below is +> unimplemented and still stands as an open item. + **This investigation's single strongest recommendation: single-gate or default-on the existing Podman sandbox for `contribute-hive` (both containerized and, especially, `local` mode), with the current denylist diff --git a/src/docs/design/agent-turn-handoff.md b/src/docs/design/agent-turn-handoff.md new file mode 100644 index 000000000..b43771fc2 --- /dev/null +++ b/src/docs/design/agent-turn-handoff.md @@ -0,0 +1,348 @@ +# Evaluating a handoff path for the re-entrant turn model (#4002, step 3) + +Status: **spike / investigation — no decision taken.** This page answers the two +questions RFC #4002 scopes to step 3 ("does hive want queue-based handoff, and +what is the minimal state envelope?") and the beads-checkpointing challenge +raised on the issue. It recommends a **sequence**, not an architecture, and it +does not propose wiring anything into the live agent loop. + +Read [The agent turn model and where in-process state lives](agent-turn-model.md) +first: it is steps 1 and 2, and this page assumes its findings rather than +repeating them. + +Every claim carries a `file:line` citation against `v4` at the time of writing. +Line numbers drift; function names are the durable handle. Three findings are +additionally pinned by characterization tests, named where they appear, so a +future change that closes a gap surfaces as a test asking to be rewritten rather +than as a stale paragraph nobody re-reads. + +--- + +## Summary of findings + +1. **Hive has already built handoff's two hard mechanisms — twice — and wired + neither.** `pkg/convergence/mutation` (accepted on #4255) implements a + durable, epoch-fenced claim ledger *and* an idempotent operation journal. + `pkg/turn` (step 2 of this RFC, #4933) implements a second operation journal + with its own idempotency-key derivation. **Nothing in the repository imports + either package.** Step 3's most consequential finding is not a missing + mechanism; it is duplication between two unwired prototypes. +2. **No existing store has all three properties handoff needs.** Atomic + compare-and-set on claim, cross-process serialization, and a + corruption-resistant persist are spread across three implementations, each + holding a different two of the three. §2 has the table. +3. **The claim path hard problem 2 tells handoff to reuse is not atomic.** + `beads.Store.Claim` writes `in_progress` unconditionally, records no + claimant, and `Ready` reserves nothing. Two callers are both told they hold + the task. +4. **The recommendation is: no queue, and not yet.** Handoff's blocker is + ownership, not transport. A queue added before ownership is fixed becomes the + fifth durable store the issue explicitly warned against, and inherits the + duplicate-work bug family it was meant to end. +5. **The beads-checkpointing challenge has a narrower answer than it was + asked.** The "idempotency guards" half of the proposed cheaper alternative + already exists — twice. What remains genuinely undecided is only whether + resuming *mid-turn context* pays for hive owning the conversation, and the + instrument that would size it measures the one execution path where owning + the conversation is impossible. + +--- + +## 1. What steps 1 and 2 settled + +- **The turn envelope exists and is re-entrant.** `SessionEnvelope` + (`src/pkg/turn/envelope.go:71`) carries messages, plan, journal, status and + task ref; `Runner.Step` (`src/pkg/turn/runner.go:120`) is a plain function + over it, returning a structured `TurnOutput`. +- **Landed effects are protected across re-entry.** + `JournaledExecutor.Do` (`src/pkg/turn/runner.go:46`) persists intent, performs + the effect, then persists the settlement; a re-entry that finds an `intended` + entry reconciles instead of replaying. +- **Persistence is atomic and scrubbed.** `FileStore.Persist` + (`src/pkg/turn/store.go:17`) writes to a uniquely-named temp file, chmods, + fsyncs, renames, and fsyncs the directory; `ToJSON` + (`src/pkg/turn/envelope.go:113`) routes every content-bearing field through + `logscrub` on the way out. This closes residual item 3 from the stage-2 report + on the issue, which listed atomic persistence as assumed rather than done. +- **The motivating problem now has an instrument.** `TurnLoss` + (`src/pkg/agent/turn_loss.go:96`), recorded by `noteTurnInterruptedLocked` + (`:117`) through the single teardown funnel `tearDownTurnLocked` (`:178`). + +What steps 1 and 2 explicitly did **not** settle is concurrency: the stage-2 +report's residual item 5 says the journal makes re-entry safe, not concurrency +safe, and that two processes racing one envelope is out of scope. That residual +is this page's subject. + +--- + +## 2. Hive already has three durable-ownership implementations + +This is the finding that should shape step 4, so it comes before the answers. + +### 2.1 `pkg/convergence/mutation` — the fenced lease, already built + +`Ledger` (`src/pkg/convergence/mutation/ledger.go:81`) is a durable claim ledger +whose every transition is a compare-and-set on `{key, expected epoch, expected +state}`. `Acquire` (`:166`) grants at `prev+1` and persists before returning, so +an epoch is never handed out that a restart could forget. `ValidateEpoch` +(`:257`) is the fence, checked at the mutation boundary. Expiry reconciles an +`ActiveMutation` entry to `Waiting` rather than `Released` (`:134`), so a crashed +holder is fenced without its ownership being silently discarded. + +Alongside it, `Journal` (`journal.go:166`) records each logical operation with an +ID computed **without owner or epoch** (`Effect.LogicalID`, `journal.go:110`), so +a reassigned replacement adopting the same desired effect finds the same entry +rather than minting a second. `Reconcile` (`journal.go:334`) resolves an +uncertain effect against authoritative external state before any retry, and +`Executor.Execute` (`executor.go:70`) sequences validate → begin → effect → +record under the epoch. + +That is, in substance, the handoff design this step was asked to evaluate — +already accepted, already merged, and inert by default (`JournalingEnabled` / +`FencingEnabled`, `executor.go:17`, `:22`). + +### 2.2 `pkg/turn` — a second operation journal + +Step 2 independently built `Journal` / `JournalEntry` +(`src/pkg/turn/journal.go:40`) and `DeriveIdempotencyKey` (`:62`), deriving a key +from `{version, session, kind, repo, target, body}` and deliberately excluding +the model's tool-call ID. That reasoning is sound and matches +`Effect.LogicalID`'s "no owner, no epoch" rule arrived at independently for +#4255 — which is precisely why the duplication matters: two teams reasoned to +the same rule and wrote it twice. + +### 2.3 Neither is wired, and the gaps are complementary + +Nothing outside those two package directories imports either. Both are +prototypes. + +| | atomic CAS on claim | cross-process serialization | corruption-resistant persist | +|---|---|---|---| +| `beads.Store` | **no** — `Claim` (`beads.go:441`) sets `in_progress` unconditionally through `Update` (`:419`) | **yes** — `lockAndRefresh` takes an exclusive `flock` and re-reads (`xproc_lock.go:32`) | **yes** — unique temp name per writer (`beads.go:773`), fixed in #4742 | +| `mutation.Ledger` | **yes** — every transition is a CAS (`ledger.go:220`) with a monotonic epoch | **no** — serialized by an in-process `sync.Mutex` only (`ledger.go:81`) | **no** — fixed `path + ".tmp"`, no fsync (`ledger.go:285`) | +| `turn.FileStore` | **no** — `SessionEnvelope` carries no owner, epoch or lease at all | **no** | **yes** — `CreateTemp` + `Sync` + rename + dir fsync (`store.go:17`) | + +Each implementation holds two of the three properties, and a different two. The +`mutation.Ledger` persist gap is the *same* fixed-temp-name pattern that #4742 +removed from beads — the beads code carries the explanatory comment +(`beads.go:789-794`) and the ledger, written later and independently, does not. + +**Pinned by test.** `TestTwoOpenLedgersBothAcquireTheSameClaim` and +`TestReopeningAfterAConcurrentAcquireSeesOnlyTheLastWriter` +(`src/pkg/convergence/mutation/ledger_crossprocess_test.go`) demonstrate the +middle column: two handles opened on one path both acquire the same claim **at +the same epoch**, so `ValidateEpoch` authorizes both, and the whole-file rewrite +erases the loser's entry rather than merging it. This is not a live defect — the +package is inert — but it is exactly the property a cross-process handoff would +need and would otherwise assume. + +--- + +## 3. Question A — does hive want queue-based handoff? + +**Recommendation: no queue, and not yet.** Three reasons, in order of weight. + +### 3.1 The blocker is ownership, not transport + +Handoff means a second process safely adopting work a first process may still +believe it holds. That is a mutual-exclusion problem. A queue moves *messages*; +it does not by itself decide who owns a task, and every queue-based design still +needs the lease underneath. Hive's lease already exists (§2.1) and needs +cross-process serialization; adding a queue first solves the part that is not +blocking. + +### 3.2 The claim path handoff was told to reuse cannot yet exclude anyone + +The issue's hard problem 2 is explicit: cross-process handoff "must go through +the existing atomic offer→claim path". The path exists. The atomicity does not: + +- `Store.Claim` (`beads.go:441`) sets `StatusInProgress` unconditionally via + `Update` (`:419`). The cross-process `flock` (`xproc_lock.go:32`) serializes + the two *writes*; nothing compares against the prior status, so the second + caller is told it succeeded. `bd update --claim` (`src/cmd/bd/main.go:243`) + prints "Claimed" either way. +- A claim records **no claimant**. `Bead.Actor` (`beads.go:111`) is set at + `Create` and means *addressee*, not *holder*, and `Claim` never touches it. So + re-entry cannot distinguish "I already hold this, resume it" from "somebody + else holds this, leave it alone" — the one distinction a lease exists to make. +- `Ready` (`beads.go:578`) is a pure read with no reservation, consumed only by + `bd ready` (`src/cmd/bd/main.go:169`). Offer→claim is therefore a + read-then-write across two separate short-lived CLI processes. + +**Pinned by test.** `TestClaimDoesNotRejectAnAlreadyClaimedBead`, +`TestClaimRecordsNoClaimant` and `TestReadyOffersTheSameBeadToRepeatedReaders` +(`src/pkg/beads/claim_handoff_test.go`) pin all three. They characterize current +behaviour and skip with a rewrite instruction if a compare-and-set ever lands. + +Whether that is a defect depends on a fact this spike cannot settle from the +code: beads are addressed to a named `Actor` and today only that actor's agent +polls them, so the exclusion may simply never have been needed. It becomes +needed the moment a handoff exists, which is why it is named here rather than +filed as a bug. + +### 3.3 A queue now would be durable store number five + +The issue's own caution — "the state envelope should consolidate or at least map +onto these, not become store number five" — applies with more force after §2.3. +Hive currently has, for this problem alone, three partial ownership stores and +two unwired journals. The next thing built here should reduce that number. + +### 3.4 The ordered prerequisites, if handoff is wanted later + +1. **Pick one journal.** `pkg/turn`'s journal and `pkg/convergence/mutation`'s + journal answer the same question with the same rule. Converging them — most + plausibly by having `turn` depend on `mutation` rather than the reverse, + since `mutation` already carries the epoch — is the cheapest possible step + and removes a whole class of future divergence. +2. **Give the chosen ledger cross-process serialization**, using the pattern + beads already proved: `flock` plus re-read-under-lock (`xproc_lock.go:32`), + plus the unique-temp-name persist beads adopted in #4742. +3. **Join the envelope to the lease.** §4. +4. **Only then** ask whether a transport is wanted. With a fenced lease and a + shared journal, "handoff" may reduce to a replacement process acquiring at a + higher epoch and loading the envelope from the shared path — no queue. + +--- + +## 4. Question B — the minimal state envelope + +The envelope hive would hand off is smaller than the RFC implies, because most +of it already exists. + +**Already carried** by `SessionEnvelope` (`src/pkg/turn/envelope.go:71`): +`Messages` (the conversation), `Plan` with bound idempotency keys, `Journal` +(what landed), `TaskRef` (the join to the work item), `Status`, and the +version field that makes the format evolvable. + +**Must be added** — and this is the whole of the addition: + +| field | why | +|---|---| +| `Owner` | who holds this envelope now. `TaskRef` names the work, not the holder; §3.2 shows the bead cannot supply it. | +| `Epoch` | the fencing token. Must be minted by the ledger, not by the envelope, so it is monotonic across processes. `mutation.Entry.Epoch` (`ledger.go:58`) is that value. | +| `LeaseExpiry` | so a crashed holder's work becomes adoptable without a human. `mutation.Ledger` already reconciles expiry to `Waiting` (`ledger.go:134`). | + +And one behaviour change: `Persist` must become a **compare-and-set on +`Epoch`**, refusing a write from a holder the ledger has already fenced. +Without it, two processes that both believe they hold the envelope each rewrite +the whole file and the loser's journal entries vanish — reopening exactly the +duplicate-effect class the step-2 journal closed. This is the same failure the +`mutation.Ledger` test in §2.3 demonstrates, one layer up. + +**Explicitly not in the envelope, and not portable:** + +- **Backend session references.** `~/.claude.json`, per-agent `CODEX_HOME`, and + copilot session-state directories are spoke-local paths holding + backend-private formats. §5.1 of the step-1 page establishes hive can neither + parse nor migrate them. A handed-off envelope therefore cannot carry a + tmux-hosted agent's conversation, only a headless turn's. +- **Everything on `AgentProcess`** — pane observations, nudge budgets, tmux + identity. These describe a terminal on one host and are meaningless on + another. The step-1 page inventories them. + +**The consequence is the fork, forced.** Step 1 named the choice between +backend-specific resume envelopes and an API-shaped backend, and deliberately +took no position. Handoff removes the option of not choosing: the envelope +described above is only handoff-able on the headless path. A tmux-hosted agent +can have durable *control-plane* state — it already does — but not a +handoff-able conversation, at any envelope design. + +--- + +## 5. Question C — why not just beads-checkpointing? + +The challenge on the issue proposes a cheaper alternative: a `bd note` +checkpoint verb, prompt-pack discipline, and idempotency guards, priced against +conversation-as-state. Two corrections narrow it. + +**The guards half already exists — twice.** The proposed "idempotency guards" +are `pkg/convergence/mutation`'s journal and `pkg/turn`'s journal (§2). Whichever +alternative wins, that work is done and should be converged, not rebuilt. So the +comparison is not "conversation-as-state versus checkpoints plus guards"; it is +"conversation-as-state versus checkpoints, given guards either way". + +**That leaves exactly one undecided variable.** Of the three things the challenge +lists as uncaptured by beads today — mid-turn context, side-effect awareness, +turn-granular resume — the journal already supplies side-effect awareness, and +turn-granular resume is a consequence of context, not an independent benefit. So +the whole decision reduces to: **does resuming mid-turn context save enough to +justify hive owning the conversation?** + +**And the instrument cannot answer it yet.** `TurnLoss` +(`src/pkg/agent/turn_loss.go:96`) is honest about what it measures: `UpperBound` +is explicitly the most a restart *could* have cost, `Producing` is the +threshold-free count of interruptions that certainly hit a working agent. Both +are collected on the **tmux path** — the path where, by §4, hive cannot own the +conversation at all. The instrument therefore sizes the *problem* on the fleet's +normal mode, and the *solution* is only available on a different mode that +carries no instrument. + +The killed-at-50% experiment the challenge asks for is still the right +acceptance criterion. Stated against fields that now exist, it needs: + +1. A baseline from fleet `TurnLoss` data — `Producing` over `Interruptions` + answers "how often does a teardown actually discard work", which nothing has + answered yet. Until that ratio is known, both arms of the comparison are + being priced against an unsized problem. +2. A headless task instrumented on both arms, since arm (a) cannot run on the + tmux path. That makes the experiment a **prototype cost**, not a measurement + cost — worth stating plainly, because the challenge's premise was that + measuring is cheaper than prototyping, and for arm (a) specifically it is not. + +If (1) shows `Producing` is a small fraction of `Interruptions`, the honest +recommendation is to close this RFC in favour of the smaller effort, exactly as +the challenge proposes — and the guards are already built either way. + +--- + +## 6. The stage-2 residual list, re-checked against `v4` + +The stage-2 report on the issue listed six residuals. Their status today: + +1. **The LLM call is not journaled** — stands. `Runner.Step` binds a plan + supplied by the caller (`runner.go:120`, `bindPlan` `:153`); inference is + outside the envelope transition. +2. **Reconciliation is only as good as its query** — stands, and is inherent. + `mutation.Reconcile` (`journal.go:334`) takes external state as an argument + for the same reason. +3. **Persistence is assumed atomic** — **closed.** `FileStore.Persist` + (`store.go:17`) does temp + chmod + fsync + rename + directory fsync. Worth + noting the sibling ledger did not get this treatment (§2.3). +4. **`Runner.Step` does not use the journal** — partly stale as written. `Step` + drives every operation through `JournaledExecutor.Do` (`runner.go:141`). What + remains true is that nothing in the live agent loop constructs a `Runner`. +5. **No claim integration** — stands, and is this page's subject. +6. **The journal grows unboundedly** — stands. `Journal.Entries` + (`journal.go:78`) is appended and never compacted, while `Messages` is + nominally compactable. A handed-off envelope crossing spokes makes its size a + transfer cost, not only a disk cost. + +--- + +## 7. What step 4 still needs + +Step 4 is the feasibility and migration-cost call. Its remaining inputs: + +1. **Fleet `TurnLoss` data** — the `Producing`/`Interruptions` ratio (§5). This + is now a matter of collecting from log aggregation, not of building anything. +2. **A decision on §4's fork**, which handoff forces and step 1 deferred. +3. **A decision on the two journals** (§3.4 item 1), which is worth making even + if the RFC stalls, because two unwired implementations of one rule will + diverge. +4. **An answer to whether beads' claim should exclude** (§3.2) — a question for + whoever owns the contribute plane, not a code reading. + +Nothing on that list requires more prototyping. Two of the four are questions +for maintainers, and the `hold` label should stay until they are answered. + +--- + +## What this page does not say + +It does not recommend for or against the RFC — step 4 owns that. It does not +propose changing `beads.Claim`: making a claim start failing changes live agent +behaviour, and the case for it rests on a handoff that does not exist yet. It +does not file the `mutation.Ledger` persist and serialization gaps as defects, +because the package is inert by design and reaching them requires wiring that +has not happened. All three are recorded here so that whoever does the wiring +meets them as known constraints rather than as incidents. diff --git a/src/docs/design/agent-turn-model.md b/src/docs/design/agent-turn-model.md index 25a5bd474..54541df32 100644 --- a/src/docs/design/agent-turn-model.md +++ b/src/docs/design/agent-turn-model.md @@ -95,6 +95,15 @@ text**, by two independent mechanisms: calls it at `src/pkg/agent/manager.go:4627`. This gate is the only thing that stops hive typing a new prompt on top of an in-flight response. +The gate is bounded by `inputPromptTimeout` (120s), which is why operator kicks +from the dashboard do **not** use `SendKick` directly. `SendKickAsync` +(`src/pkg/agent/kick_async.go`) keeps the fast, deterministic preconditions +synchronous and runs this gate plus the typing on a background goroutine, so the +HTTP handler cannot outlive an ingress idle timeout and report a succeeding kick +as a 504 failure (kubestellar/hive#5325). Delivery is deduplicated per agent, so +a retried click cannot type the prompt twice. The governor's tick still calls +`SendKick` synchronously — it has no proxy in front of it. + **The pane poller.** `pollTmuxOutputForAgent` (`src/pkg/agent/manager.go:2847`) runs a `3 * time.Second` ticker (`src/pkg/agent/manager.go:2848`) for the agent's whole lifetime, diffing @@ -634,3 +643,9 @@ and is otherwise behaviour-preserving. It does not say the RFC is infeasible, and it does not say it is worthwhile. Step 4 is where that judgement belongs, and it should be made with §5.1's fork named explicitly and Open question 3 answered. + +Step 3 — the handoff evaluation — is now written up separately in +[Evaluating a handoff path for the re-entrant turn model](agent-turn-handoff.md). +It re-checks §6.3's residuals against `v4`, and reports that §5.1's fork is no +longer deferrable: the envelope it describes is handoff-able on the headless +path only. diff --git a/src/docs/design/tui.md b/src/docs/design/tui.md index 89c12031f..facc0b56d 100644 --- a/src/docs/design/tui.md +++ b/src/docs/design/tui.md @@ -10,7 +10,7 @@ Two things in the epic's drafted text do not match this repository, and both are corrected here with the evidence: the `v2/` path prefix ([Paths](#paths-the-epics-v2-prefix-predates-3996)) and the assumption that `dashboard/openapi.json` is a usable contract for the action tasks -([Contract status](#contract-status-the-spec-covers-11-of-the-api-and-no-writes)). +([Contract status](#contract-status-the-spec-now-covers-writes)). Citations are against `origin/v4` at `45a13d5`. --- @@ -30,7 +30,7 @@ tmux, the relay is a CLI, self-hosters are SSH'd into a box — so exposing :300 or tunnelling it just to check governor mode is friction the web UI imposes. A second API consumer also hardens the contract: anything the TUI cannot do through the published spec is a gap worth an issue anyway, which is exactly what -[section 2.1](#contract-status-the-spec-covers-11-of-the-api-and-no-writes) +[section 2.1](#contract-status-the-spec-now-covers-writes) turned out to be. **Non-goals for v1**, quoted from the epic: @@ -126,48 +126,47 @@ package. The mapping for the whole epic: Verification for every code task is `go test ./pkg/tui/...`, run from `src/`. -### Contract status: the spec covers 11% of the API, and no writes +### Contract status: the spec now covers writes The Contract decision names `dashboard/openapi.json` as the source of truth and -tells a sub-issue what to do when an operation is missing from it. That clause -is going to fire far more often than the epic assumes, so the size of the gap is -recorded here once rather than rediscovered by each task. - -Measured at `45a13d5`: - -| | Published in `dashboard/openapi.json` | Registered by the dashboard | -|---|---:|---:| -| Distinct `/api` routes | 32 | 298 | -| `GET` | 32 | 137 | -| `POST` | **0** | 83 | -| `PUT` | **0** | 64 | -| `DELETE` | **0** | 14 | - -The spec is **GET-only**. It documents no write operation of any kind, which -means **every Phase 2 action task in the epic — T14 pause/resume, T17 apply -model, T19 apply ACMM, T20 kick now — has no contract to build against.** Each -of those endpoints exists and is exercised today by `hivectl`; none is in the -spec: - -| Task | Endpoint `hivectl` uses today | In spec | -|---|---|---| -| T14 pause/resume | `POST /api/pause/{name}`, `POST /api/resume/{name}` (`commands/agent.go:39`) | no | -| T17 apply model | `PUT /api/config/agent/{name}/models` (`commands/agent.go:262`) | no | -| T19 apply ACMM | `GET /api/acmm/evaluation`, `POST /api/acmm/issue` (`dashboard/api.go:228-229`) | no | -| T20 kick now | `POST /api/kick/{name}` (`commands/agent.go:158`) | no | - -Two **read** tasks are affected too, and one of them is named after the gap: - -- **T2 (#4917) is "API client core + `/api/health`" — `/api/health` is not in - the spec.** It is a real endpoint (`hivectl system health` calls it, - `commands/system.go`), just an undocumented one. -- **T4 needs the agent list. `/api/agents` is not in the spec** either, though - `hivectl agent list` calls it (`commands/agent.go:24`). The spec has - `/api/status` and `/api/config/agent/{name}`, but no list operation. - -Of the four pane endpoints, only three are published: `/api/status`, -`/api/config/governor`, `/api/tokens` and `/api/events` are all in the spec; -`/api/agents` is not. +tells a sub-issue what to do when an operation is missing from it. This section +records how large that gap actually is. + +**Originally measured at `45a13d5`, the spec was GET-only: 32 routes, zero +writes.** [#5023](https://github.com/kubestellar/hive/pull/5023) closed most of +that — it found the 32-vs-298 comparison had been made against +`dashboard/server.js`, a legacy Node prototype that `dashboard/README.md` +states v2 production never starts, and re-measured against the live Go server. + +Current state: + +| Method | Published in `dashboard/openapi.json` | +|---|---:| +| Distinct `/api` paths | 255 | +| `GET` | 131 | +| `POST` | 83 | +| `PUT` | 64 | +| `DELETE` | 14 | + +The Phase 2 action tasks now have a contract to build against, and the two read +tasks that were blocked are unblocked: **`/api/agents` is in the spec** (`get`, +`post`), as is `/api/acmm/evaluation` and `/api/events`. + +The Phase 2 action endpoints are all published — note the path parameter is +`{agent}`, not `{name}`: `POST /api/pause/{agent}`, `POST /api/resume/{agent}`, +`POST /api/kick/{agent}`, plus `/api/agents/{name}/kicks`. + +**One endpoint remains unpublished by design.** `GET /api/health` (used by +`hivectl system health`, `commands/system.go`) sits in the parity test's +documented exception set as "not part of the client data contract", alongside +`/api/health/deep`, `/api/livez`, `/api/docs`, `/api/contribute/ws`, +`/api/terminal/assertion/renew` and the legacy `/api/v1/` catch-all. T2 should +treat it as a deliberate exception rather than a gap to fill. + +`TestOpenAPISpecCoversEveryRegisteredRoute` +(`src/pkg/dashboard/openapi_route_parity_test.go`, added by #5023) now fails if +a registered route and the spec diverge in either direction, so this section +should not go stale again silently. **What this means for sub-issues.** The epic's own escape hatch applies and should be used deliberately rather than as a surprise: a task whose endpoint is @@ -294,5 +293,5 @@ running Hive, no Docker, no network. - [`hivectl`](../hivectl.md) — the non-interactive client for the same API, and the command this TUI is a subcommand of. - [`api-reference.md`](../api-reference.md) — the dashboard API as documented - for humans; see [section 2.1](#contract-status-the-spec-covers-11-of-the-api-and-no-writes) + for humans; see [section 2.1](#contract-status-the-spec-now-covers-writes) for how much of it `dashboard/openapi.json` actually publishes. diff --git a/src/docs/env-vars.md b/src/docs/env-vars.md index 57e54d829..f2d6d8e7e 100644 --- a/src/docs/env-vars.md +++ b/src/docs/env-vars.md @@ -34,6 +34,14 @@ This reference is compiled by hand from the Go source under `src/`, the deployme | `HIVE_FEDERATION_REGISTRY_PATH` | No | `/data/federation/registry.json` | Federation registry path override. | | `HIVE_WEBHOOK_SECRET` | No | none | HMAC secret for the spoke `/webhook` channel. | | `GITHUB_WEBHOOK_SECRET` | No | `/data/saas/webhook-secret.key` when present | Hub GitHub webhook HMAC secret. | +| `HIVE_DASHBOARD_URL` | No | none | Base URL the `hive tui` client targets (`pkg/tui/client`). A bad value surfaces as a request error on the first call, not at startup. | +| `HIVE_CONVERGENCE_MODE` | No | `convergence.mode` in `hive.yaml`, else `off` | Process-level override of the convergence mode (`off`, `shadow`, `enforce`) so an operator can flip shadow mode without editing `hive.yaml`. Any unrecognised value — a typo, or a mode this build does not know — resolves to `off`. | +| `HIVE_WATCHDOG_PAUSE` | No | unset (not paused) | Fleet-wide watchdog kill switch (`1`, `true`, `yes`, `on`). Read at every config resolve, so it takes effect without a restart. It can only ever REDUCE authority: it never turns a watchdog on and never promotes observe to heal. | +| `HIVE_DELEGATION_CHAIN_ENABLED` | No | disabled | Enables delegation chain minting (`1`, `true`, `yes`, `on` — same spelling as `HIVE_METRICS_ENABLED`). Read on each call rather than cached, so disabling it on a misbehaving spoke does not require a pod roll. | +| `HIVE_ALLOW_PRIVATE_GIT_SOURCE` | No | `false` | Opt-in to knowledge Git sources whose host resolves to a private/internal address (self-hosted GitLab and similar). Off by default as SSRF protection. | +| `HIVE_SHARED_AGENT_HOME` | No | per-agent HOME | Escape hatch (`1`) restoring the legacy shared-HOME layout for agents. | +| `HIVE_WORKSPACE_CLEANUP_ENABLED` | No | enabled | Set `0` to opt out of automatic agent workspace cleanup. | +| `HIVE_DOSSIER_CACHE_MAX_ENTRIES` | No | `512` | Caps each public dossier cache. Bounds username-spray memory while keeping normal contributor reuse hot. | ## Generating and rotating `HIVE_DASHBOARD_TOKEN` @@ -107,9 +115,14 @@ new value at the same time. | `HIVE_PROXY_PORT` | No | `3001` | Node reverse-proxy/front-door port used by `src/deploy/entrypoint.sh`. | | `HIVE_STATIC_DIR` | No | `/opt/hive/proxy/public` | Static asset directory for the Node proxy. | | `HIVE_PROXY_EGRESS_MARK` | No | `0x1112` | Packet mark exempted from the MITM egress redirect. | -| `HIVE_PROXY_ADVISORY_OK` | No | `false` | Allows the spoke to start when the forced-proxy egress redirect cannot be installed (no `CAP_NET_ADMIN`/iptables). Enforcement becomes advisory-only — agents can bypass the proxy. Also gates whether the Go proxy trusts a self-asserted `Proxy-Authorization` header as agent identity when its UID map is unavailable (N7, #3841) — off by default, an unidentified caller is treated as `ADVISORY` (writes blocked) rather than whatever name it claims. See [security-model.md](security-model.md#forced-proxy-egress-f5-and-cap_net_admin). | +| `HIVE_PROXY_ADVISORY_OK` | No | `false` | Allows the spoke to start when the forced-proxy egress redirect cannot be installed (no `CAP_NET_ADMIN`/iptables). Enforcement becomes advisory-only — agents can bypass the proxy. Also gates whether the Go proxy trusts a self-asserted `Proxy-Authorization` header as agent identity when its UID map is unavailable (N7, #3841) — off by default, an unidentified caller is treated as `ADVISORY` (writes blocked) rather than whatever name it claims. See [security-model.md](security-model.md#forced-proxy-egress-and-cap_net_admin). | | `HIVE_TMUX_HISTORY_LIMIT` | No | `50000` | tmux scrollback depth applied when an agent session is created (positive integer; the authoritative knob for terminal scrollback and full-log capture). | | `HIVE_TTYD_HISTORY_LIMIT` | No | `50000` | Defense-in-depth history-limit raise applied at browser attach time; only affects panes created after attach. | +| `HIVE_TMUX_PANE_WIDTH` | No | `200` | Column count agent tmux sessions are created with. A detached tmux session defaults to 80 columns because no attached client supplies a size. | +| `HIVE_KICK_LOG_DIR` | No | `/data/logs/kicks` | Root directory per-kick log archives are written under. On the persistent volume so archives survive restarts, pod rolls, and image upgrades. | +| `HIVE_KICK_LOG_RETENTION` | No | `10` | Archived kick logs kept per agent. `0` disables archiving entirely. | +| `HIVE_KICK_LOG_MAX_BYTES` | No | `67108864` (64 MiB) | Per-agent total size cap across archived kick logs. | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | No | none | OTLP trace exporter endpoint. Tracing stays disabled while unset. | | `HIVE_WIKI_GIT_URL` | No | none | Optional wiki vault URL cloned into `/data/vaults/hive-wiki` on first boot. | ## Inference, CLI backends, and agents @@ -137,6 +150,13 @@ new value at the same time. | `HIVE_EXPLAIN_MODE` | No | `off` | **Fallback** for the hive-wide default agent explain mode (`off`, `brief`, `full`) — see [agent-configuration.md](agent-configuration.md#explain-mode-debugging-agent-behaviour). `governor.explain_mode` in `hive.yaml` (Settings → Governor → General in the dashboard) takes precedence; this variable applies only when that is unset. Either way it applies only to agents that leave `explain_mode` unset; an agent with an explicit value, including `off`, keeps it. Hive also injects the *resolved* mode into every agent process under this same name. An unrecognized value resolves to `off`. | | `BD_DIR` | No | current directory | `bd` beads CLI data directory. | | `BD_DASHBOARD_URL` | No | none | Dashboard URL used by `bd kb` integration. | +| `OPENAI_API_KEY` | No | none | OpenAI-compatible API key consulted by backend/model resolution. | +| `CODEX_API_KEY` | No | none | API key consulted for the Codex CLI backend. | +| `HIVE_AGENT_TOKEN_REFRESH_INTERVAL` | No | `40m` | Go duration overriding the per-agent token refresh interval. Invalid or non-positive values fall back to the default. | +| `HIVE_CREDENTIAL_WATCHDOG_INTERVAL` | No | `5m` | Go duration overriding how often the credential watchdog verifies each in-use backend credential file. `0` does NOT disable the watchdog — disabling is intentionally not offered. | +| `HIVE_COPILOT_SESSION_REFRESH_INTERVAL` | No | `10m` | Go duration overriding the Copilot session refresh interval. | +| `HIVE_COPILOT_SESSION_REFRESH_START_DELAY` | No | `30s` | Go duration overriding the delay before the first Copilot session refresh. | +| `HIVE_CLAUDE_DANGEROUSLY_ALLOW_HOST_STATE` | No | unset | Bypasses the Claude host-state isolation guard. As the name says, unsafe outside local development. | | `HIVE_CONN__URL` | No | generated from agent connection config | Agent API connection URI variable when a connection omits `env_name`; `` is the uppercased connection name with `-` replaced by `_`. | | Custom connection auth env vars | No | none | If an agent API connection uses `auth.type: env`, Hive reads `auth.env_var` and injects that exact variable into the agent. | @@ -181,6 +201,36 @@ Inside an **agent** session (set by the hive, never by the operator): ISSUES_ONL | `OCI_AVAILABILITY_DOMAIN` | Required for OCI FSS provisioning | none | OCI availability domain. | | `OCI_MOUNT_TARGET_ID` | Required for OCI FSS provisioning | none | OCI mount target OCID. | | `OCI_EXPORT_SET_ID` | Required for OCI FSS provisioning | none | OCI export set OCID. | +| `HIVE_HUB_ADMIN_USERNAME` | No | none | Single hub admin username. Consulted alongside `HIVE_HUB_ADMINS`. | +| `HIVE_HUB_ADMINS` | No | none | Comma-separated hub admin usernames. | +| `HIVE_HUB_GITHUB_TOKEN` | No | none | Hub-side GitHub token used by the dibs public-repo check. | +| `HIVE_REACH_REPO_DIR` | No | none (GitHub compare API) | Local clone the reach ancestry check resolves against via `git merge-base --is-ancestor`. The hub image ships no clone, so the compare-API adapter is the default. | +| `HIVE_REACH_NEVER_RAN_DAYS` | No | `3` | Never-ran grace period in days (integer, > 0). Absent or invalid values fall back to the default. | +| `HIVE_PROVISION_WORKERS` | No | saved scale setting, else built-in default | Provision queue worker count. The saved dashboard scale setting takes precedence over this variable. | +| `HIVE_PROVISION_PER_CLUSTER` | No | saved scale setting, else built-in default | Maximum concurrent provisions per cluster. | +| `HIVE_KUBECTL_MAX_PER_CLUSTER` | No | saved scale setting, else built-in default | Maximum concurrent `kubectl` executions per cluster. | +| `HIVE_UPGRADE_WAVE_SIZE` | No | saved scale setting, else built-in default | Number of spokes upgraded per wave. | +| `HIVE_UPGRADE_DEBOUNCE_SECONDS` | No | built-in default | Debounce window before an upgrade wave starts. | +| `HIVE_UPGRADE_MAX_HOLD_SECONDS` | No | built-in default | Maximum time an upgrade may be held before proceeding. | + +### Spoke-side derived keys + +A hub-hosted spoke is provisioned with only the derived sub-keys it needs and +never receives the master `HIVE_HUB_SECRET`. When one of these is unset, the +spoke derives the same domain-separated sub-key from `HIVE_HUB_SECRET`, so a +spoke still rolling on an older Deployment keeps working. Both sources yield the +identical key, so hub verification succeeds either way; a lookup fails closed +only when neither is configured. + +| Variable | Required | Default | Purpose | +|---|---:|---|---| +| `HIVE_HEARTBEAT_KEY` | No | derived from `HIVE_HUB_SECRET` | Spoke heartbeat signing sub-key. | +| `HIVE_SESSION_KEY` | No | derived from `HIVE_HUB_SECRET` | Spoke session-cookie signing sub-key. | +| `HIVE_INVITE_KEY` | No | derived from `HIVE_HUB_SECRET` | Per-hive contributor-invite signing key. Symmetric: the spoke both mints and verifies invite tokens with it. | +| `HIVE_TERMINAL_KEY` | No | self-derived per-hive from `HIVE_HUB_SECRET` + `HIVE_ID` | Per-hive terminal-assertion signing key. It never falls back to a fleet-uniform key. | +| `HIVE_SSO_PUBLIC_KEY` | No | none | Ed25519 **public** key a spoke verifies hub-minted SSO handoff tokens with. Holding only the public key, a spoke can verify but cannot mint. | +| `HIVE_SSO_PUBLIC_KEY_PREV` | No | none | Previous SSO public key, accepted during rotation so a spoke bridges a hub key change. | +| `HIVE_SSO_KEY` | No | none | Legacy symmetric SSO key, still read for one release so spokes on a pre-cutover Deployment keep working. | ### Hub login providers @@ -221,7 +271,8 @@ With two or more providers configured, `/login` renders a provider picker; with | `CONTRIBUTOR_MODE` | No | `interactive` | Contributor relay mode: `interactive` uses tmux; `headless` uses one-shot CLI execution for supported backends. | | `HIVE_HEADLESS_STATUS_FILE` | No | `/tmp/contributor-headless-status.json` | Status file written by headless contributor relay. | | `HIVE_CONTRIBUTOR_IMAGE` | No | `ghcr.io/kubestellar/hive-contributor:latest` | Image used by `just contribute-hive`. | -| `HIVE_CONTAINER_RUNTIME` | No | autodetect `docker` or `podman` | Container runtime override for contributor helpers. | +| `HIVE_CONTAINER_RUNTIME` | No | autodetect `docker` or `podman` | Container runtime override for contributor helpers. `just contribute-hive` also passes the runtime it resolved into the container, so the attach hints printed from inside it name the engine that actually launched it rather than assuming `docker` ([#5145](https://github.com/kubestellar/hive/issues/5145)). | +| `HIVE_CONTAINER_NAME` | No | `hive-contributor` | Set by `just contribute-hive` on the container it starts. The contributor entrypoint and relay read it for their attach hints; unset means the relay is running in local mode, where the hint is a plain `tmux attach`. | | `HIVE_SKIP_VERSION_CHECK` | No | `false` | Skips `just` version freshness check when set to `true`. | | `HIVE_SKIP_PULL` | No | `false` | Skips contributor image pull when set to `true`. | | `HIVE_KEEP_CONTAINER` | No | remove failed contributor container | Keeps failed contributor containers for debugging when set to `true`. | @@ -268,6 +319,19 @@ The code is authoritative and this table is hand-maintained, so it drifts unless PRs update it. **If your change adds, renames, or removes an environment variable lookup, update this file in the same PR.** +A CI guard (`TestEnvVarsDocDocumentsOnlyRealVariables`, in +`src/pkg/config/env_vars_doc_parity_test.go`) enforces **one** direction of +this: every variable given a table row here must actually appear in the +implementation, so the reference cannot document something nothing reads. It is +one-directional on purpose — env var names reach `os.Getenv` through package +constants, config-resolved struct fields, injected `getenv` parameters, and +local wrapper helpers, so no static check can enumerate the full set of +variables the code reads. + +**The converse is therefore not enforced: adding a lookup without adding a row +here will not fail CI.** Keeping this file complete remains a human +responsibility, which is what the rest of this section is for. + What counts as a change that needs an entry: - A new `os.Getenv` or `os.LookupEnv` call in `src/` — most live in diff --git a/src/docs/examples/verify-delegation-chain/main.go b/src/docs/examples/verify-delegation-chain/main.go index 24bf85f44..69661f310 100644 --- a/src/docs/examples/verify-delegation-chain/main.go +++ b/src/docs/examples/verify-delegation-chain/main.go @@ -264,7 +264,7 @@ func loadKeyDocument(ref string) (keyDocument, error) { if herr != nil { return keyDocument{}, herr } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return keyDocument{}, fmt.Errorf("hub returned HTTP %d", resp.StatusCode) } diff --git a/src/docs/forge-app-setup.md b/src/docs/forge-app-setup.md new file mode 100644 index 000000000..ec09a79b9 --- /dev/null +++ b/src/docs/forge-app-setup.md @@ -0,0 +1,222 @@ +# Forge setup: GitLab, Gitea, and Forgejo + +> **Read this first.** On **GitHub.com and GitHub Enterprise** the Forge App is a +> GitHub App and everything works — see [GitHub App setup](github-app-setup.md). +> On **GitLab, Gitea, and Forgejo** it does not. The adapters exist in the source +> tree and are tested, but they are **still not on a path a non-GitHub hive can +> reach**. One caller now constructs an adapter from `project.forge` — the +> governor's escalation writes ([#5259](https://github.com/kubestellar/hive/issues/5259)) +> — and it is a genuine first step, but nothing feeds it on a GitLab or Gitea +> hive, because enumeration still requires a GitHub client. There is no +> non-GitHub setup path to follow yet, and this page exists so you can establish +> that in one read instead of discovering it after an install. + +## Terminology + +The dashboard and docs call the app Hive installs on your source control system +the **Forge App**, and the docs call the system itself your **forge**. That +naming is deliberately forge-neutral because the code abstraction underneath it +is. The naming does not, on its own, imply the non-GitHub paths are finished — +this page is the difference. + +## What is supported today + +| Forge | Agents do work | Read API | Comment / label / hold | Open issues & PRs | Merge | Dashboard login | +| --- | --- | --- | --- | --- | --- | --- | +| GitHub.com | Yes | Yes | Yes | Yes | Yes | Yes | +| GitHub Enterprise | Yes | Yes | Yes | Yes | Yes | Yes | +| GitLab (SaaS or self-managed) | **No** | Adapter only, unwired | Wired but unreachable | **Not implemented** | **Not implemented** | **No** | +| Gitea | **No** | Adapter only, unwired | Wired but unreachable | **Not implemented** | **Not implemented** | **No** | +| Forgejo | **No** | Adapter only, unwired | Wired but unreachable | **Not implemented** | **Not implemented** | **No** | + +"Adapter only, unwired" means the Go code is written and covered by tests, but no +running code path calls it. "Wired but unreachable" is the narrower state the +comment/label column is now in: one production caller — the governor's +escalation sweep — selects its adapter from `project.forge` and would post the +evidence comment and `needs-human` label to your forge, but it is driven by the +GitHub-only enumeration the governor cycle starts from, which produces nothing +without a GitHub client. +The wiring is real; the input never arrives. `hold` is not in that caller and +stays unwired. Gitea and Forgejo share one adapter because they share one REST +API surface. + +**A hive cannot presently run against GitLab, Gitea, or Forgejo.** If that is +your forge, there is nothing to install and no configuration that will make +agents work. + +This is the state the [roadmap](roadmap.md) records for the abstraction: the +adapters are built, the first production caller is behind the seam, and what +remains is the read path — neutralizing enumeration, which today owns hold-label +filtering, issue filters and SLA tracking inside `pkg/github`. The design +rationale is [ADR-0005](adr/0005-forge-abstraction.md). + +## Why: the agent execution path is GitHub-only + +The forge abstraction and the thing agents actually run are two different +mechanisms, and only the second one moves work. + +Agents do not call the forge abstraction. They shell out to the **`gh` CLI**, +through the wrapper Hive installs ahead of it on `PATH` +(`bin/gh-wrapper.sh`), which injects a **GitHub App installation token** and +enforces the per-agent restriction rules. The higher-level helpers agents use to +file work — `bin/hive-open-issue.sh`, `bin/hive-open-pr.sh`, `bin/hive-merge.sh` +— are all built on that same `gh` path. There is no `glab` equivalent, no Gitea +CLI, and no forge-neutral shell entry point. + +The wrapper does not even let an agent call GitHub directly for writes: it +intercepts `gh pr create`, `gh issue create`, and `gh issue comment` and redirects +them to those helpers, which write request files that the hive daemon consumes +and executes with an App installation token. The GitHub MCP server's write tools +are explicitly denied for the same reason, so that route is closed too. + +So the write path that matters — open an issue, push a branch, open a PR, merge +it — is GitHub-shaped end to end, independently of anything `project.forge` says. + +Three further consequences follow, and each is load-bearing: + +- **Authentication.** Every credential path is a GitHub App: App ID, private key, + installation ID, per-tier minted installation tokens. GitLab and Gitea have no + GitHub App, so none of that machinery has an analogue. The adapters authenticate + with a plain personal access token instead, which is why they could never simply + inherit the existing credential plumbing. +- **Dashboard login.** Signing in uses GitHub OAuth or a configured OIDC provider. + There is no GitLab or Gitea login provider, including for a hive whose + `project.forge` names one. +- **Webhooks.** Both forge webhook receivers verify `X-Hub-Signature-256` and read + `X-GitHub-Event`. There is no GitLab (`X-Gitlab-Token`) or Gitea receiver. + +## Why the adapters exist anyway + +`src/pkg/forge` is a genuine, tested abstraction, written so Hive is not +permanently welded to GitHub. It defines a forge-neutral `Forge` interface plus +neutral types — note `ChangeRequest` rather than "pull request", since that is a +GitHub term — and three adapters: GitHub (wrapping `pkg/github`), GitLab (REST +v4), and Gitea/Forgejo (REST v1). + +All three implement the same operation set: + +| Operation | Meaning | +| --- | --- | +| `GetRepo` | Fetch one repo/project by its slug | +| `ListOpenIssues` | Open issues (GitHub's adapter filters out PRs, which its REST API models as issues) | +| `ListOpenChangeRequests` | Open pull requests / merge requests | +| `CreateIssueComment` | Comment on an issue or change request | +| `AddLabels` / `RemoveLabel` | Label maintenance | +| `SetHold` | Apply or clear the `hold` merge gate, via the label | + +What is deliberately **absent** matters as much as what is present: + +- **No merge primitive.** `Merger` is declared as an optional extension interface + that no adapter implements, and it carries a `TODO` in the source. Merge + semantics diverge too sharply across forges — strategy names, gate checks, + MR-versus-PR — to neutralize convincingly, so it was left explicit rather than + half-built. +- **No issue or change-request creation.** The interface has no `CreateIssue` and + no `CreatePR`. Even fully wired, the adapters could comment and label but could + not file the work. + +That second point is the ceiling on this path: the abstraction was scoped to the +read path plus light write operations, not to the create-and-merge lifecycle an +agent needs. + +## The configuration surface that exists + +These keys parse and validate today. They are documented here because they are +real and you will find them in the source — **not** because setting them makes a +non-GitHub hive work. They have two consumers: the dashboard's Platform card, +which reads them to display a forge name and instance URL, and the governor's +escalation writes, which build an adapter from them (see the table above for why +that caller is not reached on a non-GitHub hive today). + +```yaml +project: + forge: gitlab # "github" (default) | "gitlab" | "gitea" + +gitlab: + gitlab_url: https://gitlab.example.com # default https://gitlab.com + token_env: GITLAB_TOKEN # env var NAME, never the token + +gitea: + gitea_url: https://gitea.example.com # no default; required for Gitea + token_env: GITEA_TOKEN # env var NAME, never the token +``` + +Notes that will save you a wrong guess: + +- `gitlab:` and `gitea:` are **top-level** blocks, siblings of `github:` — not + nested inside it. +- `token_env` names the **environment variable to read the token from**. The + token value is never written to config, matching Hive's no-secrets-in-config + rule. Use `GITLAB_TOKEN` / `GITEA_TOKEN` unless you have a reason not to. +- Omitting `project.forge` means GitHub, so every existing config is unaffected. +- GitLab defaults to `https://gitlab.com`. Gitea has **no** default — there is no + single public Gitea host, so selecting the Gitea forge without a URL is a + configuration error at client construction. +- The instance URL is the **bare instance root**. The adapters append `/api/v4` + (GitLab) and `/api/v1` (Gitea) themselves; adding the suffix yourself produces + a doubled path. + +### Do not confuse `project.forge` with `github.forge` + +Two unrelated settings share the word "forge", and mixing them up is the most +likely way to break a working hive: + +| Key | Meaning | Values | +| --- | --- | --- | +| `project.forge` | Which forge **family** a spoke executes against — selects the `pkg/forge` adapter (read by the dashboard and by the governor's escalation writes; see above) | `github`, `gitlab`, `gitea` | +| `github.forge` | Which GitHub **instance** this hive's App and repos live on — drives App ID, app slug, and API URL | a GitHub host label | + +`github.forge` is part of the GitHub App identity system and is fully live. Do +not set it to `gitlab` or `gitea`; those are not GitHub hosts and it is not that +kind of setting. + +## If you evaluate the adapters anyway + +Should you exercise `pkg/forge` directly — from a test or your own program, which +is still the only way to reach most of it — the tokens it expects are ordinary +access tokens, not apps: + +| Forge | Credential | Sent as | +| --- | --- | --- | +| GitLab | Personal, project, or group access token | `PRIVATE-TOKEN` header | +| Gitea / Forgejo | Personal access token | `Authorization: token ` | + +Scope them to the repositories the hive would work on, with read access to +issues and merge/pull requests, plus write access to issue comments and labels if +you intend to exercise the comment/label/hold operations. A token may be omitted +entirely for unauthenticated reads of public projects; private projects and all +writes require one. Use a throwaway token — this is an evaluation path, not a +supported deployment. + +One further caveat if you do: Hive's egress proxy enforces ACMM policy only on +hosts registered for mode enforcement, which today are GitHub hosts and the +Linear API. A GitLab or Gitea host is not among them, so its traffic is tunneled +without request-level ACMM enforcement. An advisory-tier hive would not have its +writes to such a host gated the way it would on GitHub — another reason this path +is not deployment-ready. + +## What full support would require + +Listed so the size of the gap is legible, not as a plan of record: + +1. A forge-neutral enumeration path. Today the governor cycle starts at + `github.Client.EnumerateActionable`, which also owns hold-label filtering, + issue filters and SLA tracking, so neutralizing it means lifting that policy + above the forge boundary — this is what makes the already-wired escalation + writes unreachable on a non-GitHub hive. +2. `CreateIssue` and `CreateChangeRequest` on the `Forge` interface, plus a + settled `Merge` — the create-and-merge lifecycle agents depend on. +3. A forge-neutral agent execution path, since agents reach their forge through + the `gh` CLI wrapper rather than through `pkg/forge`. +4. Credential plumbing for token-based forges alongside the GitHub App minting + machinery, including per-tier scoping. +5. A GitLab/Gitea dashboard login provider. +6. Egress-proxy mode enforcement registration for the non-GitHub hosts. + +## See also + +- [GitHub App setup](github-app-setup.md) — the supported path: app creation, + permissions, Setup URL, and `/gh-setup`. +- [Getting started](getting-started.md) — first-session setup, including Step 0. +- [Troubleshooting](troubleshooting.md) — Forge App and credential symptoms. +- [Operator reference](operator-reference.md) — the full configuration surface. diff --git a/src/docs/general-technical-review.md b/src/docs/general-technical-review.md index 39ecc329f..df70a8a80 100644 --- a/src/docs/general-technical-review.md +++ b/src/docs/general-technical-review.md @@ -24,13 +24,6 @@ plainly in the relevant answers below rather than hidden behind a marker: - **No SLOs/SLIs** — the project defines no project-wide Service Level Objectives or Indicators, has run no controlled load test, and publishes no recommended capacity limits ([SLO/SLI](#describe-how-the-project-defines-service-level-objectives-slos-and-service-level-indicators-slis), [load testing](#describe-the-load-testing-that-has-been-performed-on-the-project-and-the-results), [limits](#describe-the-recommended-limits-of-users-requests-system-resources-etc-and-how-they-were-obtained)). -- **`NOTICE` data not yet authoritative** — the attribution mechanism now - exists (generator, `notice-drift` CI guard, release attachment), but the - committed `NOTICE` is a statically-derived placeholder whose license - fields all read `UNVERIFIED`. No license was inferred; the authoritative - `go-licenses` output requires one maintainer step, tracked in - [#5007](https://github.com/kubestellar/hive/issues/5007) - ([attribution](#what-steps-does-the-project-take-to-ensure-that-all-third-party-code-and-components-have-correct-and-complete-attribution-and-license-notices)). - **No formal compliance certification** — no SOC 2, FedRAMP, or other formal certification is pursued or claimed ([compliance](#describe-any-compliance-requirements-addressed-by-the-project)). - **No security-response on-call or diversity target** — the process is now @@ -183,7 +176,7 @@ The dashboard/hub REST API itself carries **no formal version scheme** for its s Two layers, both automated with no human tagging step in the normal path (`src/docs/releases.md`): 1. **Continuous delivery** — every merge to `v4` publishes moving image tags (`v4-latest`, the three channel tags, and an immutable short-SHA tag) via `.github/workflows/docker.yml`. -2. **Tagged semver releases** — `.github/workflows/release.yml` runs after every successful `docker.yml` build on `v4` and reads `CHANGELOG.md`'s `## Unreleased` section: an empty section means no release; a non-empty section triggers a release, with the bump inferred from which subsections are present — `### Security` → **major**, else `### Added` → **minor**, else (`### Changed`/`### Fixed`/`### Deprecated`) → **patch** (`releases.md` "What triggers a release"). A `` HTML-comment marker is the human escape hatch when inference would be wrong. `release.yml` never rebuilds — it retags the already-published, freshness-verified digest with `docker buildx imagetools create`, so the versioned image is byte-identical to the commit's `v4-latest`/short-SHA image, and generates a per-image SPDX JSON SBOM (Syft) attached to the GitHub Release (`releases.md` "How a release is actually built", "Software bill of materials (SBOM)"). +2. **Tagged semver releases** — `.github/workflows/tagged-release.yml` runs after every successful `docker.yml` build on `v4` and reads `CHANGELOG.md`'s `## Unreleased` section: an empty section means no release; a non-empty section triggers a release, with the bump inferred from which subsections are present — `### Security` → **major**, else `### Added` → **minor**, else (`### Changed`/`### Fixed`/`### Deprecated`) → **patch** (`releases.md` "What triggers a release"). A `` HTML-comment marker is the human escape hatch when inference would be wrong. `tagged-release.yml` never rebuilds — it retags the already-published, freshness-verified digest with `docker buildx imagetools create`, so the versioned image is byte-identical to the commit's `v4-latest`/short-SHA image, and generates a per-image SPDX JSON SBOM (Syft) attached to the GitHub Release (`releases.md` "How a release is actually built", "Software bill of materials (SBOM)"). ### Installation @@ -314,7 +307,7 @@ Through `CHANGELOG.md`'s `## Unreleased` section, which explicitly asks for entr #### Explain how the project permits utilization of alpha and beta capabilities as part of a rollout. -No formal alpha/beta feature-flag maturity system (like Kubernetes feature gates) exists. The closest analogs, both explicit and self-labeled: (1) **release channels** — `edge`/`candidate`/`stable`, all currently synced to the same `v4-latest` digest but designed as the promotion mechanism for future channel divergence (`src/docs/release-channels.md`); (2) **doc-level status labels** on features that are design-only, partly shipped, or shipped-but-unwired — e.g. the skill registry is explicitly documented as "loaded and counted on the dashboard, but not yet delivered to agents" (`src/docs/README.md` skills.md entry), `AGENTS.md` parsing is "parsed and tested, but not wired into kicks," and the `design/` directory indexes longer-form records each carrying a status of shipped/partly-shipped/design-only/historical specifically so a proposal is never mistaken for current behavior (`src/docs/README.md` "Historical/design notes"). The agent self-healing watchdog is a concrete example of graduated rollout via an explicit mode ladder: it ships in `observe` mode (classifies and would-have-acted, but takes no action) and only promotes to `heal` (acts) on operator decision, with `HIVE_WATCHDOG_PAUSE=true` as a fleet-wide downgrade switch (`src/docs/agent-watchdog.md`). +No formal alpha/beta feature-flag maturity system (like Kubernetes feature gates) exists. The closest analogs, both explicit and self-labeled: (1) **release channels** — `edge`/`candidate`/`stable`, all currently synced to the same `v4-latest` digest but designed as the promotion mechanism for future channel divergence (`src/docs/release-channels.md`); (2) **doc-level status labels** on features that are design-only, partly shipped, or shipped-but-unwired — e.g. the skill registry is explicitly documented as "loaded and counted on the dashboard, but not yet delivered to agents" (`src/docs/README.md` skills.md entry), `AGENTS.md` parsing was labeled "parsed and tested, but not wired into kicks" until its checkout root was threaded in [#5227](https://github.com/kubestellar/hive/issues/5227), and the `design/` directory indexes longer-form records each carrying a status of shipped/partly-shipped/design-only/historical specifically so a proposal is never mistaken for current behavior (`src/docs/README.md` "Historical/design notes"). The agent self-healing watchdog is a concrete example of graduated rollout via an explicit mode ladder: it ships in `observe` mode (classifies and would-have-acted, but takes no action) and only promotes to `heal` (acts) on operator decision, with `HIVE_WATCHDOG_PAUSE=true` as a fleet-wide downgrade switch (`src/docs/agent-watchdog.md`). ## Day 2 - Day-to-Day Operations Phase @@ -441,11 +434,13 @@ The process and its enforcement are in place; the authoritative data is not yet A repo-root `NOTICE` lists every Go module dependency compiled into `hive`, `hive-hub`, and `hive-contributor`, generated by `src/scripts/generate-notice.sh` (pinned `google/go-licenses`) and kept current by the `notice-drift` CI job in `go-security-analysis.yml`, which regenerates on every change to `src/go.mod`, `src/go.sum`, or the script — and weekly, since an upstream dependency can relicense with our files untouched. Tagged releases attach `NOTICE` alongside the per-image SBOMs (`src/docs/releases.md`). -**The committed `NOTICE` is currently a statically-derived placeholder.** It was assembled from `src/go.mod` without running Go tooling, so every license field reads `UNVERIFIED` and transitive modules present only in `go.sum` may be absent. No license identifier was inferred or fabricated — an attribution file asserting a wrong license is a false legal claim, which is worse than a missing one. The `notice-drift` job fails until a maintainer commits the authoritative `go-licenses` output; that single remaining step is tracked in [#5007](https://github.com/kubestellar/hive/issues/5007). Note `go.sum` pinning is integrity and provenance tracking, not license attribution, and never produced an assembled notice by itself. +The guard is enforcing rather than advisory, and it has already caught a real licensing problem rather than mere drift. On its first working run it flagged `github.com/fumiama/go-docx` — a **direct** dependency compiled into the shipped binary — as **AGPL-3.0**, which `go-licenses` classifies `FORBIDDEN` and which is incompatible with the project's Apache-2.0 posture. It backed one narrow function, read-only text extraction in the knowledge vault's `.docx` parser, and was removed in favor of a standard-library implementation (`archive/zip` + `encoding/xml`) that preserves behavior and adds no replacement dependency; `go mod tidy` dropped its transitive `github.com/fumiama/imgsz` with it. An AGPL dependency had been shipping undetected, and the attribution check is what surfaced it. + +Regenerating the committed `NOTICE` from the generator's verified output is ordinary maintenance carried by that CI job, not an open design question. The project does not infer or fabricate license identifiers: an attribution file asserting a wrong license is a false legal claim, and a field is left explicitly unverified rather than guessed. Note `go.sum` pinning is integrity and provenance tracking, not license attribution, and never produced an assembled notice by itself. #### Describe how the project ensures alignment with CNCF recommendations for attribution notices. -`NOTICE` and its generator are the project's attribution mechanism; see the answer above, including the placeholder caveat. Notices for unmodified third-party Go modules are covered by the generated file rather than by vendoring their license texts into the tree, and build artifacts carry it via the release attachment. The project ships no third-party code copied directly into its own source files, so there is no per-file header convention to maintain. +`NOTICE` and its generator are the project's attribution mechanism; see the answer above. Notices for unmodified third-party Go modules are covered by the generated file rather than by vendoring their license texts into the tree, and build artifacts carry it via the release attachment. The project ships no third-party code copied directly into its own source files, so there is no per-file header convention to maintain. ##### How are notices managed for third-party code incorporated directly into the project's source files? diff --git a/src/docs/getting-started.md b/src/docs/getting-started.md index 089be3d3e..c72ada566 100644 --- a/src/docs/getting-started.md +++ b/src/docs/getting-started.md @@ -42,11 +42,12 @@ The biggest mistake new users make: seeing agent output and either (a) panicking None of the level guidance below works until your hive is connected to your git host. Do this in your **first session**: -1. **Install the Forge App.** The Forge App is the app Hive installs on your forge (your source control system, e.g., GitHub, GitHub Enterprise, GitLab, or Gitea) — on **GitHub.com and GitHub Enterprise (GHE) it's a GitHub App**; on GitLab or Gitea it's the equivalent host app. This is how Hive talks to your repo. +1. **Install the Forge App.** The Forge App is the app Hive installs on your forge (your source control system, e.g., GitHub, GitHub Enterprise, GitLab, or Gitea) — on **GitHub.com and GitHub Enterprise (GHE) it's a GitHub App**. This is how Hive talks to your repo. GitLab, Gitea, and Forgejo are **not supported for running a hive** today — see [Forge setup: GitLab, Gitea, and Forgejo](forge-app-setup.md). - **From the dashboard (easiest):** click **Install Forge App** in the welcome checklist, or open **Governor Config → Forge App** and use the install link there. Grant the app access to your repo. - **On GitHub.com:** the install button sends you to `github.com/apps/` — pick your org/repo and approve. - **On GitHub Enterprise (IBM, corporate):** the same flow lives on your **GHE host**, not github.com — the install page is `https:///github-apps/`. Make sure your hive is pointed at your GHE host URL (Governor Config → Forge App shows which host is configured). - **Self-hosting or creating the app yourself?** See the [GitHub App setup guide](github-app-setup.md) for app creation, permissions, and the `/gh-setup` flow. + - **On GitLab, Gitea, or Forgejo?** There is no Forge App to install: those forges are **not supported for running a hive** today, and the rest of this guide assumes GitHub. See [Forge setup: GitLab, Gitea, and Forgejo](forge-app-setup.md) for what is and is not implemented. 2. **⏰ Don't put this off.** Unconfigured hive instances are reclaimed on a timer. Finish the Forge App install in your first session or your hive may be reaped — see [What if my hive disappeared?](#what-if-my-hive-disappeared-inactive-hive-reaping) below. 3. **Wait for the first heartbeat.** After installing, a heartbeat cycle has to run (a few minutes) before everything lights up green. @@ -219,6 +220,26 @@ New users often expect PRs at L2 (they don't happen) or are surprised when they > 💡 **Tip: customize before you escalate.** Before moving from L3 to L4, take 30 minutes to edit each agent's policy template. Add your coding conventions, your preferred test framework, your off-limits directories. Agents follow instructions literally — the more specific you are, the better the output. +### 🔒 Where agents actually run (read this before L3) + +By L3, agents are writing code and running commands on your behalf — so it's worth knowing exactly where that happens. On the contributor path (`just contribute-hive `), agents run in a **tmux session on the host** by default: the backend CLI runs as your own user, with permission prompts bypassed, and nothing containing it to the assigned workspace unless the backend provides its own confinement. + +**Confinement is not the same for every backend.** As of this writing: + +| Backend | Confined? | +|---|---| +| `claude` / `litellm` | Yes — Claude Code's native OS sandbox | +| `codex` | Yes — its own `workspace-write` sandbox | +| `copilot` | Yes — Copilot CLI's own `--sandbox`, checked at launch | +| `opencode` | Partial — a command deny-list only, **not** a filesystem sandbox | +| `goose`, `agy`, `bob`, `pi`, `aider` | No — these backends have no confinement mechanism at all. Local mode **refuses to launch** for them unless you explicitly set that backend's own `HIVE__DANGEROUSLY_RUN_UNCONFINED=1` | + +If you see one of those `_DANGEROUSLY_RUN_UNCONFINED` variables mentioned in setup instructions, it means exactly what it says: that backend has no sandbox, and setting the variable is you accepting that the agent runs with full access to your machine. Prefer container mode (drop `local` from the command) or a confined/denylisted backend if you're running hive on a machine you care about. + +This matters beyond backend choice, too: the hub-side Podman sandbox (`agent_sandbox`) is a separate, opt-in mechanism, and enabling it requires setting **both** the global `agent_sandbox.enabled` flag **and** a per-agent `sandbox.enabled` flag — the dashboard's Security tab only writes the global one, so turning that toggle on by itself does not sandbox any agent. + +See [sandbox-isolation.md](sandbox-isolation.md) for the full threat model, the complete per-backend matrix, and the hub-side sandbox setup. + ## L4 — Issues and Security **The level:** You're trusting agents to *file issues on their own* and trusting sec-check to propose security fixes — still all hold-labeled. @@ -249,10 +270,12 @@ New users often expect PRs at L2 (they don't happen) or are surprised when they **What you get:** The full hive works for you. Architect produces RFCs for bigger design changes. You shift from doing the work to batch-reviewing it. **Un-pause:** supervisor, architect — and yes, now you can un-pause brainstorm too -**Leave paused:** nothing +**Leave paused:** telemetry, operations — unless you opt in (see below) ⚠️ **Set cadences first:** Every newly un-paused agent gets the gear treatment: all modes **12h or 1d**. More agents running = faster token burn, so this matters more than ever. +**Two new agents appear at L5:** `telemetry` and `operations` become available for the first time — they don't exist in the roster at any lower level — but they remain **paused by default**, even here. They're opt-in on purpose: to activate them, open **Settings → Project Observability**, select your managed project's observability stack (OpenTelemetry, Prometheus, Grafana, a `ServiceMonitor`, a commercial backend — whatever you actually run), and save. Saving replaces each agent's paused cadence with a conservative `24h` interval, which you can then tune from its Cadences tab like any other agent. Leave the tab unconfigured and both agents stay paused — there's no rush to enable them just because you reached L5. See [Telemetry agent](telemetry.md) and [Operations agent](operations.md) for what each one actually does, and [agent-configuration.md](agent-configuration.md) for the `project_observability` config block. + **Using the findings:** Batch-review on a schedule (say, twice a week). Approve the PRs you like, decline the ones you don't, 👍 the issues that match your roadmap. > 💡 **Tip: batch-review in one sitting.** Reviewing ten agent PRs in a single hour teaches you the agents' patterns faster than reviewing one per day. Patterns jump out when the PRs sit side by side — repeated habits, favorite files, blind spots. @@ -270,10 +293,12 @@ New users often expect PRs at L2 (they don't happen) or are surprised when they **What you get:** A repo that improves itself while you sleep. The tests quality built at L3 are now the guardrails that keep agents honest. **Un-pause:** everything stays on from L5 -**Leave paused:** nothing +**Leave paused:** telemetry, operations — still opt-in, unless you already enabled them at L5 ⚠️ **Cadence check:** You can shorten cadences now if your token budget allows — but 12h/1d still works fine. Faster isn't better if you're not reading the output. +Telemetry and operations don't auto-enable just because you reached L6 — they carry the same opt-in requirement here as at L5 (**Settings → Project Observability**). If you enabled them at L5, they stay on and switch to full mode (auto-merge on green CI) like the rest of your roster. + **Using the findings:** Spot-check merged PRs weekly. 👍 issues to steer agent priorities. **Building tests:** Keep improving your tests — they're your only gatekeeper now. Every test you add makes the automation safer. diff --git a/src/docs/github-app-setup.md b/src/docs/github-app-setup.md index 2c3407b7c..7317eb6e3 100644 --- a/src/docs/github-app-setup.md +++ b/src/docs/github-app-setup.md @@ -2,6 +2,13 @@ > **Terminology:** the dashboard and docs call this the **Forge App** — the app Hive installs on your forge (your source control system, e.g., GitHub, GitHub Enterprise, GitLab, or Gitea). On **GitHub.com and GitHub Enterprise (GHE)** the Forge App **is a GitHub App**; this page covers creating and installing it. Dashboard controls live under **Governor Config → Forge App**. +> **On GitLab, Gitea, or Forgejo?** This page does not apply, and there is no +> equivalent setup to perform: those forges are **not supported for running a +> hive** today. The adapters exist in the source tree but are not wired into any +> running code path, and the agent execution path is GitHub-only. See +> [Forge setup: GitLab, Gitea, and Forgejo](forge-app-setup.md) for exactly what +> is and is not implemented before you attempt an install. + Hive can authenticate with either a personal access token or a GitHub App. Use a GitHub App for production hives because installation tokens are scoped to selected repositories and can author PRs as the app bot when `github.app_authored_prs` is enabled. ## Personal access token (PAT) scopes @@ -46,8 +53,17 @@ Recommended values: - **GitHub App name**: any unique operator-owned name. - **Homepage URL**: your project or Hive dashboard URL. -- **Setup URL**: `https:///gh-setup`. -- **Redirect on update**: enabled. +- **Setup URL** (optional): `https:///gh-setup`. `` means + **the address you type into your browser to reach this hive** — not where the + hive process runs. GitHub redirects *your browser* here; it never fetches this + URL itself, and neither does the hive. See + [Choosing a Setup URL](#choosing-a-setup-url) before setting it, and leave it + blank if the hive is not reachable from the browser you administer it with — + Hive discovers `installation_id` on its own. +- **Redirect on update**: enabled — but only alongside a Setup URL that is + actually reachable. It re-fires the redirect on every repository add or + removal, so an unreachable Setup URL produces a dead browser tab every time + you change the installation, not just once at install. - **Webhook**: inactive unless you separately configure webhook channels; the dashboard setup flow does not require webhooks. - **Device Flow**: enabled, so dashboard login can use the app's client ID. - **Visibility**: private for an organization-specific app; public only if you intentionally operate one app for many unrelated owners. @@ -138,7 +154,7 @@ line above from its logs. ```yaml github: app_id: - installation_id: # optional when /gh-setup can complete it + installation_id: # optional; auto-discovered (see below) app_slug: key_file: /secrets/gh-app-key.pem oauth_client_id: @@ -146,6 +162,56 @@ github: For GitHub Enterprise, also set `api_url`/`base_url` or the supported `forge` value so install URLs and API calls target the same host. +## Choosing a Setup URL + +The Setup URL is a **browser redirect target**, not a callback GitHub's servers +make. After an install — and, with **Redirect on update** enabled, after every +repository add or removal — GitHub sends whatever browser you were using to +that address. So the only question that matters is: + +> Can the browser I administer this hive from open that URL? + +That is a property of how *you* reach the hive, not of where the hive runs: + +| How you reach the dashboard | Correct Setup URL | +| --- | --- | +| Desktop session on the hive machine | `http://localhost:3001/gh-setup` | +| SSH tunnel (`ssh -L 3001:localhost:3001 you@hive`) | `http://localhost:3001/gh-setup` — the tunnel makes `localhost` genuinely the hive | +| Another machine on the network | `http://:3001/gh-setup` | +| Public hostname behind TLS | `https:///gh-setup` | +| Not reachable from your browser at all | **leave it blank** — see below | + +`localhost` is the common trap. It is correct only for the first two rows, and +it fails silently everywhere else: set it while sitting at the hive machine and +it works, then administer the hive from a laptop and every install-update +redirect lands on the laptop's own port 3001, which is nothing at all. + +### Headless, NATed, and remotely administered hives + +**The Setup URL is optional. A hive that is not reachable from anywhere should +leave it blank and turn Redirect on update off.** Nothing is lost. + +The callback's only job is to save you from pasting an `installation_id`, and +Hive already resolves that itself: it asks GitHub `GET /orgs/{org}/installation` +authenticated with the **App JWT**, falling back to walking +`GET /app/installations`. That runs when the hive starts, whenever App +credentials are (re)loaded, and when you press **Re-check** on the dashboard's +Forge App panel — adopting and persisting an unambiguous match, so it corrects a +missing *or wrong* `installation_id` without any redirect. An ambiguous or +absent result leaves your configuration untouched rather than guessing. + +Every one of those calls is **outbound, hive to GitHub**. A headless Fedora +CoreOS host, a VM on a NAT network, or any server you only reach over SSH needs +no inbound reachability for GitHub App authentication to work. The +preconditions are the ones App auth already has: the private key mounted at +`key_file`, and `project.org` set so Hive knows whose installation to look for. + +If you do leave a Setup URL configured that your browser cannot reach, the +resulting dead redirect is cosmetic. Adding a repository modifies the existing +installation rather than creating a new one, so the `installation_id` in that +redirect is one the hive already holds — the failed page does not mean App +authentication is broken. + ## `/gh-setup` flow When the app's Setup URL points at `https:///gh-setup`, GitHub redirects back with `setup_action` and, for install/update, `installation_id`. diff --git a/src/docs/hive-merge.md b/src/docs/hive-merge.md new file mode 100644 index 000000000..65e903c70 --- /dev/null +++ b/src/docs/hive-merge.md @@ -0,0 +1,149 @@ +# `hive-merge` — merge a PR as the App bot + +`bin/hive-merge.sh` is how an agent merges a pull request. Agents call it +**instead of the GitHub MCP `merge_pull_request` tool** (or a GraphQL +`mergePullRequest` mutation). GitHub rejects that mutation for App +installation tokens with "Resource not accessible by integration" even when +the token holds `contents:write` + `pull_requests:write`. The hive merges the +PR over REST with the App token instead, which succeeds — so the merge is +authored by the App bot (`[bot]`) and uses the transport that actually +works for an App token. + +It does not merge the PR itself. It writes a request file that the hive's +merge-request watcher picks up and acts on. + +## Why it exists + +Two reasons. + +**Transport.** The MCP/GraphQL merge path is rejected outright for App +installation tokens. Routing through the hive's REST-based merge path is not +optional convenience — it's the only path that works. + +**Authorization + forge resistance.** The script runs **as the agent**, in +that agent's tmux session under that agent's UID, so the request file it +writes is owned by that UID. The watcher re-derives the requesting agent from +the **file's owner**, not from anything written inside the file. `hive-merge` +**adds no privilege**: the watcher enforces the same per-agent forge-resistance +check and a `CanMerge` ACMM gate (`AuthorizeMerge`) that a direct merge would +need — an agent whose mode allows opening PRs but not merging them still +cannot merge through this path. + +On top of `AuthorizeMerge`, the watcher's authorizer is wrapped with a second, +target-specific check (the F4 target-binding, CWE-863) before any merge is +attempted: + +- the request must carry a non-empty `expect_sha` — an empty expected SHA + would mean "merge whatever HEAD is now," which is a TOCTOU hole: a commit + pushed after the PR was judged eligible could get merged unseen; +- the `(repo, number)` pair must currently appear in the governor's + merge-eligible list **at that exact head SHA** — so an agent cannot request + landing an arbitrary reachable PR (including its own) merely because its + checks happen to be green; it must be a PR the hive's governor already + deemed eligible, at the commit it reviewed. + +Both the agent/UID/`CanMerge` check and this target-binding check must pass +before the merge is attempted. + +**The hive does not force-merge.** With admin bypass disabled, GitHub still +enforces branch protection (required checks like build-gate). A PR whose +required checks aren't green fails here, and the result file records why. + +## Usage + +```sh +hive-merge --repo --number [--method squash|merge|rebase] \ + [--expect-sha ] [--update-branch] +``` + +| Flag | Required | Default | +| --- | :---: | --- | +| `--repo` | yes | — | +| `--number` (alias `--pr`) | yes | — | +| `--method` | no | `squash` | +| `--expect-sha` | no | auto-resolved (see below) | +| `--update-branch` | no | off | + +Both `--flag value` and `--flag=value` forms work. `--squash`, `--merge`, and +`--rebase` are accepted as method shorthands. `--admin` is accepted and +**ignored** — the hive never admin-bypasses branch protection. + +`--repo` and `--number` must resolve, or the script exits `2`. `--number` must +be an integer, or the script exits `2`. + +### `--expect-sha` is auto-resolved, not optional in effect + +The merge-request watcher **requires** a non-empty `expect_sha` (the F4 TOCTOU +guard above) — a request with none is denied outright. Rather than push that +burden onto every call site, the script resolves the PR's *current* head SHA +itself when `--expect-sha` is not given, using the cached App token at +`/var/run/hive-metrics/gh-app-token.cache`, and pins it into the request. The +head is captured at request time, closing the TOCTOU window exactly as +intended, so existing call sites that never passed `--expect-sha` keep +working unchanged. + +If the SHA cannot be resolved (no token cache, `gh` unavailable, or the +lookup fails), the script prints an error naming the token-cache path and +**exits `3`** rather than writing a request with no pinned head. + +### `--update-branch` + +When set, the watcher syncs the PR branch with its base before attempting the +merge (resolves the common "behind main" case). A failure to update the +branch is not fatal — the merge attempt still proceeds and surfaces the real +blocker. + +## It is asynchronous, by design + +On success the script prints the request path and returns `0`. **The PR has +not been merged yet at that point.** It merges on the next watcher tick +(polling every 10 seconds). + +To confirm, poll the `.result.json` written next to the request file, or +simply check whether the PR is merged. + +## Retries and what happens when a merge can't land + +The watcher retries a failed merge attempt up to **3 attempts total**, +tracked across ticks via the prior `.result.json`. What happens after the +final attempt depends on why it failed: + +- **A required check is failing or pending** (classified from GitHub's own + branch-protection error text, e.g. "required status check ... has not + succeeded") — the request is quarantined (renamed `.exhausted`), but the + hive re-engages its fix loop for that PR instead of abandoning it, subject + to a per-red-SHA re-dispatch cap. +- **The blocker is unfixable by pushing code** — a true merge conflict or a + permission error (matched against GitHub's own wording, e.g. "merge + conflict", "not accessible by integration", "403", "must be a member") — + the request is quarantined (`.exhausted`) and not retried further; the + result file records the last error. + +An authorization denial (forge-resistance failure, `CanMerge` gate failure, +or F4 target-binding failure) is not retried at all — the request file is +renamed `.denied` immediately and the result file records the reason. + +A malformed request file (invalid JSON) is renamed `.bad`. + +## Where things live + +| Path | What | +| --- | --- | +| `/var/run/hive-metrics/merge-requests` | request files the watcher consumes | +| `/var/run/hive-metrics/gh-app-token.cache` | cached App token, used to auto-resolve `--expect-sha` | +| `/var/run/hive/uid-map.json` | UID → agent-name map, used for a nicer log line | + +The UID map is **informational only** here. The watcher re-derives ownership +from the file's UID regardless. + +## Related + +- [`hive-open-pr`](hive-open-pr.md) — the equivalent relay for opening a PR; + same request-file mechanism and authorship model +- [Security threat model](security-threat-model.md) — forge resistance and the + UID-ownership anchor +- [Agent configuration](agent-configuration.md) — ACMM levels and the + merge gate (`CanMerge`, `ModeIssuesPRsMerge`) that governs whether an agent + may merge a PR at all +- [Audit log](audit-log.md) — merges are recorded there with the requesting + agent diff --git a/src/docs/hive-open-issue.md b/src/docs/hive-open-issue.md new file mode 100644 index 000000000..4a6679ca6 --- /dev/null +++ b/src/docs/hive-open-issue.md @@ -0,0 +1,139 @@ +# `hive-open-issue` — create an issue, comment, or claim as the App bot + +`bin/hive-open-issue.sh` is how an agent creates an issue, posts a comment, or +claims an issue. Agents call it **instead of `gh issue create` / +`gh issue comment`**. + +It does not perform the GitHub write itself. It writes a request file that the +hive's issue-request watcher executes with the App installation token — +server-side, retried with backoff, and deduplicated by exact open-issue title. + +## Why it exists + +The direct path rode the agent's own shell tool, and that path lost work +silently. Root-caused live on 2026-08-21 on a hosted hive: the sec-check +agent's `gh issue create` timed out mid-flight — repeatedly — and the finding +it was recording survived only as a bead, not as the intended GitHub issue. +One GHE secondary-rate-limit stall, a network blip, or a mangled multiline +command was enough to lose a finding with no visible failure. + +Routing through this script makes the agent's job "record the request" — a +local file write that takes milliseconds and cannot fail on network — and the +hive owns delivery: retried, backed off, and immune to the agent's own shell +timing out. + +**Authorization + forge resistance.** The script runs **as the agent**, in +that agent's tmux session under that agent's UID, so the request file it +writes is owned by that UID. The watcher re-derives the requesting agent from +the **file's owner**, not from anything written inside the file. The watcher +enforces the same per-agent mode gate (`CanCreateIssues`, mode ≥ +`ISSUES_ONLY`) and UID forge-resistance as the direct `gh` path would need — +this shim **adds no privilege**. The same `CanCreateIssues` gate covers all +three kinds (issue, comment, claim): commenting and claiming an issue are +both issue-writes under the same tier. + +## Usage + +Three shapes, selected by an optional leading positional keyword +(`comment` or `claim`; the default with no keyword is `issue`): + +```sh +hive-open-issue --repo --title "" [--body ""|--body-file f] [--label a,b] +hive-open-issue comment --repo --body "" +hive-open-issue claim --repo +``` + +| Flag | Aliases | Applies to | Notes | +| --- | --- | --- | --- | +| `--repo` | `-R` | all | required | +| `--title` | `-t` | issue | required for `issue` | +| `--body` | `-b` | issue, comment | required for `issue` and `comment`; not used by `claim` | +| `--body-file` | `-F` | issue, comment | reads body from a file; `-` reads stdin | +| `--label` | `-l` | issue | repeatable | +| `--number` | — | comment, claim | the issue/PR number; a bare positional number or a `.../issues/N` or `.../pull/N` URL is also accepted | + +Both `--flag value` and `--flag=value` forms work. Flags `gh` accepts but this +path does not need — `--assignee`/`-a`, `--milestone`/`-m`, `--project`/`-p`, +`--template`/`-T`, `--web`/`-w`, `--editor`/`-e` — are **accepted and +ignored** (the value-taking ones correctly consume their following argument so +it isn't misread as the issue number). + +### `issue` (default) + +`--repo`, `--title`, and `--body` are all required — an empty body is treated +as an agent bug, not a valid issue, and the script exits `2` rather than +letting the watcher quarantine it later. This is a deliberate, pinned contract +(`bin/test_hive_open_issue.sh`), not an oversight. `--label` may be repeated; +labels are always sent as a JSON array (empty if none given). + +### `comment` + +`--repo`, a number or URL, and `--body` are all required, or the script exits +`2`. + +### `claim` + +`--repo` and a number or URL are required; no body or title needed. A claim +records that this agent is starting work on an issue. Because App bots cannot +be GitHub assignees, the watcher applies a `hive/claimed-by-` label +instead — the visible, auditable ownership signal — and audits it as +`agent_issue_claimed`. + +## It is asynchronous, by design + +On success the script prints the request path and returns `0`. **The +issue/comment/claim has not happened yet at that point.** It executes on the +next watcher tick (polling every 10 seconds); poll the `.result.json` written +next to the request file for the resulting number/URL. + +## Retries and what happens when a request can't be fulfilled + +Unlike the merge and PR-open watchers' fixed attempt caps, the issue-request +watcher backs off **exponentially per request**: starting at 30 seconds and +doubling up to a 15-minute ceiling. A request that still hasn't succeeded +after **24 hours** is given up on and quarantined (renamed `.failed`), so a +persistently failing request cannot hammer the forge indefinitely and the +queue directory cannot grow without bound. + +A request that is structurally invalid — missing required fields for its +kind, or an unrecognized kind — is rejected before authorization or any API +call and quarantined immediately (renamed `.bad`); it is never retried, since +no amount of retrying changes a shape that can never succeed. Likewise, +invalid JSON in the request file is quarantined `.bad`. An authorization +denial (forge-resistance failure or `CanCreateIssues` gate failure) is +quarantined `.denied` immediately, also without retry — policy won't change +on the next tick. + +### Idempotency + +Issue creation is deduplicated by exact (whitespace-trimmed) title against +open issues in the target repo (scanning up to the 3 most recent pages). If a +matching open issue already exists, the watcher reuses it instead of creating +a duplicate — this is what makes the retry loop safe: a create that actually +succeeded server-side but crashed before the request file was consumed (or an +agent-side "timed out but maybe it worked" ambiguity) never produces a second +issue. The result file's `already_existed` field reports which case +happened. + +## Where things live + +| Path | What | +| --- | --- | +| `/var/run/hive-metrics/issue-requests` | request files the watcher consumes | +| `/var/run/hive/uid-map.json` | UID → agent-name map, used for a nicer log line | + +The UID map is **informational only** here. The watcher re-derives ownership +from the file's UID regardless. + +## Related + +- [`hive-open-pr`](hive-open-pr.md) — the equivalent relay for opening a PR; + same request-file mechanism and authorship model +- [`hive-merge`](hive-merge.md) — the equivalent relay for merging a PR +- [Security threat model](security-threat-model.md) — forge resistance and the + UID-ownership anchor +- [Agent configuration](agent-configuration.md) — ACMM levels and the + `CanCreateIssues` gate that governs whether an agent may create issues, + comment, or claim at all +- [Audit log](audit-log.md) — issue creation, comments, and claims are + recorded there with the requesting agent diff --git a/src/docs/hivectl.md b/src/docs/hivectl.md index f38dffd7b..0d2839e53 100644 --- a/src/docs/hivectl.md +++ b/src/docs/hivectl.md @@ -137,6 +137,38 @@ hivectl observe timeline hivectl observe trends --range week # or --hours 12 (1-720); not both ``` +### tui — live terminal dashboard + +```bash +hivectl tui +``` + +Opens a full-screen, keyboard-driven view of the fleet over the same dashboard +API the non-interactive subcommands use, so it honours the same `--hive` / +endpoint configuration. Requires a real terminal; press `q` or `ctrl+c` to +exit. + +**Under active construction.** Four panes sit in a 2×2 grid, and only half are +wired to live data today: + +| Pane | State | +|---|---| +| Agents | live — polls `GET /api/agents` | +| Tokens | live — per-agent rows and fleet total | +| Governor | stub — renders its title, pending T7 | +| Events | stub — renders its title, pending T11 | + +`tab` moves focus between panes; `q` or `ctrl+c` exits. Those are the only keys +bound: no help overlay, no pause/resume, no resize handling yet. + +Note the command's own `--help` text describes an event feed — that is the +end-state design, not what ships today. + +Track progress under the `hive tui` epic +([#4907](https://github.com/kubestellar/hive/issues/4907)); the open `tui T*` +issues list what is still missing. Prefer the web dashboard or the +non-interactive subcommands above for anything you need today. + ### enroll — spoke-based lite repo enrollment ```bash diff --git a/src/docs/hooks.md b/src/docs/hooks.md index 740a82747..1c82e617d 100644 --- a/src/docs/hooks.md +++ b/src/docs/hooks.md @@ -177,7 +177,7 @@ Places a request on the [#4000](https://github.com/kubestellar/hive/issues/4000) ## Predicates (`when:`) -An optional CEL expression, evaluated against the transition payload bound to `t`. Empty means "always fire". It uses the same engine and the same fail-closed posture as `triggers:` (`pkg/celtrigger`). +An optional CEL expression, evaluated against the transition payload bound to `t`. Empty means "always fire". It uses the same engine and the same fail-closed posture as `triggers:` (`pkg/celtrigger`) — for the `triggers:` config key itself (declarative agent triggering on source-control events, a separate surface from hooks that happens to share this engine), see [CEL-based agent triggers](cel-triggers.md). ```yaml hooks: diff --git a/src/docs/linear-agent.md b/src/docs/linear-agent.md index ab96020db..ebe8502fa 100644 --- a/src/docs/linear-agent.md +++ b/src/docs/linear-agent.md @@ -185,13 +185,38 @@ connected to it. ### Session kicks and governor kicks A delegated issue reaches the hive twice: the webhook opens an agent -session (kicked immediately to `session_agent`), and — with +session (kicked immediately to the session agent), and — with `assigned_only: true` — the same issue is enumerated into the governor's -backlog on the next sweep. This mirrors GitHub, where a webhook-channel kick -and a governor kick for the same issue also coexist. The governor kick -rotates the session's kick log, which the responder reports into the -session as "finished this run"; the follow-on run carries on with the same -identifier in its work list. +backlog on the next sweep. Kicks never interrupt a running agent +(`SendKick` waits for the CLI's input prompt), so the risk is a *re-hand*: +the governor kicking the same issue again the moment the session's run +ends, or a second agent in the lane taking it in parallel. + +The session tracker is therefore the in-flight ledger. While a session is +`working`, the scheduler withholds its issue from every governor kick's +`${ISSUE_LIST}` and `IssueRefs`, and says so in an **In Flight** note +appended at the same seam as the tracker section (`${IN_FLIGHT}` places it +explicitly). The hold releases when the session finishes — its kick log +archives — or fails. GitHub-sourced items are never session-held. + +### Which agent takes sessions + +`work_source.linear.session_agent` when set; otherwise the sole configured +agent; otherwise the sole enabled agent whose ACMM mode allows tracker +writes (`ISSUES_ONLY` and above) — which is what makes the L3 pack (six +agents, quality the only writer) work without extra config. Two or more +writers is ambiguous and the session is acknowledged with an error naming +the setting. + +### PRs in the session + +When the hive's `hive-open-pr` watcher opens a PR for an agent with an +active session, the PR is narrated into the session as an `action` +activity and attached to the session's external links +(`agentSessionUpdate.externalUrls`), so the person who delegated the issue +sees where the work landed before the run ends. Linear's GitHub integration +attaches the same PR to the *issue* on its own; this is the session +surface. ## Proxy enforcement @@ -234,3 +259,11 @@ workspace: 8. **PR auto-link**: open a PR on a branch named `/team-123-slug` with `Fixes TEAM-123` in the body and confirm Linear attaches it and moves the issue to In Progress, then Done on merge. +9. **Session PR link**: with a session `working`, have the agent open a PR + through `hive-open-pr` and confirm the session shows an "Opened pull + request" activity and the PR under its external links. +10. **In-flight withholding**: while a session is `working`, trigger a + governor kick for the same agent and confirm the delegated issue is + absent from its work list and named under "In Flight"; after the run + ends, confirm it is handed out again on the next sweep (or has left the + enumerated states via the PR). diff --git a/src/docs/manual-provisioning.md b/src/docs/manual-provisioning.md index fd392c792..b3feadd4c 100644 --- a/src/docs/manual-provisioning.md +++ b/src/docs/manual-provisioning.md @@ -161,7 +161,7 @@ kubectl apply -k . > **Which image tags exist.** `ghcr.io/kubestellar/hive` carries the channel > tags (`stable`, `candidate`, `edge`, `v4-latest`) and a short-SHA tag per > merge. A `vX.Y.Z` **image** tag exists only when the automated tagged-release -> workflow (`.github/workflows/release.yml`, see +> workflow (`.github/workflows/tagged-release.yml`, see > [Tagged releases](releases.md)) has cut that version — it retags the merge's > short-SHA images. That workflow landed after the `v4.0.0` git tag, so there is > **no `:v4.0.0` image**; `newTag: v4.0.0` is an `ImagePullBackOff`. Pin a diff --git a/src/docs/net-admin-requirement.md b/src/docs/net-admin-requirement.md index 49e2d0eab..4a9837c8b 100644 --- a/src/docs/net-admin-requirement.md +++ b/src/docs/net-admin-requirement.md @@ -92,7 +92,7 @@ netfilter lock contention) still exits `1`, since granting `NET_ADMIN` would not fix those. The escape hatch is the same as always: set `HIVE_PROXY_ADVISORY_OK=true` to -start anyway in advisory-only mode (see [security-model.md](security-model.md#forced-proxy-egress-f5-and-cap_net_admin)), +start anyway in advisory-only mode (see [security-model.md](security-model.md#forced-proxy-egress-and-cap_net_admin)), or grant the capability per the section below for the full gate. ## How to get the full gate diff --git a/src/docs/operations.md b/src/docs/operations.md new file mode 100644 index 000000000..ae8fdb8b1 --- /dev/null +++ b/src/docs/operations.md @@ -0,0 +1,82 @@ +# Operations Agent + +The operations agent audits and, once opted in, improves the **operational readiness of the managed project** — health checks, SLO/SLI definitions, alerting, runbooks, and release/rollback safety. It is an L5/L6-only agent: absent from the roster below L5 and paused in every governor mode at L5/L6 until an operator explicitly opts in. + +## What the operations agent does + +On each kick, operations follows its ACMM-level policy template (`operations-advisory.md`, `operations-holdgated.md`, or `operations-full.md`) and: + +- Inspects health and readiness endpoints, SLO/SLI definitions, user-impact alert rules, runbooks, incident/postmortem templates, release procedures, and rollback paths in the repositories it is authorized to audit (`$HIVE_REPOS`). +- Verifies that health/readiness probes check every dependency required to serve the state they claim (a probe that reports healthy without checking a required dependency is treated as a defect, not a pass). +- Flags alerts without runbook links, machine-state alerts with no user impact, undocumented rollback paths, and SLOs without measurable indicators. +- Never weakens an existing alert or SLO to make reported health look better — this is a hard rule in every mode, including PR-capable ones. + +At **advisory** level (ACMM L2–L4, though operations does not actually appear in the roster until L5 — see [ACMM level gating](#acmm-level-gating) below), it writes each confirmed finding as an advisory bead owned by `operations` and returns an `AgentReport` with `kind: "findings"`. It never creates GitHub issues, branches, commits, or pull requests in this mode. + +At **hold-gated** (L5) and **full** (L6) modes, operations runs in `ISSUES_AND_PRS` and, in addition to filing findings, can open bounded, hold-gated pull requests: health and readiness handlers, SLO/SLI definitions, user-impact alert rules with runbook links, `runbooks/*.md`, incident and postmortem templates, and release/rollback documentation or safeguards. It re-verifies and closes only stale beads it owns, and files findings with an `[operations]` title. + +Operations must never, at any PR-capable level: merge its own PR; weaken an existing alert or SLO to improve reported health; or add a probe that reports healthy without checking a dependency required to serve traffic. At L5 it additionally must never remove a `hold`/`on-hold`/`do-not-merge` label; at L6 it must not modify work already labeled `hold`, `on-hold`, or `do-not-merge`. + +## ACMM level gating + +Telemetry and operations are **L5/L6-only opt-in agents**. Per the built-in ACMM packs (`src/pkg/config/packs/level-5.yaml`, `level-6.yaml`): + +- They are absent from the roster entirely at L1–L4 — no pack below L5 defines an `operations` entry, so the agent does not appear in the dashboard, does not spawn a pane, and cannot be kicked. +- At L5 and L6 they are present but their cadence is `paused` in **every** governor mode (`surge`, `busy`, `quiet`, `idle`) until an operator opts in — the pack literally sets `operations: paused` in all four cadence tables at both levels. +- `kick_template` is `operations-holdgated.md` at L5 and `operations-full.md` at L6, matching the hold-gated-vs-full PR behavior described above. + +## When to enable operations + +Enable operations once you're comfortable with the rest of your L5/L6 roster and want automated operational hardening for the *managed* project — health/readiness handlers, SLO definitions, alert rules tied to runbooks, and rollback documentation. It is most useful once telemetry (or an existing observability stack) already gives it signal to reason about: SLOs and alert rules are only as good as the metrics behind them. + +## How to opt in: `governor.project_observability` + +Un-pausing the agent alone does nothing useful — operations' PR-capable policies fail closed without a confirmed target stack. Configure the opt-in under **Settings → Project Observability** in the dashboard: + +```yaml +governor: + project_observability: + open_source: [opentelemetry, prometheus, grafana] + kube_native: [servicemonitor] + commercial: [honeycomb] + references: + honeycomb: + endpoint_env: OTEL_EXPORTER_OTLP_ENDPOINT + credential_secret: observability/honeycomb-key +``` + +Reference fields accept **names only** — an environment-variable name or a `secret-name/key` reference. Literal endpoints, tokens, and API keys are rejected. Selecting platforms and saving persists them under `governor.project_observability`, and replaces operations' (and telemetry's) all-mode `paused` cadence with a conservative `24h` interval, which can then be tuned from the agent's Cadences tab. + +After telemetry's first advisory run, platforms mentioned in its findings are preselected as suggestions in the Project Observability tab. They stay unsaved until an operator reviews and clicks **Save** — only then does the persisted declaration govern future operations (and telemetry) work. + +## How operations interacts with other agents + +Operations' lane keywords (`healthz`, `readyz`, `readiness`, `slo-`, `sli-`, `service-level-objective`, `service-level-indicator`, `error-budget`, `runbook`, `incident-response`, `rollback`, `alerting`) are disjoint from telemetry's instrumentation-focused keywords, so the two agents do not compete for the same issues. Both share the same `${PROJECT_OBSERVABILITY}` prompt section and the same `governor.project_observability` configuration — telemetry adds the instrumentation; operations builds the health/SLO/runbook layer on top of it. + +## Configuration reference + +Registered defaults (`applyKnownAgentDefaults` in `src/pkg/config/config.go`), applied when a field is left blank in your own config: + +| Field | Default | +|-------|---------| +| `emoji` | 🚨 | +| `color` | `#d35400` | +| `aliases` | `["op"]` | +| `bead_role` | `worker` | +| `sort_order` | `66` | +| `include_repos` | `true` | +| `lane_keywords` | `healthz`, `readyz`, `readiness`, `slo-`, `sli-`, `service-level-objective`, `service-level-indicator`, `error-budget`, `runbook`, `incident-response`, `rollback`, `alerting` | +| `detect_keywords` | `operations`, `operability`, `healthz`, `runbook` | + +ACMM packs additionally set `backend: copilot`, `model: claude-sonnet-4-6`, `mode: ISSUES_AND_PRS`, `stale_timeout: 28800`, and the level-appropriate `kick_template`. + +## Cadence and budget considerations + +Operations is a heavyweight, PR-capable agent once enabled. Follow the same guidance as every other agent un-paused at L5/L6: set all modes to `12h` or `1d` first (the automatic un-pause already lands at a conservative `24h`), watch its output for a few cycles, and only shorten the cadence once you understand what it's producing and how much budget it consumes per run. + +## What to read next + +- **[Telemetry Agent](telemetry.md)** — the companion L5/L6 opt-in agent that instruments the project operations can then build SLOs and alerts on top of. +- **[Agent Configuration](agent-configuration.md)** — every agent field, the ACMM level packs, and `project_observability` details. +- **[ACMM Policy Matrix](acmm-policy-matrix.md)** — the full per-level, per-agent policy table, including the L5/L6-only gating note. +- **[Getting Started](getting-started.md)** — when to opt in as part of the level-climbing journey. diff --git a/src/docs/operator-reference.md b/src/docs/operator-reference.md index f14b21123..489589dba 100644 --- a/src/docs/operator-reference.md +++ b/src/docs/operator-reference.md @@ -52,6 +52,7 @@ Top-level YAML keys accepted by `config.Config`: | `github` | PAT or GitHub App credentials and forge URLs. | Use one auth method. | | `notifications` | ntfy, Slack, and Discord webhooks. | All optional; see [notifications.md](notifications.md). | | `dashboard` | Web UI port, snapshots, auth token, frame allowlist, authorized users. | `auth_token` can come from `HIVE_DASHBOARD_TOKEN`. | +| `agent_sandbox` | Podman-rootless sandbox launcher for hub/pod agents (`pkg/sandbox`). | Opt-in and **two-gate**: this block's own `enabled: true` sandboxes nothing by itself — each agent also needs `sandbox.enabled: true` under `agents.`. The dashboard's Security tab writes only this global flag, so enabling it there alone can leave every agent unconfined — but the tab now shows this: `security.sandboxWarnings` in `GET /api/config/governor` carries `config.AgentSandboxGateWarnings`'s diagnosis (also logged at WARN at boot/reload), rendered both in the page's coherence-warnings box and inline under the toggle, naming the still-unconfined agents and the fixing key (#4918). See [sandbox-isolation.md](sandbox-isolation.md) and the [getting-started confinement section](getting-started.md#where-agents-actually-run-read-this-before-l3). | | `data` | Metrics, logs, session, and agent overlay directories. | Defaults are `/data/...` in containers. | | `knowledge` | Wiki layers, vaults, git/document sources, primer, curator, bead synthesizer. | Disabled unless `enabled: true`. | | `hub` | Hub/spoke hosted-hive metadata. | Usually provisioner-owned. | diff --git a/src/docs/releases.md b/src/docs/releases.md index ad69880e3..1a1191ba2 100644 --- a/src/docs/releases.md +++ b/src/docs/releases.md @@ -9,12 +9,27 @@ no human ever pushes a tag or clicks "Draft a release" in the normal path. ## What triggers a release -`.github/workflows/release.yml` runs after every successful +`.github/workflows/tagged-release.yml` runs after every successful `Build and Push Docker Image` (`docker.yml`) run on `v4` — a `workflow_run` trigger, not a tag push, because there is no tag until this workflow decides to create one. It never runs for `v2`, `mk`, `dd`, or a manual `workflow_dispatch` build. +It also runs **hourly on a schedule**, as a backstop (#5318). The +`workflow_run` trigger alone can silently lose a release opportunity: a +`docker.yml` run that is *cancelled* never fires `workflow_run` at all, and a +run that fires but finds `v4` already advanced stands down in favour of a +successor that may itself stand down. Standing down is correct — the run's +images were built from the older tree, so tagging would name content those +images do not contain — but nothing used to come back for the abandoned work. +The scheduled pass evaluates `v4`'s **current** tip, whose images have long +since been published, so it retags an existing digest exactly as the normal +path does. It refuses to act unless `docker.yml` has a *successful, completed* +push run for that tip, so a cancelled or in-flight build never produces a tag +with no digest behind it, and it is a no-op whenever `## Unreleased` is empty +— which on a healthy repository is almost always. Deferrals are logged as +warnings so a skipped opportunity is visible rather than silent. + `src/scripts/derive-release-version.sh` then decides two things by reading [`CHANGELOG.md`](../../CHANGELOG.md)'s `## Unreleased` section — nothing else, no commit-message parsing: @@ -93,9 +108,9 @@ errors loudly) rather than a silent pick — remove all but one. |---|---|---| | `v4-latest`, `stable`, `candidate`, `edge` | `docker.yml`, every merge to `v4` | Yes — moving pointers | | `<7-hex-sha>` | `docker.yml`, every successful build | No — immutable, but not a *release* | -| `v1.2.3` | `release.yml`, only when a release is cut | No — immutable, and **is** the release | +| `v1.2.3` | `tagged-release.yml`, only when a release is cut | No — immutable, and **is** the release | -`release.yml` never writes `stable`/`candidate`/`edge`. Channel promotion is a +`tagged-release.yml` never writes `stable`/`candidate`/`edge`. Channel promotion is a separate, deliberate policy described in [release-channels.md](release-channels.md); cutting a version tag never silently couples to it, on purpose — the operator explicitly asked for these @@ -103,10 +118,10 @@ to stay decoupled. ## How a release is actually built -`release.yml` does **not** rebuild the image. `docker.yml`'s own freshness +`tagged-release.yml` does **not** rebuild the image. `docker.yml`'s own freshness guard already proved, for this exact commit, that the pushed digest's embedded commit hash matches — rebuilding would only reintroduce the risk -that guard exists to eliminate. Instead, `release.yml` retags the +that guard exists to eliminate. Instead, `tagged-release.yml` retags the already-published `<7-hex-sha>` digest as the immutable version tag with `docker buildx imagetools create`, the same primitive `src/scripts/publish-image-tags.sh` already uses for the moving tags. The @@ -127,13 +142,80 @@ Concretely, per release: 4a. Syft generates an SPDX JSON SBOM for each of the three retagged images (see "Software bill of materials (SBOM)" below) — this happens before the changelog commit, using the version tag written in step 3. -5. That change is committed (`git commit -s`, signed off by the release bot) - and pushed to `v4`, then the workflow creates and pushes the `v` - git tag on that commit. -6. A GitHub Release is created from the tag, with GitHub's auto-generated +5. That change is committed (`git commit -s`, signed off by the release bot). +5a. Before it can reach `v4`, the commit has to earn the `gate` check that + branch protection requires (see "Satisfying branch protection" below). + The commit is pushed to a throwaway `release-gate/v` branch, + `docker.yml` is dispatched, and the workflow waits for `gate` to succeed + on that exact SHA. It then mirrors the verified result as a SHA-scoped + `gate: success` commit status so a release PR can see it. +6. The workflow opens a PR from the scratch branch into `v4` and merges it + through the SHA-keyed merge API, leaving branch protection fully enforced. + It deletes the scratch branch, then creates and pushes the `v` tag + on the commit that landed on `v4`. +7. A GitHub Release is created from the tag, with GitHub's auto-generated notes plus an SBOM callout, and the three SBOM files from step 4a attached as release assets. +## Satisfying branch protection + +`v4`'s only required context is `gate` (`docker.yml`). The release commit is +created inside `tagged-release.yml`, so it has no check when it first exists; +a direct push to `v4` is rejected (`GH006: Required status check "gate" is +expected`, [#5026](https://github.com/kubestellar/hive/issues/5026)). Retrying +does not create the missing evidence, so every attempt fails identically. + +The workflow first pushes the commit to `release-gate/v`, dispatches +`docker.yml`, and waits for its `gate` check-run on the exact release SHA. +That verifies the same code path as an ordinary PR gate, but a +`workflow_dispatch` check-run has no pull-request association: its +`pull_requests` list remains empty even if it is dispatched after the release +PR exists. Consequently GitHub's protected-PR rollup omits it and the merge +API still reports `gate` as expected ([#5356](https://github.com/kubestellar/hive/issues/5356)). + +After the check-run succeeds, the workflow posts a `gate: success` commit +status on the same SHA using its `GITHUB_TOKEN` and `statuses: write` +permission. A commit status is SHA-scoped rather than check-suite/PR-scoped, +so it appears in the release PR's required-context rollup. This is a mirror, +not a second source of truth: a missing or red docker gate prevents the status +from being posted, a failed status POST prevents the PR from opening, and the +SHA-keyed merge API still asks GitHub to enforce `v4` protection server-side. + +**Getting `docker.yml` to actually run on the scratch branch (#5072):** +`docker.yml`'s `push` trigger is `branches: ["**"]` (minus bot branches — see +`.github/release-lines.yml`'s `unpinned` entry for it), which on paper covers +`release-gate/*` too — but the scratch push uses this job's default +`GITHUB_TOKEN`, and GitHub deliberately does not start *other* workflow runs +from a `GITHUB_TOKEN`-authenticated push (recursive-workflow prevention). The +`push` trigger silently never fires, no `gate` check ever attaches to the +commit, and the wait loop times out — every release run failed this way until +#5072. `docker.yml` also has a `workflow_dispatch` trigger, which a +`GITHUB_TOKEN` *can* start via the API (`gh workflow run docker.yml --ref +release-gate/v`), and that run's check-runs attach to the scratch +branch's head SHA exactly as a `push`-triggered run's would — so this step +dispatches it explicitly right after the scratch push, rather than relying on +the `push` trigger. + +`workflow_dispatch` on `docker.yml` normally forces a GHCR push regardless of +branch (so a throwaway branch can be published for a hive on demand) — which +would mean every release pushes a real, one-off `release-gate/v` +image and moving tag to GHCR purely to obtain a status check that only needs +the `gate` job (a few seconds) to run. `docker.yml`'s `gate` job carries a +`release-gate/*` exception so that never happens, on any trigger: the scratch +branch name is deliberately not in `docker.yml`'s `LONG_LIVED` set (`v2 v4 mk +dd`) and the exception forces `push=false` for it unconditionally, so this +detour never pushes a GHCR image or moves a channel tag; `gate` runs +regardless of push policy, which is all this needs. The scratch branch is +deleted by the merge step's `trap ... EXIT` once that step starts, whether the +PR merges or fails. A failure during the preceding gate-earning step leaves the +branch in place for diagnosis. + +This preserves branch protection exactly as configured — no bypass, no +weakened check, no `enforce_admins` change, and no force push. The workflow +earns the real docker gate on the scratch branch, mirrors that exact-SHA +verdict into the representation the release PR can consume, and lets the +protected merge endpoint make the final decision. + ## Software bill of materials (SBOM) Every tagged release ships a downloadable SBOM per image — `hive`, @@ -180,7 +262,7 @@ must stay a plain manifest. The release SBOM generated here never touches that constraint. It runs **after** `docker.yml` has already published the plain-manifest image -(`release.yml` retags, never rebuilds — see above), scans the published +(`tagged-release.yml` retags, never rebuilds — see above), scans the published digest from the outside with an independent tool, and writes an ordinary JSON file that is uploaded to the GitHub Release. Nothing about generating it adds an attestation to the GHCR image, changes its media type, or touches the @@ -218,27 +300,17 @@ request touching `src/go.mod`, `src/go.sum`, or the generator script itself a version bump), and fails the build if the committed file differs from a fresh run. A generated file that can silently go stale is worse than no file at all — it would make a false completeness claim the moment a dependency -changed. There is deliberately no special-case in that job to tolerate an -out-of-date `NOTICE` indefinitely: the first time it runs against a real Go -toolchain it will very likely find the committed placeholder (see below) -stale, and that is the correct, actionable failure — the fix is the same one -line either way, "run the script, commit the result." - -**Current state of the committed file, stated plainly.** The `NOTICE` file -introduced alongside this section was assembled in an environment that could -not run `go` tooling at all, so it could not produce the real, resolved -module graph. It is derived **statically from `src/go.mod`'s require -blocks only** (not `go.sum`, not the full transitive graph, not verified -license text), and every entry's license field reads `UNVERIFIED` rather than -guessing — an attribution file with a wrong license identifier is a false -legal claim and is worse than an admitted gap. The very next CI run of -`notice-drift` after this merges is expected to fail once, showing a full -diff against the real, `go-licenses`-generated content; a maintainer applies -that regenerated output (or re-runs `generate-notice.sh` locally with `go` -installed) and commits it, after which `NOTICE` is the authoritative, -license-text-included file and `notice-drift` keeps it that way going -forward. This is not a hidden gap: `NOTICE`'s own header states the same -thing in the file itself. +changed. There is deliberately no special-case to tolerate an out-of-date +`NOTICE`: the fix is always "run the script, commit the result." + +**Current state of the committed file.** `NOTICE` is the authoritative, +`go-licenses`-generated output from the resolved module graph. The generator +uses `go-licenses report` rather than `save`: `save` refuses to emit anything +when the graph contains a license class the tool considers incompatible, +while an attribution inventory must identify every dependency, including a +restrictively licensed one. Inclusion in `NOTICE` records what ships; it is +not an approval or compatibility decision. License-acceptance policy belongs +in a separate gate so it cannot make this inventory incomplete. **What `NOTICE` does NOT cover.** Go module dependencies only. It does not cover: @@ -257,7 +329,7 @@ cover: not carry), and the SBOM gives full package inventory (OS + language runtime layers) that `NOTICE` does not attempt. -**Shipped in releases.** `release.yml` copies the repo-root `NOTICE` (already +**Shipped in releases.** `tagged-release.yml` copies the repo-root `NOTICE` (already kept fresh by `notice-drift` at every commit that changes dependencies) to `hive-v-NOTICE` and attaches it to the GitHub Release alongside the three SBOM files, using the same `gh release create` asset-upload call. @@ -266,7 +338,7 @@ three SBOM files, using the same `gh release create` asset-upload call. - **Step 5 emptying `Unreleased`** is what makes this safe to chain off `docker.yml`: pushing the release commit to `v4` triggers `docker.yml` - again, which triggers `release.yml` again — and on that second pass + again, which triggers `tagged-release.yml` again — and on that second pass `Unreleased` is empty, so `derive-release-version.sh` returns `release=false` and the workflow is a no-op. It never chases its own tail. - `concurrency: { group: tagged-release-v4, cancel-in-progress: false }` @@ -289,7 +361,7 @@ build time via `-ldflags -X main.version=...`, exactly like the existing (every ordinary branch build, including plain `docker.yml` runs) the Go linker default `0.0.0-dev` ships instead — never an empty string. -`release.yml` does not need to pass `VERSION` to a rebuild, because it never +`tagged-release.yml` does not need to pass `VERSION` to a rebuild, because it never rebuilds (see above) — the retagged image was already built by `docker.yml` carrying whatever `version` that ordinary build embedded. This is deliberate: today, a tagged-release image and its ``/`v4-latest` sibling report the @@ -304,7 +376,7 @@ concrete gap remains before the first automated `v0.x.y` should be trusted end-to-end: - **The running binary's `--version` output does not yet say `v1.2.3` for a - release build.** Because `release.yml` retags rather than rebuilds (by + release build.** Because `tagged-release.yml` retags rather than rebuilds (by design — see "How a release is actually built"), the image GHCR now calls `ghcr.io/kubestellar/hive:v1.2.3` still reports whatever `main.version` the original `docker.yml` build embedded, which today is always the diff --git a/src/docs/roadmap.md b/src/docs/roadmap.md index f324e1bd9..6c5db2131 100644 --- a/src/docs/roadmap.md +++ b/src/docs/roadmap.md @@ -22,7 +22,7 @@ order, not priority rank. | Prompt-injection defense-in-depth | Canary-token checks, output redaction, fail-closed scanning, and the optional model-based semantic classifier build on deterministic `ioscan` kick-path redaction. | [#2805](https://github.com/kubestellar/hive/issues/2805), [security threat model](security-threat-model.md), [ADR-0008](adr/0008-ioscan-untrusted-input.md) | | Intent verification | Tier-based change authorization is in the merge-gate path; trajectory-integrated intent-alignment review remains the next slice. | [#2803](https://github.com/kubestellar/hive/issues/2803), [intent verification](intent-verification.md) | | Review fan-out | Structured review reports and deterministic aggregation are in place; scheduler fan-out to parallel review perspectives is the deferred wiring. | [#2807](https://github.com/kubestellar/hive/issues/2807), [review swarm](review-swarm.md) | -| Credential-free sandbox kick path | Move agent execution toward no live token and no direct network in the sandbox, with trusted host-side post-steps retaining the MITM proxy as an outer layer. | [#2804](https://github.com/kubestellar/hive/issues/2804), [security threat model](security-threat-model.md#known-gaps-and-roadmap) | +| Credential-free sandbox kick path | Move agent execution toward no live token and no direct network in the sandbox, with trusted host-side post-steps retaining the MITM proxy as an outer layer. | [#2804](https://github.com/kubestellar/hive/issues/2804), [security threat model](security-threat-model.md#residual-risks-and-known-gaps) | | Spoke-based lite enrollment | Keep `hivectl enroll OWNER/REPO` as a zero-secret on-ramp by adding repos to an existing spoke or provisioning a hosted lite spoke; the hub tracks only spokes. | [#2808](https://github.com/kubestellar/hive/issues/2808), [lite enrollment](lite-enrollment.md) | | Retrospective learning lane | Build from deterministic post-completion advisory beads toward LLM-assisted retro summaries and knowledge extraction. | [#2809](https://github.com/kubestellar/hive/issues/2809), [retro lane](retro-lane.md) | | ADR back-fill for remaining subsystems | **Done.** Back-filled accepted ADRs capture the knowledge system, skill registry, CEL/channel triggers, and hub/spoke mechanics, so architecture decisions stay auditable. | [ADR-0011](adr/0011-knowledge-system.md), [ADR-0012](adr/0012-skill-registry.md), [ADR-0013](adr/0013-cel-triggers.md), [ADR-0014](adr/0014-hub-spoke.md) | @@ -31,8 +31,8 @@ order, not priority rank. | Work | Outcome | Tracking | | --- | --- | --- | -| Docs site publication | The [docs index](README.md) ships and is maintained; the MkDocs/site pipeline is still deferred — no site config exists in the tree. Its originating issue is closed, so this item currently has no open tracker. | [docs index](README.md), origin: [#2811](https://github.com/kubestellar/hive/issues/2811) (closed) | -| GitLab through `pkg/forge` | `pkg/forge` ships GitHub, GitLab, and Gitea/Forgejo adapters with the read path and core write path implemented and tested; `Merge` is left an explicit interface TODO because merge semantics diverge across forges. What remains is moving scheduler operations behind the abstraction — production callers still use the forge-specific client directly. Its originating epic is closed, so this item currently has no open tracker. | [ADR-0005](adr/0005-forge-abstraction.md), origin: [#2812](https://github.com/kubestellar/hive/issues/2812) (closed) | +| Docs site publication | **Live, not deferred.** The org already runs one docs site — [kubestellar/docs](https://github.com/kubestellar/docs) (Next.js on Netlify) — and pulls a growing subset of `src/docs/` straight from this repo's `v4` branch on every build, publishing at [kubestellar.io/docs/hive](https://kubestellar.io/docs/hive) with links rewritten to site routes. There is deliberately no second site generator in this tree (a per-repo MkDocs/Docusaurus config would duplicate that pipeline); `hive.kubestellar.io` itself serves the product/dashboard landing page, not docs. This repo's job is keeping `src/docs/` a correct source for that sync — see [Docs Link Check](https://github.com/kubestellar/hive/blob/v4/.github/workflows/docs-link-check.yml), which gates relative links and heading anchors on every PR touching `src/docs/`. Remaining follow-up, tracked separately: expanding the sync manifest (`kubestellar/docs:scripts/sync-hive-docs.ts`) to cover the ~65 pages not yet on it is a change to that repo, not this one. | [docs index](README.md), origin: [#2811](https://github.com/kubestellar/hive/issues/2811) (closed), tracker: [#5258](https://github.com/kubestellar/hive/issues/5258) | +| GitLab through `pkg/forge` | `pkg/forge` ships GitHub, GitLab, and Gitea/Forgejo adapters with the read path and core write path implemented and tested; `Merge` is left an explicit interface TODO because merge semantics diverge across forges. **First production caller landed:** the governor's escalation writes (evidence comment + `needs-human` label) are now typed against the `forge.IssueWriter` seam, with the adapter selected from `project.forge` — so that key is no longer display-only. A GitHub hive is unchanged, still on `*github.Client`. Those writes are not yet *reached* on a non-GitHub hive, because the read path is still GitHub-shaped: `EnumerateActionable` feeds the whole governor cycle and owns hold-label filtering, issue filters and SLA tracking inside `pkg/github`. Neutralizing enumeration — lifting that policy above the forge boundary, and adding a bulk list method so an N-repo hive does not enumerate N times — is what remains. | [ADR-0005](adr/0005-forge-abstraction.md), [#5259](https://github.com/kubestellar/hive/issues/5259), origin: [#2812](https://github.com/kubestellar/hive/issues/2812) (closed) | ## Later diff --git a/src/docs/sandbox-isolation.md b/src/docs/sandbox-isolation.md index 69f993fb0..1348e4697 100644 --- a/src/docs/sandbox-isolation.md +++ b/src/docs/sandbox-isolation.md @@ -7,7 +7,7 @@ Hive agents are untrusted code executors: prompts, tool output, and cloned repos Two halves of that target hold differently, and the difference matters: - **Credentials and pushes are constrained on every path.** No agent receives a GitHub token or pushes directly; authorship goes through the App-gated `gh` wrapper and the push broker. -- **Workspace write confinement depends on the launch path and backend.** The Podman sandbox below is the hub-side boundary. Contributor container mode has its own container boundary. In contributor local mode, Claude/LiteLLM now require Claude Code's native OS sandbox and Codex retains its `workspace-write` sandbox; other backends still run as the operator's user without a filesystem boundary. +- **Workspace write confinement depends on the launch path and backend.** The Podman sandbox below is the hub-side boundary. Contributor container mode has its own container boundary. In contributor local mode: claude/litellm and codex have OS-enforced sandboxes; copilot has its own OS-enforced sandbox, wired the same way, gated on the installed CLI actually supporting it; opencode has no sandbox but does get a command-name deny-list (a floor, not a boundary); goose, agy, bob, pi, and aider have **no confinement mechanism this repo can wire at all** and refuse to launch in local mode unless the operator explicitly opts in per backend. See the [per-backend matrix](#per-backend-confinement-on-the-contributor-local-path) below. **#4918 is what that costs in practice, and it did not require a compromise.** An agent doing correct work on an assigned third-party repo ran that repo's own test suite; a latent defect in two of its tests let a hook escape its stubs and issue `rpm-ostree kargs --append-if-missing=...` against the operator's real deployment. Nothing was written, and the only reason is that the process happened to lack privilege. Benign behaviour was a sufficient precondition, so this is a routine exposure rather than an exceptional one. @@ -21,13 +21,33 @@ This is the part that is easy to get wrong, because the two paths have different |---|---|---| | Runs where | The hive spoke's own container | The contributor's machine | | Podman agent sandbox (`agent_sandbox`) | Available, opt-in — see below | **Does not exist on this path.** `SandboxEnabled` is read only by `pkg/agent`; nothing in `bin/contributor-relay.sh`, `bin/contributor-agent.sh` or the `Justfile` consults it | -| The confinement lever | `agent_sandbox` + the per-agent opt-in | Container mode (the default), or a backend-native sandbox in local mode. Claude/LiteLLM and Codex are write-confined locally; other backends are not | +| The confinement lever | `agent_sandbox` + the per-agent opt-in | Container mode (the default), or a backend-native sandbox in local mode — see the matrix below, coverage varies by backend | | Host-state denials (#4938) | Yes | Yes (`config/backends.conf`) | | Credentials / pushes | Constrained | Constrained | -**The #4918 incident happened on the contributor relay's local mode**, so enabling `agent_sandbox` would not have prevented it. Claude-family local launches now use Claude Code's native sandbox with hard-fail startup and unsandboxed retry disabled. Container mode remains the stronger backend-independent remedy and the `just contribute-hive` default. +**The #4918 incident happened on the contributor relay's local mode**, so enabling `agent_sandbox` would not have prevented it. Claude-family local launches now use Claude Code's native sandbox with hard-fail startup and unsandboxed retry disabled; codex keeps its `workspace-write` sandbox; copilot local launches now use Copilot CLI's own OS-enforced sandbox. Container mode remains the stronger backend-independent remedy and the `just contribute-hive` default. -Operators running hive on a machine they care about should therefore prefer container mode on the contributor path, use only a locally sandboxed backend when local mode is necessary, and enable the sandbox below on the hub path. +Operators running hive on a machine they care about should therefore prefer container mode on the contributor path, use only a locally sandboxed (or, for opencode, at least denylisted) backend when local mode is necessary, and enable the sandbox below on the hub path. + +### Per-backend confinement on the contributor local path + +This table is the ground truth for `just contribute-hive local` — the only mode where "the operator's host" means a real desktop with no container boundary. Verified against each backend's own current CLI documentation, not assumed; see `config/backends.conf` for the implementation and `src/pkg/dashboard/contribute_local_mode_backend_matrix_test.go` for the tests that pin it. + +| Backend | Mechanism | What it actually bounds | Escape hatch (unconfined opt-in) | +|---|---|---|---| +| `claude` / `litellm` | Claude Code's native OS sandbox (`--settings` sandbox JSON; Seatbelt on macOS, bubblewrap on Linux) | Filesystem writes confined to the agent cwd and `HIVE_WORKSPACE_DIR`; `failIfUnavailable: true`, no unsandboxed fallback | `HIVE_CLAUDE_DANGEROUSLY_BYPASS_APPROVALS_AND_SANDBOX=1` | +| `codex` | Codex's own `--sandbox workspace-write` | Filesystem writes confined to the workspace root(s) codex is given | `HIVE_CODEX_DANGEROUSLY_BYPASS_APPROVALS_AND_SANDBOX=1` | +| `copilot` | Copilot CLI's own `--sandbox` flag (MXC: Seatbelt on macOS, bubblewrap on Linux, ProcessContainer on Windows Insiders) | Filesystem/network/process access of the commands and tools Copilot runs, restricted by the OS-level backend; `--add-dir` grants the exact workspace | `HIVE_COPILOT_DANGEROUSLY_BYPASS_SANDBOX=1`, or automatic fallback with a loud warning if the installed CLI predates `--sandbox` (copilot-cli < 1.0.60) | +| `opencode` | opencode's own `permission.bash` deny rules (inline via `OPENCODE_PERMISSION`), denying the same host-state command family the claude deny-list covers | **Not a filesystem boundary.** A command-name floor only — anything not on the list, or reached another way, is unconstrained. Deny rules are documented to hold even under `--auto`. | `HIVE_OPENCODE_DANGEROUSLY_ALLOW_HOST_STATE=1` | +| `goose` | **None.** `GOOSE_MODE` (`auto`/`approve`/`chat`/`smart_approve`) governs interactive approval only; no mode confines writes to a directory, and only `auto` is usable unattended. | Nothing — local mode refuses to launch without explicit opt-in | `HIVE_GOOSE_DANGEROUSLY_RUN_UNCONFINED=1` | +| `agy` | **None.** Antigravity CLI's execution modes (`default`/`accept-edits`/`plan`) govern approval only, same shape as goose; `--dangerously-skip-permissions` is what hive already passes and there is no lesser mode that confines the filesystem. agy 1.1.22 also advertises `--sandbox`, but its binary's own strings point to Google's remote/cloud sandbox machinery (a `Sandbox` proto with a network endpoint+port), not a local OS boundary — treat it as unrelated to host confinement until verified otherwise. | Nothing — local mode refuses to launch without explicit opt-in | `HIVE_AGY_DANGEROUSLY_RUN_UNCONFINED=1` | +| `bob` | **None.** No sandbox, approval mode, or path-restriction mechanism documented anywhere in Bob Shell's own docs. | Nothing — local mode refuses to launch without explicit opt-in | `HIVE_BOB_DANGEROUSLY_RUN_UNCONFINED=1` | +| `pi` | **None.** `@earendil-works/pi-coding-agent` ships with no sandbox by default; directory confinement exists only via a third-party extension (`pi-permission-modes`) hive does not install or depend on. | Nothing — local mode refuses to launch without explicit opt-in | `HIVE_PI_DANGEROUSLY_RUN_UNCONFINED=1` | +| `aider` | **None.** No Docker/OS isolation option of any kind. | Nothing — local mode refuses to launch without explicit opt-in | `HIVE_AIDER_DANGEROUSLY_RUN_UNCONFINED=1` | + +The five backends with no mechanism (goose, agy, bob, pi, aider) are a hard stop, by design: `just contribute-hive local` prints an honest refusal and a non-zero exit rather than a silent unconfined launch, unless the operator sets that backend's own escape-hatch env var. This is deliberately not a blanket `HIVE_DANGEROUSLY_RUN_UNCONFINED` — a single shared flag would let opting into one unconfined backend silently opt into all five. + +For `agy` specifically, this local-mode refusal used to be a dead end (#5048): `src/Dockerfile.contributor` never installed the `agy` binary, so container mode — the only real boundary any of these five backends can get on this path — was unavailable too, leaving no working path at all. That is now fixed: the image installs `agy` from Google's published, checksummed release tarball, so `just contribute-hive agy` (container mode, the default) actually works. Nothing above about agy's *local*-mode posture changed — it still has no sandbox and still refuses without the escape hatch, exactly like goose/bob/pi/aider. ## Current wiring @@ -47,7 +67,9 @@ agents: enabled: true ``` -**Both gates are required, and the global one alone does nothing.** `agent_sandbox.enabled: true` with no per-agent `sandbox.enabled: true` sandboxes zero agents. That matters more than it reads, because the dashboard's Security tab writes *only* the global flag and is the only sandbox control the UI offers: an owner can turn "agent sandbox" on, be told the setting was updated, and have every agent keep running unconfined. Hive now logs a `agent sandbox posture` warning at boot and on every config reload when the sandbox is enabled globally but some or all agents are not opted in (`config.AgentSandboxGateWarnings`). +**Both gates are required, and the global one alone does nothing.** `agent_sandbox.enabled: true` with no per-agent `sandbox.enabled: true` sandboxes zero agents. That matters more than it reads, because the dashboard's Security tab writes *only* the global flag and is the only sandbox control the UI offers: an owner can turn "agent sandbox" on and, without the fix below, be told the setting was updated while every agent kept running unconfined. + +Hive logs an `agent sandbox posture` warning at boot and on every config reload when the sandbox is enabled globally but some or all agents are not opted in (`config.AgentSandboxGateWarnings`), and — as of the #4918 fix — the same diagnosis also reaches the dashboard itself: `GET /api/config/governor`'s `security.sandboxWarnings` array carries it, and the Security tab renders it both in the page's "Coherence warnings" box and as an inline warning directly beneath the sandbox toggle, naming which agents are still unconfined and the exact `sandbox: {enabled: true}` key that fixes it. The array is empty (and nothing renders) on the documented default — sandbox off globally — and on a fully opted-in hive, so the warning appears only when it is actionable. The second gate is deliberate rather than an oversight, and it is not safe to simply collapse. A sandboxed agent runs a different execution model — no tmux CLI at all, and every kick is a Podman run against the primary repo — and `startSandboxKickLocked` has **no fallback to the tmux path**: an agent opted in without a resolvable image fails every kick outright rather than degrading. Making the global flag sufficient would therefore convert working agents into permanently failing ones on any hive that set it without an image. Changing that default is a fleet-affecting decision that wants measurement, not a code-reading; the warning above is the part that is safe today. diff --git a/src/docs/security-self-assessment.md b/src/docs/security-self-assessment.md index 84df315ba..721469e8e 100644 --- a/src/docs/security-self-assessment.md +++ b/src/docs/security-self-assessment.md @@ -492,7 +492,7 @@ development, not externally reported vulnerabilities. ### Open SSF best practices -An [OpenSSF Scorecard](.github/workflows/scorecard.yml) workflow runs weekly +An [OpenSSF Scorecard](https://github.com/kubestellar/hive/blob/v4/.github/workflows/scorecard.yml) workflow runs weekly against the repository. This assessment does not reproduce the current numeric score here (it changes over time and is available live via the Scorecard badge/API); a reviewer should pull the current score rather than diff --git a/src/docs/security-threat-model.md b/src/docs/security-threat-model.md index 0c30f161d..fdc499c29 100644 --- a/src/docs/security-threat-model.md +++ b/src/docs/security-threat-model.md @@ -67,7 +67,7 @@ the threats they reduce. | Layer | How it works | Threats reduced | Evidence | | --- | --- | --- | --- | | Deterministic pre-kick pipeline | `run-pipeline.sh` enumerates, classifies, detects clusters/architecture impact, and writes `merge-eligible.json` before agents see work. | Reduces agent discretion over what is actionable and what can merge. | [Architecture §4](architecture.md#4-the-deterministic-pipeline) | -| ACMM autonomy dial | Human-selected ACMM level maps to per-agent modes from advisory through full autonomy; supervisor/brainstorm stay advisory. | Limits damage from a compromised agent by constraining issue/PR/merge capabilities. | [Architecture §6](architecture.md#6-acmm-controlling-agent-autonomy), [policy matrix](acmm-policy-matrix.md) | +| ACMM autonomy dial | Human-selected ACMM level maps to per-agent modes from advisory through full autonomy; supervisor/brainstorm stay advisory. | Limits damage from a compromised agent by constraining issue/PR/merge capabilities. | [Architecture §6](architecture.md#6-acmm--controlling-agent-autonomy), [policy matrix](acmm-policy-matrix.md) | | CLI tool-deny | Agents are launched with backend-specific denied/disallowed tools based on mode. | Blocks obvious local-tool routes before credentials or network are involved. | [Architecture §5](architecture.md#5-layered-guardrails-defense-in-depth) | | Scoped per-agent App tokens | Mode maps to token tiers (`advisor`, `newcomer`, `contributor`, `trusted`); tokens are least-privilege and per-agent rather than one shared full token. v4 hardened token handling so agents do not receive the full installation token via `HIVE_GITHUB_TOKEN` (#2760), and shared token cache files are owner-only/fail-loud (#2747). | Limits token exfiltration and confused-deputy writes. | [Architecture §5](architecture.md#5-layered-guardrails-defense-in-depth), `pkg/agent/mode.go`, security commits #2760/#2747 | | MITM proxy mode rules | `api.github.com` traffic is intercepted; first-match REST rules and GraphQL mutation classification require minimum modes. Blocks return `403` with `X-Hive-Proxy-Blocked`. `github.com` is tunneled opaquely for OAuth/git smart HTTP. | Blocks direct PR writes, issue writes, GraphQL mutation bypasses, and mode escalation over the network. | [Architecture §5](architecture.md#5-layered-guardrails-defense-in-depth), `pkg/proxy/rules.go`, `pkg/proxy/github_proxy.go` | @@ -75,11 +75,11 @@ the threats they reduce. | Repo allowlist | The proxy permits writes only to configured repos; seed prompt/repo allowlists come from trusted config. | Prevents compromised agents from writing to arbitrary repositories reachable by a credential. | [Architecture §5](architecture.md#5-layered-guardrails-defense-in-depth), `pkg/proxy/rules.go` | | ioscan redaction and semantic classification | `pkg/ioscan` is stdlib-first and scans input/output text for prompt injection, dangerous directives, secrets, zero-width characters, base64-hidden instructions, and high-entropy tokens. Scheduler input enforcement defaults on and replaces blocked untrusted text with an explicit marker while audit logging the rule. An optional LLM-judge classifier scores already-redacted untrusted segments for plain-English semantic injections; classifier outages fail open, while successful high-risk scores redact in open mode or block in fail-closed mode. v4 made ioscan default-on/broader (#2666) and closed the #2805 semantic-injection gap. | Reduces prompt injection and accidental secret emission before text reaches agents or external outputs. | `pkg/ioscan`, `pkg/scheduler/ioscan_enforce.go`, #2666/#2805 | | Trajectory review | A second model periodically compares each running agent's intent to a bounded tmux transcript tail and pauses or alerts on divergent trajectories. It fails open on reviewer outage. | Catches multi-step goal drift that individual API/tool checks may not detect. | [trajectory-review.md](trajectory-review.md) | -| Token budget | The seven-day rolling token budget warns at 90% and suppresses kicks on exhaustion except for exempt agents. | Limits runaway cost or denial-of-wallet from compromised loops. | [Architecture §3](architecture.md#3-the-governor-loop-from-queue-depth-to-a-kick) | +| Token budget | The seven-day rolling token budget warns at 90% and suppresses kicks on exhaustion except for exempt agents. | Limits runaway cost or denial-of-wallet from compromised loops. | [Architecture §3](architecture.md#3-the-governor-loop--from-queue-depth-to-a-kick) | | Per-UID isolation and attribution | Agents may run as per-agent OS users; the proxy maps connection owner UID to agent name/mode. v4 fixed token/cache permissions and pinned/restricted the SUID `su-exec` helper (#2754). | Limits cross-agent file access and makes network decisions attributable to an agent identity. | [Architecture §2](architecture.md#2-container-process-model), [§5](architecture.md#5-layered-guardrails-defense-in-depth), #2754 | | SSO and terminal authorization | v4 hardened hosted terminal access with per-hive authorization (#2756), short-lived signed terminal assertions (#2762), and asymmetric Ed25519 SSO handoff (#2771). | Reduces hub/terminal confused-deputy and token-forgery risks. | [Architecture §2](architecture.md#2-container-process-model), #2756/#2762/#2771 | -| Hub secret and heartbeat hardening | v4 domain-separated the hub master secret (#2758) and stopped heartbeats from broadcasting every tenant's App key (#2755). | Reduces blast radius from hub compromise or cross-tenant data exposure. | [Architecture §8](architecture.md#8-hub-spoke), #2758/#2755 | -| Per-hive dashboard/terminal authz | Empty dashboard tokens fail closed and terminal cookie HMACs are verified (#2662); per-hive terminal authz was added in #2756. | Prevents unauthenticated dashboard or terminal access. | [Architecture §10](architecture.md#10-dashboard-observability), #2662/#2756 | +| Hub secret and heartbeat hardening | v4 domain-separated the hub master secret (#2758) and stopped heartbeats from broadcasting every tenant's App key (#2755). | Reduces blast radius from hub compromise or cross-tenant data exposure. | [Architecture §8](architecture.md#8-hub--spoke), #2758/#2755 | +| Per-hive dashboard/terminal authz | Empty dashboard tokens fail closed and terminal cookie HMACs are verified (#2662); per-hive terminal authz was added in #2756. | Prevents unauthenticated dashboard or terminal access. | [Architecture §10](architecture.md#10-dashboard--observability), #2662/#2756 | ## Residual risks and known gaps diff --git a/src/docs/skills.md b/src/docs/skills.md index 18108feed..203ff3b05 100644 --- a/src/docs/skills.md +++ b/src/docs/skills.md @@ -1,24 +1,20 @@ # Skill registry (`/data/skills/`) -> **⚠️ Loaded and counted, but NOT yet delivered to agents.** -> -> The registry is read at startup and its skill count appears on the dashboard. -> **Nothing injects those skills into an agent's context.** The source says so -> directly: *"The skills registry (pkg/skillreg) is not yet wired into the -> runtime"* (`src/pkg/dashboard/status_builder.go:31-32`). -> -> Populating `/data/skills/` today gives you a number on a dashboard and -> nothing else. It does not change how any agent behaves. Treat this page as -> the file-format contract to author against, not as a feature you can deploy -> to influence agents. - -The registry is the intended home for reusable, named instructions — domain -knowledge, repo conventions, review patterns — that agents could request by -name instead of having them pasted into every prompt. See +The registry is the home for reusable, named instructions — repo conventions, +review patterns, "how we do X here" — that an agent loads by name instead of +having them pasted into every prompt. See [ADR-0012](adr/0012-skill-registry.md) for why it exists. -For knowledge that **does** reach agents today, use the -[knowledge vault](knowledge-curator.md) instead. +Skills reach agents **only when an agent declares them**. Dropping files in +`/data/skills/` makes them *available*; it does not change any agent's +behaviour until that agent's config names them (see +[Declaring skills on an agent](#declaring-skills-on-an-agent)). An agent with +no `skills:` list is unaffected no matter what the directory contains. + +Skills are for *procedural* instructions the operator writes and versions. For +*factual* project knowledge retrieved per issue, use the +[knowledge vault](knowledge-curator.md) — the two are complementary and both +land in the same `${KNOWLEDGE}` block of the kick. ## Where files go @@ -70,32 +66,92 @@ and the dashboard count is one lower than you expected. If the count does not match your file count, look for a malformed file rather than assuming the feature is broken. -## What exists in the package today +## Declaring skills on an agent -| Function | What it does | +An agent opts in by naming skills in its config. The name is the skill's +`name` (which defaults to the filename without `.md`), not the file path: + +```yaml +agents: + reviewer: + skills: + - go-error-wrapping + - review-checklist +``` + +At each kick the scheduler loads `/data/skills/` and, when a checkout is +configured for the primary repo, parses that repo's `AGENTS.md` and adjacent +`skills/` directory. It resolves each declared name against the hive-wide +registry first and falls back to the repo-local definition when the registry +has no match, then prepends the rendered block to the agent's `${KNOWLEDGE}` +section (`src/pkg/scheduler/scheduler.go`, `primeSkills`). Loading happens **per +kick, not once at startup**, so editing either source takes effect on the next +kick — no hive restart required. + +The fallback needs a checkout root from `project.checkouts_dir` (or the guarded +`policies.local_dir` fallback described in [agents-md.md](agents-md.md)). Without +one, registry-backed skills continue to work exactly as before. + +Only declared skills are injected. A skill sitting in the directory that no +agent names is never sent to anyone. + +## What happens when something is wrong + +Every failure mode degrades the kick rather than blocking the agent: + +| Situation | Result | | --- | --- | -| `NewRegistry()` | empty registry | -| `Registry.Load(dir, logger)` | reads `*.md` from a directory, returns the count loaded | -| `Registry.Add(skill)` | adds one skill | -| `ParseAgentSpec` / `LoadAgentSpec` | parse an agent spec that can name `DefaultSkills` | +| agent declares no `skills:` | nothing injected | +| `/data/skills/` absent | repo-local matches still inject when a checkout is configured; otherwise nothing | +| repo checkout absent | registry matches still inject; repo-local fallback is unavailable | +| declared name matches neither source | that name skipped; the others still inject | +| *every* declared name unknown | nothing injected, logged at `warn` | +| malformed front matter in a file | that file skipped by `Load`, logged | + +Each injection logs at `info` with the agent, registry directory, repo root, and +how many skills were injected, so "did this agent actually get its skills" is +answerable from the hive log. + +## Size cap + +A single kick may carry at most **8 KiB** of skill bodies +(`maxSkillsInjectionBytes`). The kick prompt shares a context budget with the +knowledge primer and the issue/PR lists, so an unbounded skill file could +otherwise crowd out the actual work queue. + +Skills are kept in declaration order until the next one would exceed the cap. +A skill that does not fit is **dropped whole, never truncated** — an agent +never receives half an instruction — and dropped names are logged at `warn`. +Smaller skills declared after an oversized one are still considered, so one +large file does not silently suppress everything behind it. -The only non-test consumer in the tree is -`src/pkg/dashboard/status_builder.go:466`, which loads the directory to report -a count. +## Versions -## Open questions +When several files declare the same `name` with different `version` values, +the **highest version wins** for a plain name reference. `Registry.Resolve` +additionally understands `^1.2.0` (highest sharing that major) and `>=1.2.0` +constraints; agent config uses plain names today, which resolve to the highest +version. -These are **not** settled in the code, and this page will not guess: +## Package surface -- **Precedence over inline `AGENTS.md` snippets.** A comment in - `agentspec.go:53` refers to resolving a requested skill "against a Registry - via `ResolveRequested`", but **no such function exists** in the package. There - is no implemented resolution path, so there is no precedence behaviour to - document yet. -- **How a skill will reach an agent** — injection at kick time, on request, or - something else — is undecided in the tree. -- **Whether `version` will be used for selection.** It is parsed and stored, but - nothing consumes it. +| Function | What it does | +| --- | --- | +| `NewRegistry()` | empty registry | +| `Registry.Load(dir, logger)` | reads `*.md` from a directory, returns the count loaded | +| `Registry.Add(skill)` | adds one skill | +| `Registry.Get` / `Resolve` / `Search` / `List` | look skills up by name, constraint, or term | +| `Registry.ResolveRequested(cfg, names)` | resolves names, preferring registry skills over an `AGENTS.md` inline fallback | +| `InjectionText(skills)` | renders resolved skills as the Markdown block injected into a kick | +| `ParseAgentSpec` / `LoadAgentSpec` | parse a BYO-agent spec that can name `DefaultSkills` | + +## Still not wired + +**`AgentSpec.DefaultSkills`** remains unconnected. The BYO-agent contract can +declare default skills, but no BYO-agent launcher consumes `AgentSpec` yet; use +the agent `skills:` config above. This is separate from the scheduler path, +which now resolves those configured names across both registry and repo-local +sources. ## Related diff --git a/src/docs/telemetry.md b/src/docs/telemetry.md new file mode 100644 index 000000000..bd4a31b97 --- /dev/null +++ b/src/docs/telemetry.md @@ -0,0 +1,82 @@ +# Telemetry Agent + +The telemetry agent audits and, once opted in, improves the **observability of the managed project** — the repositories the hive watches, not the hive's own internal metrics/tracing (see [Config.OTel/Tracing](operator-reference.md) for that, a deliberately separate concern). It is an L5/L6-only agent: absent from the roster below L5 and paused in every governor mode at L5/L6 until an operator explicitly opts in. + +## What the telemetry agent does + +On each kick, telemetry follows its ACMM-level policy template (`telemetry-advisory.md`, `telemetry-holdgated.md`, or `telemetry-full.md`) and: + +- Inspects tracing, metrics, structured logging, scrape targets, dashboards, monitoring custom resources, collector/exporter configuration, and web analytics in the repositories it is authorized to audit (`$HIVE_REPOS`). +- Detects the project's *existing* observability stack before recommending anything, and prefers OpenTelemetry as a vendor-neutral spine — but only acts on a backend an operator has explicitly configured. +- Flags unbounded metric labels, high-cardinality span attributes, missing scrape targets, inconsistent span names, and dashboards that aren't source-controlled. +- Never reveals credentials, endpoints, API keys, or secret values in its output — it refers only to environment-variable or secret names. + +At **advisory** level (ACMM L2–L4, though telemetry does not actually appear in the roster until L5 — see [ACMM level gating](#acmm-level-gating) below), it writes each confirmed finding as an advisory bead owned by `telemetry` and returns an `AgentReport` with `kind: "findings"`. It never creates GitHub issues, branches, commits, or pull requests in this mode. + +At **hold-gated** (L5) and **full** (L6) modes, telemetry runs in `ISSUES_AND_PRS` and, in addition to filing findings, can open bounded, hold-gated pull requests: OpenTelemetry SDK wiring and request-path spans, bounded metrics and `/metrics` endpoints, structured logging, dashboard JSON, alert-rule YAML, `ServiceMonitor`/`PodMonitor` resources, collector/exporter configuration, dashboard-lint CI, and GA4 wiring for an identified web property. It re-verifies and closes only stale beads it owns, and files findings with a `[telemetry]` title. + +Telemetry must never, at any PR-capable level: commit credentials, literal collector endpoints, API keys, or secret values; add an exporter that sends data off-box without an explicitly configured backend; introduce unbounded labels or span attributes; merge its own PR; or (at L5) remove a `hold`/`on-hold`/`do-not-merge` label. At L6 it still never merges its own PR, and it must not modify work already labeled `hold`, `on-hold`, or `do-not-merge`. + +## ACMM level gating + +Telemetry and operations are **L5/L6-only opt-in agents**. Per the built-in ACMM packs (`src/pkg/config/packs/level-5.yaml`, `level-6.yaml`): + +- They are absent from the roster entirely at L1–L4 — no pack below L5 defines a `telemetry` entry, so the agent does not appear in the dashboard, does not spawn a pane, and cannot be kicked. +- At L5 and L6 they are present but their cadence is `paused` in **every** governor mode (`surge`, `busy`, `quiet`, `idle`) until an operator opts in. This is not a description of typical defaults — the pack literally sets `telemetry: paused` in all four cadence tables at both levels. +- `kick_template` is `telemetry-holdgated.md` at L5 and `telemetry-full.md` at L6, matching the hold-gated-vs-full PR behavior described above. + +## When to enable telemetry + +Enable telemetry once you're comfortable with the rest of your L5/L6 roster and want automated observability hardening for the *managed* project — bounded metrics, tracing, and dashboards-as-code, on a backend you've explicitly named. Leave it paused if you have no observability backend in mind yet: with nothing configured, its policies fail closed, so it will only detect and report the existing stack rather than propose changes. + +## How to opt in: `governor.project_observability` + +Un-pausing the agent alone does nothing useful — telemetry's PR-capable policies fail closed without a confirmed target stack. Configure the opt-in under **Settings → Project Observability** in the dashboard: + +```yaml +governor: + project_observability: + open_source: [opentelemetry, prometheus, grafana] + kube_native: [servicemonitor] + commercial: [honeycomb] + references: + honeycomb: + endpoint_env: OTEL_EXPORTER_OTLP_ENDPOINT + credential_secret: observability/honeycomb-key +``` + +Reference fields accept **names only** — an environment-variable name or a `secret-name/key` reference. Literal endpoints, tokens, and API keys are rejected. Selecting platforms and saving persists them under `governor.project_observability`, and replaces telemetry's (and operations') all-mode `paused` cadence with a conservative `24h` interval, which can then be tuned from the agent's Cadences tab. + +After telemetry's first advisory run, platforms mentioned in its findings are preselected as suggestions in the Project Observability tab. They stay unsaved until an operator reviews and clicks **Save** — only then does the persisted declaration govern future telemetry (and operations) work. + +## How telemetry interacts with other agents + +Telemetry's lane keywords (`observability`, `opentelemetry`, `prometheus`, `grafana`, `tracing`, `metrics`, `structured-logging`, `servicemonitor`, `podmonitor`) are disjoint from operations' (health, SLO, runbook, incident, rollback, alerting terms), so the two agents do not compete for the same issues. Both share the same `${PROJECT_OBSERVABILITY}` prompt section and the same `governor.project_observability` configuration — telemetry adds the instrumentation; operations builds the health/SLO/runbook layer on top of it. + +## Configuration reference + +Registered defaults (`applyKnownAgentDefaults` in `src/pkg/config/config.go`), applied when a field is left blank in your own config: + +| Field | Default | +|-------|---------| +| `emoji` | 📡 | +| `color` | `#00a8cc` | +| `aliases` | `["tm"]` | +| `bead_role` | `worker` | +| `sort_order` | `65` | +| `include_repos` | `true` | +| `lane_keywords` | `observability`, `opentelemetry`, `prometheus`, `grafana`, `tracing`, `metrics`, `structured-logging`, `servicemonitor`, `podmonitor` | +| `detect_keywords` | `telemetry`, `observability`, `opentelemetry`, `prometheus` | + +ACMM packs additionally set `backend: copilot`, `model: claude-sonnet-4-6`, `mode: ISSUES_AND_PRS`, `stale_timeout: 28800`, and the level-appropriate `kick_template`. + +## Cadence and budget considerations + +Telemetry is a heavyweight, PR-capable agent once enabled. Follow the same guidance as every other agent un-paused at L5/L6: set all modes to `12h` or `1d` first (the automatic un-pause already lands at a conservative `24h`), watch its output for a few cycles, and only shorten the cadence once you understand what it's producing and how much budget it consumes per run. + +## What to read next + +- **[Operations Agent](operations.md)** — the companion L5/L6 opt-in agent for operational readiness (health checks, SLOs, runbooks). +- **[Agent Configuration](agent-configuration.md)** — every agent field, the ACMM level packs, and `project_observability` details. +- **[ACMM Policy Matrix](acmm-policy-matrix.md)** — the full per-level, per-agent policy table, including the L5/L6-only gating note. +- **[Getting Started](getting-started.md)** — when to opt in as part of the level-climbing journey. diff --git a/src/docs/troubleshooting.md b/src/docs/troubleshooting.md index 008ca0d5d..ca0f0d542 100644 --- a/src/docs/troubleshooting.md +++ b/src/docs/troubleshooting.md @@ -56,7 +56,7 @@ When no token or App credentials are usable, Hive starts the dashboard but disab - `GitHub App configured without credentials — hive starting in dashboard-only mode. Install the app and provide installation_id + key to enable agents.` - `persisted user token is invalid or expired` -Check the configured `github:` block, the `HIVE_GITHUB_TOKEN` secret/env var, or the GitHub App `app_id`, `installation_id`, and `key_file`. For App setup, use the dashboard banner or `/gh-setup`; details are in [GitHub App setup](github-app-setup.md). Note the dashboard calls this the **Forge App** — the app for your forge (your source control system, e.g., GitHub, GitHub Enterprise, GitLab, or Gitea) — under Governor Config → Forge App. +Check the configured `github:` block, the `HIVE_GITHUB_TOKEN` secret/env var, or the GitHub App `app_id`, `installation_id`, and `key_file`. For App setup, use the dashboard banner or `/gh-setup`; details are in [GitHub App setup](github-app-setup.md). Note the dashboard calls this the **Forge App** — the app for your forge (your source control system, e.g., GitHub, GitHub Enterprise, GitLab, or Gitea) — under Governor Config → Forge App. GitLab, Gitea, and Forgejo are **not supported for running a hive** today; see [Forge setup: GitLab, Gitea, and Forgejo](forge-app-setup.md). ## Hosted hive disappeared or its URL times out diff --git a/src/go.mod b/src/go.mod index 50bee3c2b..556b30346 100644 --- a/src/go.mod +++ b/src/go.mod @@ -13,14 +13,15 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 + github.com/muesli/termenv v0.16.0 github.com/robfig/cron/v3 v3.0.1 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/spf13/cobra v1.10.2 go.etcd.io/bbolt v1.5.0 - go.opentelemetry.io/otel v1.45.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 - go.opentelemetry.io/otel/sdk v1.45.0 - go.opentelemetry.io/otel/trace v1.45.0 + go.opentelemetry.io/otel v1.46.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 + go.opentelemetry.io/otel/sdk v1.46.0 + go.opentelemetry.io/otel/trace v1.46.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -34,13 +35,13 @@ require ( github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect ) @@ -53,21 +54,21 @@ require ( github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect - go.opentelemetry.io/otel/metric v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect + go.opentelemetry.io/otel/metric v1.46.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.uber.org/automaxprocs v1.6.0 - go.yaml.in/yaml/v3 v3.0.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect - google.golang.org/grpc v1.83.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/grpc v1.83.1 // indirect + google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/src/go.sum b/src/go.sum index 1ff35d095..f20765e95 100644 --- a/src/go.sum +++ b/src/go.sum @@ -27,8 +27,7 @@ github.com/charmbracelet/x/exp/teatest v0.0.0-20260823001701-96af6d2cb5f6/go.mod github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= @@ -55,8 +54,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -79,8 +78,6 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -98,59 +95,60 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= -go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8= -go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= -go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= -go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= -go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= -go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= -go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= -go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= -go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= -google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= -google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/src/hive.yaml.example b/src/hive.yaml.example index e7d64db50..745bbabd9 100644 --- a/src/hive.yaml.example +++ b/src/hive.yaml.example @@ -13,6 +13,12 @@ project: - repo-two ai_author: your-bot-user # GitHub username for AI commits primary_repo: repo-one # Main repo for priority routing + # Optional: host-local directory holding one checkout per repo, as + # /. Supplies the repo root Hive reads AGENTS.md + # from at kick time (src/docs/agents-md.md). Hive agents work over the API and + # keep no clones, so without this AGENTS.md injection stays a no-op — which is + # the default. An absent directory or a repo with no AGENTS.md is also a no-op. + # checkouts_dir: /data/checkouts ioscan: enabled: true # default: true; scans untrusted text before agent kicks @@ -392,6 +398,34 @@ governor: # recent_closed_window_s: 604800 # analysis_model: "" # empty disables all LLM calls (default) +# Planning intelligence — the `plan`/`epic` issue-label trigger that auto-mints +# an epic bead and requests architect decomposition (see docs/planning-intelligence.md). +# Only gates the label trigger; the dashboard's "Plan this issue" button is always +# available regardless of this block. OFF by default: the label path pipes a raw +# issue body into the architect's kick prompt with no per-kick review, so this +# must be explicitly opted into. Even when true, it is a no-op below ACMM L5 — +# the decomposing architect only has a cadence at L5+. +# planning: +# plan_from_label: false + +# CEL-based agent triggers — declarative rules that kick an agent directly off +# a normalized source-control event (issue opened, PR opened, a label applied, +# a comment posted), evaluated with CEL against the `event` variable. This is +# additive: it runs alongside the existing label/governor triggering above, not +# instead of it, and an absent/empty list is byte-identical to today's behavior. +# The evaluator fails closed — a malformed rule is rejected at config load, and +# a runtime evaluation error is treated as "no match", never a crash. +# See docs/cel-triggers.md for the full `event.*` field reference and worked +# examples. +# triggers: +# - name: bug-triage +# expr: event.kind == "issue.opened" && hasLabel(event.labels, "bug") +# agent: triager +# priority: 10 +# - name: ready-pr-review +# expr: event.kind == "pr.opened" && !event.is_draft && event.base_branch == "main" +# agent: reviewer + # Review swarm merge gate — optional structured multi-perspective PR review. # Default is false to preserve existing merge eligibility behavior; when true, # merge-eligible.json requires an aggregate approve in review-verdicts.json. diff --git a/src/pkg/acmmadvisor/wire.go b/src/pkg/acmmadvisor/wire.go index d81cfd905..f3a5bb0e9 100644 --- a/src/pkg/acmmadvisor/wire.go +++ b/src/pkg/acmmadvisor/wire.go @@ -6,16 +6,18 @@ package acmmadvisor // signals and returns a recommendation. It does NOT read the config, apply a // level, or perform any I/O. // -// TODO(acmm2-wiring): wire RecommendFromStatus into pkg/dashboard's -// status_builder.go — populate a StatusPayload.ACMMAdvice field from the same -// inputs the dashboard already gathers: -// - CurrentLevel: dashboard.detectACMMLevel(cfg) -// - CoveragePct: MetricsCollector coverage ("coverage" key, collectCoverage) -// - GreenStreak / MergeSuccessRate: from issue-to-merge / CI metrics -// - ActionableIssues: len(actionable) already passed to buildRepos/buildHold -// - HoldCount: buildHold(actionable) count -// - HasQualityAgent: presence of an active "quality" agent in the pack -// The dashboard must render Recommendation.Met/Unmet as a checklist and must +// Wiring status: RecommendFromStatus is wired into pkg/dashboard on two +// surfaces that share ONE signal-collection path (buildACMMStatusInputs) so +// they cannot drift — the GET /api/acmm-recommendation endpoint (#5225) and +// the StatusPayload.ACMMAdvice field attached on the status-build path. +// Every input is now sourced from real data: CurrentLevel from +// detectACMMLevel, CoveragePct from the ci-maintainer coverage metric, +// ActionableIssues/HoldCount from the live status snapshot, HasQualityAgent +// from the active pack, MergeSuccessRate from the fleet-stats collector +// (#3972), and GreenStreak from default-branch Actions history (#5226). +// Signals that cannot be measured stay at zero rather than being fabricated. +// +// The dashboard renders Recommendation.Met/Unmet as a checklist and must // NEVER auto-apply the target level — a human approves via handlePackSetLevel. // StatusInputs is the forge-neutral bundle a status builder assembles from @@ -37,13 +39,5 @@ type StatusInputs struct { // so it is safe to call from a hot status-build path. Callers attach the result // to their status payload; they must not act on it automatically. func RecommendFromStatus(in StatusInputs) Recommendation { - return Recommend(Signals{ - CurrentLevel: in.CurrentLevel, - CoveragePct: in.CoveragePct, - GreenStreak: in.GreenStreak, - MergeSuccessRate: in.MergeSuccessRate, - ActionableIssues: in.ActionableIssues, - HoldCount: in.HoldCount, - HasQualityAgent: in.HasQualityAgent, - }) + return Recommend(Signals(in)) } diff --git a/src/pkg/advisory/advisory.go b/src/pkg/advisory/advisory.go index e78be4a93..1b7c0342d 100644 --- a/src/pkg/advisory/advisory.go +++ b/src/pkg/advisory/advisory.go @@ -40,6 +40,25 @@ type Finding struct { // the "docs/install.md that isn't there" bug (#3704). The renderer marks it // as outdated instead of emitting a dead file reference. PathStale bool `json:"path_stale,omitempty"` + // ProvenanceSHA is the commit the finding's evidence was actually computed + // at. Producers may set it directly (advisory JSONL, bead metadata); when + // they do not, MarkStaleProvenance recovers it from the provenance commit + // the finding already names in Detail. + ProvenanceSHA string `json:"provenance_sha,omitempty"` + // ProvenanceStale is set by MarkStaleProvenance when ProvenanceSHA names a + // commit OTHER than the digest's AnalyzedSnapshot. Such a finding is being + // republished under a freshness stamp it never earned — its evidence was + // computed against an older tree and nothing has re-checked it since, which + // is how findings survived their own fix for five cycles (#5130). The + // renderer captions it rather than passing it off as analyzed-at-HEAD. + ProvenanceStale bool `json:"provenance_stale,omitempty"` + // CachedReplays counts the byte-identical no-provenance re-reports of this + // finding that PersistAsBeads has skipped since its evidence last changed + // (#5236). Non-zero means the repetition is cached replay, not repeated + // verification, and the renderer captions the finding as unverified so a + // stale claim and the downstream issue refuting it cannot both read as + // live work. + CachedReplays int `json:"cached_replays,omitempty"` } // Snapshot identifies the single commit that a digest's analysis is pinned to. @@ -573,6 +592,8 @@ func BuildDigestFromBeads(stores map[string]*beads.Store, mode string, opts Dige if d := b.Meta("detail"); d != "" && f.Detail == "" { f.Detail = d } + f.ProvenanceSHA = b.Meta(provenanceSHAMetadataKey) + f.CachedReplays, _ = strconv.Atoi(b.Meta(evidenceReplayCountMetadataKey)) f = capCoverageGapSeverity(f) byAgent[agentName] = append(byAgent[agentName], f) total++ @@ -617,6 +638,12 @@ func BuildDigestFromBeads(stores map[string]*beads.Store, mode string, opts Dige if opts.VerifyPath != nil && overflow == 0 { VerifyFindingPaths(d, opts.VerifyPath) } + // Path existence is not freshness. A finding can cite a file that still + // exists and yet have been computed several commits ago, against evidence + // the analyzed commit no longer reproduces — the #5130 findings were + // exactly that shape, and VerifyFindingPaths waved both of them through. + // Run after the cap so only rendered findings are examined. + MarkStaleProvenance(d) return d } @@ -991,7 +1018,23 @@ func FormatDigestMarkdown(d *Digest, opts DigestOptions) string { if f.DuplicateCount > 0 { repeat = fmt.Sprintf(" _(reported %d×)_", f.DuplicateCount+1) } - b.WriteString(fmt.Sprintf("- **[%s]** %s%s%s _%s_\n", f.Type, linkifyRefs(title, org), loc, repeat, f.Agent)) + // The finding's evidence was computed at some other commit and + // nothing has re-checked it here (#5130). Say so on the finding + // itself: the footer's "Analyzed at" stamp is digest-wide, and + // letting it cover this one is the overclaim that got a stale + // finding reported as a fabrication. + prov := "" + if f.ProvenanceStale && f.ProvenanceSHA != "" { + prov = fmt.Sprintf(" ⚠️ _(evidence computed at `%s`, not re-verified at the analyzed commit)_", shortSHA(f.ProvenanceSHA)) + } else if f.CachedReplays > 0 && f.ProvenanceSHA == "" { + // A no-provenance finding whose only "confirmations" were + // byte-identical replays of cached text. Without the caption + // the repetition reads as fresh verification, and the digest + // presents a possibly-disproved claim as live work alongside + // whatever downstream issue refutes it (#5236). + prov = fmt.Sprintf(" ⚠️ _(re-reported %d× from cached evidence, not re-verified)_", f.CachedReplays) + } + b.WriteString(fmt.Sprintf("- **[%s]** %s%s%s%s _%s_\n", f.Type, linkifyRefs(title, org), loc, repeat, prov, f.Agent)) if detail != "" { b.WriteString(fmt.Sprintf(" > %s\n", linkifyRefs(detail, org))) } @@ -1026,10 +1069,10 @@ func writeRecentlyResolved(b *strings.Builder, d *Digest, org, primaryRepo strin if len(d.RecentlyResolved) == 0 { return } - b.WriteString(fmt.Sprintf("### ✅ Recently Resolved (%d)\n\n", len(d.RecentlyResolved))) + fmt.Fprintf(b, "### ✅ Recently Resolved (%d)\n\n", len(d.RecentlyResolved)) for _, r := range d.RecentlyResolved { loc := formatFindingRef(r.File, 0, org, primaryRepo, r.Title) - b.WriteString(fmt.Sprintf("- ~~%s~~%s _%s — resolved %s_\n", linkifyRefs(logscrub.ScrubString(r.Title), org), loc, r.Agent, r.ClosedAt.Format("Jan 2"))) + fmt.Fprintf(b, "- ~~%s~~%s _%s — resolved %s_\n", linkifyRefs(logscrub.ScrubString(r.Title), org), loc, r.Agent, r.ClosedAt.Format("Jan 2")) } b.WriteString("\n") } @@ -1057,7 +1100,8 @@ func writeAnalyzedFooter(b *strings.Builder, d *Digest) { if s.Branch != "" { fmt.Fprintf(b, " (branch `%s`)", s.Branch) } - b.WriteString(" — the latest commit when this digest was generated. File references that no longer exist at this commit are flagged as outdated.*\n") + b.WriteString(" — the latest commit when this digest was generated. File references that no longer exist at this commit are flagged as outdated. " + + "Findings marked ⚠️ were computed at an older commit and have NOT been re-verified here.*\n") } // SetLatestDigest stores the most recent digest for dashboard access. @@ -1100,7 +1144,51 @@ func PersistAsBeads(findings []Finding, stores map[string]*beads.Store) (created continue } + // Explicit provenance only, never the prose-inferred SHA: this decides + // whether a finding keeps ageing, and misreading "fixed in commit + // abc1234" as provenance would retire a finding that still holds. + prov := normalizeSHA(f.ProvenanceSHA) + existing := store.List(beads.ListFilter{}) + + // A re-report carrying the SAME provenance commit the bead already + // records is a restatement of evidence computed once, not fresh + // confirmation that the condition still holds. Refreshing LastSeenAt + // for it is what let fixed findings outlive their fix: agents re-report + // from cached prior findings, PersistAsBeads read that as "still + // happening", and PruneStaleAdvisoryBeads never got to age them out + // (#5130). Skipping the whole finding leaves the staleness clock + // running, so silence retires it on the normal schedule. + // + // Gated on an explicit provenance SHA on BOTH sides; findings that + // record none take the evidence-identity gate below instead. + if prov != "" && provenanceAlreadyRecorded(existing, f.Title, prov) { + continue + } + + // The same boundary for findings that record NO provenance (#5236): a + // re-report byte-identical to the text this bead already holds is a + // cached replay, not fresh confirmation — nothing was recomputed, so + // nothing was confirmed. Skipping it leaves the staleness clock + // running, exactly like the identical-provenance case above, so a + // disproved finding finally ages out instead of being kept alive + // forever by its own cache (the atomic-image-builder shell-coverage + // finding survived its fix this way). A report whose producer changed + // ANYTHING — title, detail, file, line — hashes differently and still + // refreshes below, so a genuinely re-checked condition keeps its bead + // alive as before, and a brand-new no-provenance finding still creates + // its bead normally. + hash := findingEvidenceHash(f) + if prov == "" { + if replayed := beadWithIdenticalEvidence(existing, f.Title, hash); replayed != nil { + // Count the skipped replay so the digest can caption the + // finding as unverified rather than silently live. + n, _ := strconv.Atoi(replayed.Meta(evidenceReplayCountMetadataKey)) + _ = store.SetMetadata(replayed.ID, evidenceReplayCountMetadataKey, strconv.Itoa(n+1)) + continue + } + } + dup := false for _, b := range existing { // Only OPEN beads suppress a duplicate. A resolved (closed/done) @@ -1113,12 +1201,22 @@ func PersistAsBeads(findings []Finding, stores map[string]*beads.Store) (created continue } if b.Title == f.Title && b.Type == beads.TypeAdvisory { - // The finding is being re-reported, which is exactly the signal - // staleness pruning consumes: stamp it so PruneStaleAdvisoryBeads - // keeps this bead alive for another window. Skipping the stamp - // here would let a finding an agent reports every single cycle - // still age out and be auto-closed. + // The finding is being re-reported from evidence this bead has + // not seen before (identical-provenance re-reports were skipped + // above), which is exactly the signal staleness pruning + // consumes: stamp it so PruneStaleAdvisoryBeads keeps this bead + // alive for another window. Skipping the stamp here would let a + // finding an agent reports every single cycle still age out and + // be auto-closed. _ = store.SetLastSeenAt(b.ID, time.Now()) + if prov != "" { + _ = store.SetMetadata(b.ID, provenanceSHAMetadataKey, prov) + } + // The bead now records THIS report's evidence: future replays + // compare against it, and the replay count restarts because + // the evidence visibly changed. + _ = store.SetMetadata(b.ID, evidenceHashMetadataKey, hash) + _ = store.SetMetadata(b.ID, evidenceReplayCountMetadataKey, "0") dup = true break } @@ -1143,6 +1241,14 @@ func PersistAsBeads(findings []Finding, stores map[string]*beads.Store) (created if f.Detail != "" { meta["detail"] = logscrub.ScrubString(f.Detail) } + if prov != "" { + meta[provenanceSHAMetadataKey] = prov + } + // Recorded on creation AND on the Upsert title-drift fold below (the + // meta loop runs after Upsert), so the stored hash always describes + // the report that last touched the bead. + meta[evidenceHashMetadataKey] = hash + meta[evidenceReplayCountMetadataKey] = "0" // Upsert, not Create: it stamps LastSeenAt on the new bead (so the // staleness clock starts) and folds in the cosmetic title drift that @@ -1159,6 +1265,34 @@ func PersistAsBeads(findings []Finding, stores map[string]*beads.Store) (created return created } +// provenanceAlreadyRecorded reports whether an OPEN advisory bead that this +// report would land on already records provenance commit prov. +// +// Title matching mirrors Upsert (exact, or equal under beads.UpsertTitleKey) so +// the gate covers the same beads the write path would have refreshed — matching +// on the exact string alone would miss the cosmetic drift agents re-file with, +// and the gate would almost never fire. +func provenanceAlreadyRecorded(existing []*beads.Bead, title, prov string) bool { + key := beads.UpsertTitleKey(title) + for _, b := range existing { + if b.Type != beads.TypeAdvisory { + continue + } + // A resolved bead never gates: if the condition recurs after healing, + // the re-report has to open a fresh bead (#2575). + if b.Status == beads.StatusClosed || b.Status == beads.StatusDone { + continue + } + if b.Title != title && beads.UpsertTitleKey(b.Title) != key { + continue + } + if sameCommit(b.Meta(provenanceSHAMetadataKey), prov) { + return true + } + } + return false +} + func severityIcon(sev string) string { switch sev { case "critical": diff --git a/src/pkg/advisory/evidence.go b/src/pkg/advisory/evidence.go new file mode 100644 index 000000000..6f87fbb9f --- /dev/null +++ b/src/pkg/advisory/evidence.go @@ -0,0 +1,82 @@ +package advisory + +import ( + "crypto/sha256" + "encoding/hex" + "strconv" + "strings" + + "github.com/kubestellar/hive/pkg/beads" + "github.com/kubestellar/hive/pkg/logscrub" +) + +// evidenceHashMetadataKey is the bead metadata key holding the hash of the +// finding text (title, detail, file reference) the bead most recently +// recorded. It is the no-provenance counterpart of provenanceSHAMetadataKey: +// where a provenance SHA states where evidence was computed, this identifies +// WHAT was reported, so a byte-identical re-report can be recognised as a +// cached replay rather than fresh confirmation (#5236). +const evidenceHashMetadataKey = "evidence_hash" + +// evidenceReplayCountMetadataKey counts the identical no-provenance re-reports +// PersistAsBeads has skipped since this bead's evidence last changed. The +// digest renders a non-zero count as an unverified caption: repetition that +// used to read as "reported 3×, still happening" is really the same cached +// text replayed, and saying so is what stops a disproved finding and the +// downstream issue refuting it from both presenting as live work (#5236). +const evidenceReplayCountMetadataKey = "evidence_replay_count" + +// findingEvidenceHash identifies WHAT a finding reports: a hash over its +// scrubbed title, detail and file reference. Two reports hash equal only when +// they are textually identical — the deliberately narrow definition of "cached +// replay". Anything the producer changed, even a run number in the title, +// hashes differently and keeps the conservative pre-#5236 behavior of counting +// as confirmation; only verbatim replay is stopped from refreshing forever. +// +// Fields are scrubbed before hashing so the hash stays stable across the +// scrub-on-write round trip findings take through bead metadata. +func findingEvidenceHash(f Finding) string { + h := sha256.New() + for _, part := range []string{ + strings.TrimSpace(logscrub.ScrubString(f.Title)), + strings.TrimSpace(logscrub.ScrubString(f.Detail)), + strings.TrimSpace(logscrub.ScrubString(f.File)), + strconv.Itoa(f.Line), + } { + h.Write([]byte(part)) + // Field separator, so ("ab","c") and ("a","bc") hash differently. + h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// beadWithIdenticalEvidence returns the OPEN advisory bead this report would +// land on when that bead already records exactly this evidence hash, or nil. +// +// Title matching mirrors provenanceAlreadyRecorded (exact, or equal under +// beads.UpsertTitleKey): the stored hash belongs to whichever finding last +// refreshed the bead, and after Upsert folded a cosmetic title drift that +// finding's title is not the bead's own — an exact-title gate would wave every +// replay of the drifted report through. An empty stored hash never matches: +// beads written before this key existed must take the refresh path once (which +// stamps the hash) rather than be judged on evidence nobody recorded. +func beadWithIdenticalEvidence(existing []*beads.Bead, title, hash string) *beads.Bead { + key := beads.UpsertTitleKey(title) + for _, b := range existing { + if b.Type != beads.TypeAdvisory { + continue + } + // A resolved bead never gates: if the condition recurs after healing, + // the re-report has to open a fresh bead (#2575). + if b.Status == beads.StatusClosed || b.Status == beads.StatusDone { + continue + } + if b.Title != title && beads.UpsertTitleKey(b.Title) != key { + continue + } + if h := b.Meta(evidenceHashMetadataKey); h != "" && h == hash { + return b + } + } + return nil +} diff --git a/src/pkg/advisory/evidence_test.go b/src/pkg/advisory/evidence_test.go new file mode 100644 index 000000000..8385474e6 --- /dev/null +++ b/src/pkg/advisory/evidence_test.go @@ -0,0 +1,302 @@ +package advisory + +import ( + "strconv" + "strings" + "testing" + "time" + + "github.com/kubestellar/hive/pkg/beads" +) + +// The finding from #5236, verbatim in shape: a shell-coverage claim against +// Danathar/atomic-image-builder that carried no provenance, was disproved by +// the implementation, and stayed live as "reported 3×" because every cached +// replay counted as fresh confirmation. +func shellCoverageFinding() Finding { + return Finding{ + Agent: "quality", + Severity: "medium", + Type: "coverage-gap", + Title: "No coverage measurement for contrib/aib and container/entrypoint.sh shell scripts", + Detail: "Neither has any statement/branch coverage tool wired (no kcov/bashcov)", + File: "contrib/aib", + } +} + +func TestFindingEvidenceHashDiscriminatesEveryField(t *testing.T) { + base := shellCoverageFinding() + if findingEvidenceHash(base) != findingEvidenceHash(shellCoverageFinding()) { + t.Fatal("identical findings must hash identically or every replay reads as new evidence") + } + for name, mutate := range map[string]func(*Finding){ + "title": func(f *Finding) { f.Title += " (run #3291)" }, + "detail": func(f *Finding) { f.Detail = "re-checked: still no bashcov wired" }, + "file": func(f *Finding) { f.File = "container/entrypoint.sh" }, + "line": func(f *Finding) { f.Line = 12 }, + } { + t.Run(name, func(t *testing.T) { + f := shellCoverageFinding() + mutate(&f) + if findingEvidenceHash(f) == findingEvidenceHash(base) { + t.Errorf("a changed %s must change the evidence hash — anything the producer touched is a recomputation, not replay", name) + } + }) + } +} + +// TestPersistAsBeadsIdenticalNoProvenanceReplayDoesNotRefresh is the #5236 +// regression fixture. The first report must create a normally-stamped bead (a +// genuinely new no-provenance finding is never silently discarded); identical +// cached re-reports must NOT refresh the staleness clock, so pruning finally +// retires the bead on the normal schedule. +func TestPersistAsBeadsIdenticalNoProvenanceReplayDoesNotRefresh(t *testing.T) { + store, err := beads.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("creating store: %v", err) + } + stores := map[string]*beads.Store{"quality": store} + + if created := PersistAsBeads([]Finding{shellCoverageFinding()}, stores); created != 1 { + t.Fatalf("first report created %d beads, want 1", created) + } + b := store.List(beads.ListFilter{})[0] + if _, ok := b.LastSeen(); !ok { + t.Fatal("first report did not stamp LastSeenAt — the staleness clock never started") + } + if got := b.Meta(evidenceHashMetadataKey); got != findingEvidenceHash(shellCoverageFinding()) { + t.Errorf("evidence hash metadata = %q, want the report's own hash", got) + } + + stale := time.Now().Add(-10 * 24 * time.Hour) + if err := store.SetLastSeenAt(b.ID, stale); err != nil { + t.Fatalf("stamping bead: %v", err) + } + + // "reported 3×": two more byte-identical replays of the cached finding. + for i := 0; i < 2; i++ { + PersistAsBeads([]Finding{shellCoverageFinding()}, stores) + } + after, err := store.Get(b.ID) + if err != nil { + t.Fatalf("re-reading bead: %v", err) + } + seen, _ := after.LastSeen() + if !seen.Equal(stale.UTC()) { + t.Errorf("identical no-provenance replay refreshed LastSeenAt to %s; cached text must not count as confirmation", seen) + } + if got := after.Meta(evidenceReplayCountMetadataKey); got != "2" { + t.Errorf("replay count metadata = %q, want %q", got, "2") + } + if n := len(store.List(beads.ListFilter{})); n != 1 { + t.Fatalf("replays created extra beads: store holds %d", n) + } + + // The whole point: the disproved finding now ages out within one window. + if pruned := PruneStaleAdvisoryBeads(stores, 7*24*time.Hour); len(pruned) != 1 { + t.Errorf("stale replayed finding was not retired: pruned %v", pruned) + } +} + +// Changed evidence IS a recomputation: a no-provenance re-report whose text +// differs in any way must keep refreshing exactly as before #5236, and the +// bead must adopt the new evidence identity with its replay count reset. +func TestPersistAsBeadsChangedNoProvenanceEvidenceRefreshes(t *testing.T) { + store, err := beads.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("creating store: %v", err) + } + stores := map[string]*beads.Store{"quality": store} + + PersistAsBeads([]Finding{shellCoverageFinding()}, stores) + b := store.List(beads.ListFilter{})[0] + stale := time.Now().Add(-10 * 24 * time.Hour) + if err := store.SetLastSeenAt(b.ID, stale); err != nil { + t.Fatalf("stamping bead: %v", err) + } + // A replay first, so the reset below is observable. + PersistAsBeads([]Finding{shellCoverageFinding()}, stores) + + changed := shellCoverageFinding() + changed.Detail = "re-checked at HEAD: bashcov still absent from ci.yml" + PersistAsBeads([]Finding{changed}, stores) + + after, err := store.Get(b.ID) + if err != nil { + t.Fatalf("re-reading bead: %v", err) + } + seen, _ := after.LastSeen() + if !seen.After(stale.UTC()) { + t.Error("a re-report with changed evidence must refresh LastSeenAt") + } + if got := after.Meta(evidenceHashMetadataKey); got != findingEvidenceHash(changed) { + t.Errorf("evidence hash = %q, want the changed report's hash", got) + } + if got := after.Meta(evidenceReplayCountMetadataKey); got != "0" { + t.Errorf("replay count = %q, want it reset to 0 on changed evidence", got) + } +} + +// Re-verification with newer, explicit provenance refreshes even when the +// finding text is byte-identical: the producer states the evidence was +// recomputed at a named commit, which is exactly the strong signal the +// evidence gate exists to stand in for. +func TestPersistAsBeadsExplicitProvenanceBypassesEvidenceGate(t *testing.T) { + store, err := beads.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("creating store: %v", err) + } + stores := map[string]*beads.Store{"quality": store} + + PersistAsBeads([]Finding{shellCoverageFinding()}, stores) + b := store.List(beads.ListFilter{})[0] + stale := time.Now().Add(-10 * 24 * time.Hour) + if err := store.SetLastSeenAt(b.ID, stale); err != nil { + t.Fatalf("stamping bead: %v", err) + } + + verified := shellCoverageFinding() + verified.ProvenanceSHA = analyzedAtSHA + PersistAsBeads([]Finding{verified}, stores) + + after, err := store.Get(b.ID) + if err != nil { + t.Fatalf("re-reading bead: %v", err) + } + seen, _ := after.LastSeen() + if !seen.After(stale.UTC()) { + t.Error("identical text re-verified under explicit provenance must refresh LastSeenAt") + } + if got := after.Meta(provenanceSHAMetadataKey); got != analyzedAtSHA { + t.Errorf("provenance metadata = %q, want the newly stated %q", got, analyzedAtSHA) + } +} + +// The gate has to recognise the bead a replay would land on even when the +// stored evidence came in under a cosmetically drifted title that Upsert +// folded: the bead keeps its original title, the hash describes the drifted +// report, and a verbatim replay of THAT report must still be gated. +func TestPersistAsBeadsEvidenceGateFollowsTitleDrift(t *testing.T) { + store, err := beads.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("creating store: %v", err) + } + stores := map[string]*beads.Store{"quality": store} + + first := Finding{Agent: "quality", Severity: "high", Title: "pr-verifier.yml failing (run #3279)"} + PersistAsBeads([]Finding{first}, stores) + b := store.List(beads.ListFilter{})[0] + + // Drifted title, so the hash differs: refreshes via the Upsert fold and + // re-stamps the stored evidence hash with the drifted report's. + drifted := Finding{Agent: "quality", Severity: "high", Title: "pr-verifier.yml failing (run #3291)"} + PersistAsBeads([]Finding{drifted}, stores) + if n := len(store.List(beads.ListFilter{})); n != 1 { + t.Fatalf("drifted re-report created a second bead: store holds %d", n) + } + + stale := time.Now().Add(-10 * 24 * time.Hour) + if err := store.SetLastSeenAt(b.ID, stale); err != nil { + t.Fatalf("stamping bead: %v", err) + } + PersistAsBeads([]Finding{drifted}, stores) + + after, err := store.Get(b.ID) + if err != nil { + t.Fatalf("re-reading bead: %v", err) + } + seen, _ := after.LastSeen() + if !seen.Equal(stale.UTC()) { + t.Errorf("verbatim replay of the folded report refreshed LastSeenAt to %s; the gate must match the bead the way Upsert would", seen) + } +} + +// Beads written before evidence_hash existed carry no stored hash, and "cannot +// tell" must mean "do not gate": the first re-report refreshes as before (and +// stamps the hash), so only the second identical replay is recognised. +func TestPersistAsBeadsLegacyBeadWithoutHashRefreshesOnce(t *testing.T) { + store, err := beads.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("creating store: %v", err) + } + stores := map[string]*beads.Store{"quality": store} + f := shellCoverageFinding() + + // A pre-#5236 bead: same title, no evidence metadata at all. + legacy, err := store.Upsert(f.Title, beads.TypeAdvisory, beads.PriorityMedium, "quality", "") + if err != nil { + t.Fatalf("creating legacy bead: %v", err) + } + stale := time.Now().Add(-10 * 24 * time.Hour) + if err := store.SetLastSeenAt(legacy.ID, stale); err != nil { + t.Fatalf("stamping bead: %v", err) + } + + PersistAsBeads([]Finding{f}, stores) + after, err := store.Get(legacy.ID) + if err != nil { + t.Fatalf("re-reading bead: %v", err) + } + seen, _ := after.LastSeen() + if !seen.After(stale.UTC()) { + t.Fatal("a legacy bead with no stored hash must refresh on the first re-report — an empty hash is not evidence of replay") + } + if got := after.Meta(evidenceHashMetadataKey); got != findingEvidenceHash(f) { + t.Errorf("first re-report did not stamp the evidence hash (got %q)", got) + } + + if err := store.SetLastSeenAt(legacy.ID, stale); err != nil { + t.Fatalf("re-stamping bead: %v", err) + } + PersistAsBeads([]Finding{f}, stores) + after, err = store.Get(legacy.ID) + if err != nil { + t.Fatalf("re-reading bead: %v", err) + } + seen, _ = after.LastSeen() + if !seen.Equal(stale.UTC()) { + t.Error("once the hash is stamped, an identical replay must stop refreshing") + } +} + +// The reader-facing half of #5236: a finding kept in the digest only by cached +// replays must carry an unverified caption, so the digest never presents the +// possibly-disproved claim and a downstream refutation as equally live work. +// A finding without replays renders uncaptioned exactly as before. +func TestBuildDigestCaptionsCachedReplays(t *testing.T) { + store, err := beads.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("creating store: %v", err) + } + stores := map[string]*beads.Store{"quality": store} + + replayed := shellCoverageFinding() + fresh := Finding{Agent: "quality", Severity: "medium", Type: "coverage-gap", Title: "a finding reported exactly once"} + PersistAsBeads([]Finding{replayed, fresh}, stores) + // Two cached replays — the digest should say so, not count them as + // confirmations. + PersistAsBeads([]Finding{replayed}, stores) + PersistAsBeads([]Finding{replayed}, stores) + + d := BuildDigestFromBeads(stores, "advisory", DigestOptions{ + Snapshot: &Snapshot{Owner: "Danathar", Repo: "atomic-image-builder", SHA: analyzedAtSHA}, + }) + var got Finding + for _, f := range d.ByAgent["quality"] { + if f.Title == replayed.Title { + got = f + } + } + if got.CachedReplays != 2 { + t.Fatalf("digest finding carries CachedReplays=%d, want 2", got.CachedReplays) + } + + out := FormatDigestMarkdown(d, DigestOptions{Org: "Danathar", PrimaryRepo: "atomic-image-builder"}) + want := "re-reported " + strconv.Itoa(got.CachedReplays) + "× from cached evidence, not re-verified" + if !strings.Contains(out, want) { + t.Errorf("replayed finding is not captioned as unverified:\n%s", out) + } + if strings.Contains(out, "a finding reported exactly once ⚠️") { + t.Errorf("a finding with no cached replays must not be captioned:\n%s", out) + } +} diff --git a/src/pkg/advisory/filepathref_test.go b/src/pkg/advisory/filepathref_test.go new file mode 100644 index 000000000..f741151c4 --- /dev/null +++ b/src/pkg/advisory/filepathref_test.go @@ -0,0 +1,52 @@ +package advisory + +import "testing" + +// Direct branch coverage for splitFilePathRef and isFilePathRef — the parsers +// that decide which finding refs VerifyFindingPaths checks for existence and +// what path it checks. snapshot_test.go exercises them only through the happy +// VerifyFindingPaths flow; these tests pin the edge branches so a parser +// regression shows up here rather than as a wrongly-stale (or wrongly-live) +// finding in a posted digest. + +func TestSplitFilePathRef(t *testing.T) { + cases := []struct { + name, ref, want string + }{ + {"path with line suffix", "pkg/mint/tokenreview.go:365", "pkg/mint/tokenreview.go"}, + {"path without colon", "docs/install.md", "docs/install.md"}, + {"non-numeric suffix kept", "cmd/hive:main", "cmd/hive:main"}, + {"leading colon kept", ":123", ":123"}, + {"empty ref", "", ""}, + {"trailing colon kept", "pkg/file.go:", "pkg/file.go:"}, + {"only last numeric segment stripped", "a:1:2", "a:1"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := splitFilePathRef(tc.ref); got != tc.want { + t.Errorf("splitFilePathRef(%q) = %q, want %q", tc.ref, got, tc.want) + } + }) + } +} + +func TestIsFilePathRef(t *testing.T) { + cases := []struct { + name, ref string + want bool + }{ + {"empty is not a path", "", false}, + {"gh-number ref", "gh-123", false}, + {"repo#number ref", "hive#123", false}, + {"owner/repo#number ref", "kubestellar/hive#123", false}, + {"plain file path", "docs/install.md", true}, + {"path with line", "pkg/advisory/advisory.go:812", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isFilePathRef(tc.ref); got != tc.want { + t.Errorf("isFilePathRef(%q) = %v, want %v", tc.ref, got, tc.want) + } + }) + } +} diff --git a/src/pkg/advisory/newstore_test.go b/src/pkg/advisory/newstore_test.go new file mode 100644 index 000000000..86ef38498 --- /dev/null +++ b/src/pkg/advisory/newstore_test.go @@ -0,0 +1,22 @@ +package advisory + +import "testing" + +// TestNewStore covers the constructor (previously 0% covered): every field a +// later Store method dereferences must be initialized, or the first +// ReadNewFindings/digest call on a fresh hive panics instead of reporting. +func TestNewStore(t *testing.T) { + s := NewStore() + if s == nil { + t.Fatal("NewStore returned nil") + } + if s.dir != advisoryDir { + t.Errorf("dir = %q, want %q", s.dir, advisoryDir) + } + if s.lastReadPos == nil { + t.Error("lastReadPos map not initialized — ReadNewFindings would panic on assignment") + } + if s.latestDigest != nil { + t.Errorf("latestDigest = %+v on a fresh store, want nil", s.latestDigest) + } +} diff --git a/src/pkg/advisory/provenance.go b/src/pkg/advisory/provenance.go new file mode 100644 index 000000000..771fe3961 --- /dev/null +++ b/src/pkg/advisory/provenance.go @@ -0,0 +1,144 @@ +package advisory + +import ( + "regexp" + "strings" +) + +// provenanceSHAMetadataKey is the bead metadata key holding the commit a +// finding's evidence was computed at. +const provenanceSHAMetadataKey = "provenance_sha" + +// shortProvenanceSHALen is how much of a provenance SHA the digest renders. +const shortProvenanceSHALen = 7 + +// minProvenanceSHALen is the shortest abbreviation accepted as a commit id, and +// must stay in step with the {7,40} bound in provenanceRefPattern. Seven is +// git's own default abbreviation length; shorter tokens are far too likely to +// be an ordinary hex-looking word. +const minProvenanceSHALen = 7 + +// provenanceRefPattern extracts the commit a finding's evidence was computed at +// from the finding's free text. Agents already write provenance this way — +// "revision c9546a8a24b3dded3146e3ab7a93dd99edc56fa3", "CI run 33187518367, +// commit 9a6313c" — but only as prose, invisible to the pipeline (#5130). +// +// A keyword is REQUIRED before the hex token. A bare 7+ hex run turns up in +// ordinary finding text (log ids, digests, base16 constants) far too often to +// read as a commit reference on its own. +var provenanceRefPattern = regexp.MustCompile( + `(?i)\b(?:computed[\s-]+at|measured[\s-]+at|generated[\s-]+at|as[\s-]+of|provenance|revision|commit|sha)\b[\s:=@]*` + + "`?([0-9a-fA-F]{7,40})`?(?:[^0-9a-zA-Z]|$)") + +// normalizeSHA canonicalises a commit id for comparison, returning "" when the +// input is not a plausible abbreviated-or-full git SHA. +func normalizeSHA(s string) string { + s = strings.TrimSpace(strings.Trim(strings.TrimSpace(s), "`")) + s = strings.ToLower(s) + if len(s) < minProvenanceSHALen || len(s) > 40 { + return "" + } + for _, r := range s { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') { + return "" + } + } + return s +} + +// sameCommit reports whether two commit ids denote the same commit, tolerating +// abbreviation: a finding cites "c9546a8" where the snapshot carries the full +// 40-character SHA, so equality has to be a prefix comparison at the shorter +// length. An unusable id on either side means "cannot tell", reported as false +// — every caller here treats "cannot tell" as "do not act". +func sameCommit(a, b string) bool { + na, nb := normalizeSHA(a), normalizeSHA(b) + if na == "" || nb == "" { + return false + } + n := len(na) + if len(nb) < n { + n = len(nb) + } + return na[:n] == nb[:n] +} + +// extractProvenanceSHA pulls a commit id out of finding prose, or "" when the +// text names none. +func extractProvenanceSHA(text string) string { + if text == "" { + return "" + } + m := provenanceRefPattern.FindStringSubmatch(text) + if m == nil { + return "" + } + return normalizeSHA(m[1]) +} + +// findingProvenanceSHA returns the commit a finding's evidence was computed at: +// the explicit ProvenanceSHA when the agent (or the bead metadata) supplied +// one, otherwise whatever the finding's own prose names. +// +// The two sources are deliberately NOT interchangeable for every caller. The +// explicit field is a statement by the producer; the prose match is an +// inference. An inference is good enough to LABEL a finding as computed +// elsewhere, but not to change whether that finding survives staleness pruning +// — so PersistAsBeads uses the explicit field alone. +func findingProvenanceSHA(f Finding) string { + if s := normalizeSHA(f.ProvenanceSHA); s != "" { + return s + } + return extractProvenanceSHA(f.Detail) +} + +// shortSHA abbreviates a commit id for rendering. +func shortSHA(sha string) string { + if len(sha) > shortProvenanceSHALen { + return sha[:shortProvenanceSHALen] + } + return sha +} + +// MarkStaleProvenance flags every finding whose own evidence was computed at a +// commit OTHER than the digest's analyzed snapshot. +// +// This is the freshness check missing behind #5130. The footer stamps the whole +// digest "Analyzed at ", but findings are re-rendered verbatim from open +// beads every cycle and nothing re-evaluates their evidence — so a finding +// computed several cycles and one merged fix ago is published under a commit at +// which it does not reproduce. Two such findings outlived their fix by eighteen +// hours that way, and a re-verifying agent that checked them against the +// stamped commit concluded the evidence was fabricated when it was only stale. +// +// Re-running each finding's own evidence is not something this pipeline can do +// — the evidence is arbitrary: a grep, a workflow-file read, a coverage run — +// so this does the honest thing rather than the impossible one and says which +// findings were NOT computed at the commit the digest names. A finding with no +// discoverable provenance is left unmarked: silence about provenance must not +// read as a freshness claim either. +func MarkStaleProvenance(d *Digest) { + if d == nil || d.AnalyzedSnapshot == nil { + return + } + analyzed := normalizeSHA(d.AnalyzedSnapshot.SHA) + if analyzed == "" { + return + } + for agent, findings := range d.ByAgent { + for i := range findings { + f := &findings[i] + prov := findingProvenanceSHA(*f) + if prov == "" { + f.ProvenanceSHA = "" + f.ProvenanceStale = false + continue + } + // Canonicalise onto the finding so the renderer, and anything + // reading the digest JSON, see the value this comparison used. + f.ProvenanceSHA = prov + f.ProvenanceStale = !sameCommit(prov, analyzed) + } + d.ByAgent[agent] = findings + } +} diff --git a/src/pkg/advisory/provenance_test.go b/src/pkg/advisory/provenance_test.go new file mode 100644 index 000000000..0d527615c --- /dev/null +++ b/src/pkg/advisory/provenance_test.go @@ -0,0 +1,417 @@ +package advisory + +import ( + "strings" + "testing" + "time" + + "github.com/kubestellar/hive/pkg/beads" +) + +// The two commits from #5130: the digest's analyzed-at HEAD, and the older +// commit one of its republished findings was actually computed at. +const ( + analyzedAtSHA = "29cb70657c255aceaf53b6b2bc50bbf5433e9a00" + provenanceOfOne = "c9546a8a24b3dded3146e3ab7a93dd99edc56fa3" +) + +func TestNormalizeSHA(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"full sha", analyzedAtSHA, analyzedAtSHA}, + {"abbreviated", "c9546a8", "c9546a8"}, + {"uppercase folds", "C9546A8", "c9546a8"}, + {"backticks and spaces stripped", " `c9546a8` ", "c9546a8"}, + {"too short", "c9546a", ""}, + {"too long", strings.Repeat("a", 41), ""}, + {"not hex", "notahexsha", ""}, + {"the SHA: unknown footer literal", "unknown", ""}, + {"empty", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := normalizeSHA(tc.in); got != tc.want { + t.Errorf("normalizeSHA(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestSameCommitToleratesAbbreviation is the comparison that decides whether a +// finding is republished under a commit it was not computed at. Findings cite +// short SHAs while the snapshot carries the full 40, so exact equality would +// mark every single finding stale. +func TestSameCommitToleratesAbbreviation(t *testing.T) { + if !sameCommit("29cb706", analyzedAtSHA) { + t.Error("abbreviated SHA should match the full SHA it prefixes") + } + if !sameCommit(analyzedAtSHA, analyzedAtSHA) { + t.Error("a SHA should match itself") + } + if sameCommit(provenanceOfOne, analyzedAtSHA) { + t.Error("two different commits must not compare equal") + } + // "Cannot tell" is never "same": an unusable id must not silently suppress + // the staleness marker, nor gate a finding out of the keep-alive path. + if sameCommit("", analyzedAtSHA) || sameCommit("unknown", analyzedAtSHA) { + t.Error("an unusable commit id must not compare equal to anything") + } +} + +// TestExtractProvenanceSHA covers the recovery path that makes this fix work on +// findings that already exist: agents write provenance as prose, so the SHAs in +// the #5130 digest are only reachable by reading Detail. +func TestExtractProvenanceSHA(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "finding 1 wording from #5130", + in: "contrib/aib has no test coverage; revision " + provenanceOfOne, + want: provenanceOfOne, + }, + { + name: "finding 2 wording from #5130", + in: "36 remaining partial branches (CI run 33187518367, commit 9a6313c)", + want: "9a6313c", + }, + {"backticked", "computed at `29cb706`", "29cb706"}, + {"provenance label", "provenance: c9546a8", "c9546a8"}, + {"as of", "as of 29cb706 the suite passes", "29cb706"}, + {"no keyword means no match", "hash deadbeef1234 appeared in the log", ""}, + {"unrelated long numbers", "CI run 33187518367 failed", ""}, + {"sha unknown is not a commit", "SHA: unknown", ""}, + {"empty", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := extractProvenanceSHA(tc.in); got != tc.want { + t.Errorf("extractProvenanceSHA(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestMarkStaleProvenanceFlagsOlderEvidence reproduces #5130 directly: a digest +// stamped "Analyzed at 29cb706" carrying a finding whose evidence was computed +// at c9546a8 must say so, while a finding computed at the analyzed commit is +// left clean. +func TestMarkStaleProvenanceFlagsOlderEvidence(t *testing.T) { + d := &Digest{ + AnalyzedSnapshot: &Snapshot{Owner: "Danathar", Repo: "atomic-image-builder", SHA: analyzedAtSHA}, + ByAgent: map[string][]Finding{ + "quality": { + { + Agent: "quality", + Title: "contrib/aib has no test coverage", + Detail: "revision " + provenanceOfOne, + }, + { + Agent: "quality", + Title: "computed against the analyzed commit", + ProvenanceSHA: "29cb706", + }, + { + Agent: "quality", + Title: "no provenance anywhere", + }, + }, + }, + } + MarkStaleProvenance(d) + + got := d.ByAgent["quality"] + if !got[0].ProvenanceStale { + t.Error("a finding computed at an older commit must be marked stale") + } + if got[0].ProvenanceSHA != provenanceOfOne { + t.Errorf("provenance SHA = %q, want the one recovered from Detail (%q)", got[0].ProvenanceSHA, provenanceOfOne) + } + if got[1].ProvenanceStale { + t.Error("a finding computed AT the analyzed commit must not be marked stale") + } + // Silence about provenance is not a freshness claim in either direction: + // an unmarked finding must not be captioned as verified OR as stale. + if got[2].ProvenanceStale || got[2].ProvenanceSHA != "" { + t.Errorf("a finding with no provenance must be left unmarked, got stale=%v sha=%q", got[2].ProvenanceStale, got[2].ProvenanceSHA) + } +} + +// A digest with no pinned snapshot cannot judge freshness, so it must not +// pretend to. This keeps every caller that does not resolve a snapshot (older +// flows, tests) on exactly the previous behavior. +func TestMarkStaleProvenanceNoopWithoutSnapshot(t *testing.T) { + for _, tc := range []struct { + name string + d *Digest + }{ + {"nil digest", nil}, + {"no snapshot", &Digest{ByAgent: map[string][]Finding{"q": {{Detail: "commit " + provenanceOfOne}}}}}, + {"snapshot with unusable sha", &Digest{ + AnalyzedSnapshot: &Snapshot{SHA: "unknown"}, + ByAgent: map[string][]Finding{"q": {{Detail: "commit " + provenanceOfOne}}}, + }}, + } { + t.Run(tc.name, func(t *testing.T) { + MarkStaleProvenance(tc.d) + if tc.d == nil { + return + } + if tc.d.ByAgent["q"][0].ProvenanceStale { + t.Error("nothing may be marked stale without a usable analyzed commit") + } + }) + } +} + +// TestFormatDigestMarkdownCaptionsStaleProvenance is the reader-facing half of +// the fix: the #5130 finding must carry its own provenance caption rather than +// being covered by the digest-wide "Analyzed at" stamp, and the footer must +// stop claiming more freshness than it has. +func TestFormatDigestMarkdownCaptionsStaleProvenance(t *testing.T) { + d := &Digest{ + GeneratedAt: time.Now(), + TotalCount: 2, + AnalyzedSnapshot: &Snapshot{Owner: "Danathar", Repo: "atomic-image-builder", SHA: analyzedAtSHA}, + ByAgent: map[string][]Finding{ + "quality": { + { + Agent: "quality", + Type: "coverage-gap", + Severity: "high", + Title: "contrib/aib has no test coverage", + Detail: "revision " + provenanceOfOne, + }, + { + Agent: "quality", + Type: "coverage-gap", + Severity: "high", + Title: "still reproduces at the analyzed commit", + }, + }, + }, + } + MarkStaleProvenance(d) + out := FormatDigestMarkdown(d, DigestOptions{Org: "Danathar", PrimaryRepo: "atomic-image-builder"}) + + if !strings.Contains(out, "evidence computed at `c9546a8`, not re-verified at the analyzed commit") { + t.Errorf("stale-provenance finding is not captioned:\n%s", out) + } + if strings.Contains(out, "still reproduces at the analyzed commit ⚠️") { + t.Errorf("a finding with no stale provenance must not be captioned:\n%s", out) + } + if !strings.Contains(out, "have NOT been re-verified here") { + t.Errorf("footer does not disclose that ⚠️ findings are unverified:\n%s", out) + } +} + +// TestPersistAsBeadsGatesKeepAliveOnProvenance is the behavioral half: a +// re-report from the SAME evidence must not refresh the staleness clock, which +// is what let the #5130 findings survive five regeneration cycles after their +// fix landed. +func TestPersistAsBeadsGatesKeepAliveOnProvenance(t *testing.T) { + store, err := beads.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("creating store: %v", err) + } + stores := map[string]*beads.Store{"quality": store} + finding := Finding{ + Agent: "quality", + Severity: "high", + Type: "coverage-gap", + Title: "contrib/aib has no test coverage", + ProvenanceSHA: provenanceOfOne, + } + + if created := PersistAsBeads([]Finding{finding}, stores); created != 1 { + t.Fatalf("first report created %d beads, want 1", created) + } + open := store.List(beads.ListFilter{}) + if len(open) != 1 { + t.Fatalf("store holds %d beads, want 1", len(open)) + } + b := open[0] + if got := b.Meta(provenanceSHAMetadataKey); got != provenanceOfOne { + t.Errorf("provenance metadata = %q, want %q", got, provenanceOfOne) + } + + // Age the bead past a staleness window, then re-report the identical + // evidence. The clock must keep running. + stale := time.Now().Add(-10 * 24 * time.Hour) + if err := store.SetLastSeenAt(b.ID, stale); err != nil { + t.Fatalf("stamping bead: %v", err) + } + PersistAsBeads([]Finding{finding}, stores) + after, err := store.Get(b.ID) + if err != nil { + t.Fatalf("re-reading bead: %v", err) + } + seen, ok := after.LastSeen() + if !ok { + t.Fatal("bead lost its LastSeenAt stamp") + } + if !seen.Equal(stale.UTC()) { + t.Errorf("identical-provenance re-report refreshed LastSeenAt to %s; it must leave the staleness clock running", seen) + } + if pruned := PruneStaleAdvisoryBeads(stores, 7*24*time.Hour); len(pruned) != 1 { + t.Errorf("stale finding was not retired: pruned %v", pruned) + } +} + +// The mirror of the test above: evidence RE-COMPUTED at a newer commit is a +// genuine confirmation, so it must refresh the clock and record the new +// provenance. Without this the gate would retire findings that still hold. +func TestPersistAsBeadsRefreshesOnNewProvenance(t *testing.T) { + store, err := beads.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("creating store: %v", err) + } + stores := map[string]*beads.Store{"quality": store} + title := "contrib/aib has no test coverage" + + PersistAsBeads([]Finding{{ + Agent: "quality", Severity: "high", Title: title, ProvenanceSHA: provenanceOfOne, + }}, stores) + b := store.List(beads.ListFilter{})[0] + stale := time.Now().Add(-10 * 24 * time.Hour) + if err := store.SetLastSeenAt(b.ID, stale); err != nil { + t.Fatalf("stamping bead: %v", err) + } + + PersistAsBeads([]Finding{{ + Agent: "quality", Severity: "high", Title: title, ProvenanceSHA: analyzedAtSHA, + }}, stores) + + after, err := store.Get(b.ID) + if err != nil { + t.Fatalf("re-reading bead: %v", err) + } + seen, _ := after.LastSeen() + if !seen.After(stale.UTC()) { + t.Error("a re-report computed at a NEWER commit must refresh LastSeenAt") + } + if got := after.Meta(provenanceSHAMetadataKey); got != analyzedAtSHA { + t.Errorf("provenance metadata = %q, want the newly recorded %q", got, analyzedAtSHA) + } +} + +// The "findings without provenance are unaffected" contract that used to be +// pinned here was consciously retired by #5236: a byte-identical no-provenance +// re-report is now recognised as cached replay and no longer refreshes +// LastSeenAt. The replacement contracts live in evidence_test.go. + +// The prose-inferred SHA is good enough to caption a finding but must never +// decide whether it ages out: "fixed in commit " in a Detail would +// otherwise retire a finding that still holds. The re-report varies its +// wording (while still citing the same commit) because a byte-identical +// replay is now gated on evidence identity (#5236), which is not what this +// test is about. +func TestPersistAsBeadsIgnoresProseProvenance(t *testing.T) { + store, err := beads.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("creating store: %v", err) + } + stores := map[string]*beads.Store{"quality": store} + f := Finding{ + Agent: "quality", + Severity: "high", + Title: "a finding whose detail merely mentions a commit", + Detail: "regressed in commit " + provenanceOfOne, + } + + PersistAsBeads([]Finding{f}, stores) + b := store.List(beads.ListFilter{})[0] + if got := b.Meta(provenanceSHAMetadataKey); got != "" { + t.Errorf("prose-inferred SHA was recorded as provenance metadata (%q); only explicit provenance may be", got) + } + stale := time.Now().Add(-10 * 24 * time.Hour) + if err := store.SetLastSeenAt(b.ID, stale); err != nil { + t.Fatalf("stamping bead: %v", err) + } + + f.Detail = "still regressed in commit " + provenanceOfOne + ", re-checked today" + PersistAsBeads([]Finding{f}, stores) + after, err := store.Get(b.ID) + if err != nil { + t.Fatalf("re-reading bead: %v", err) + } + seen, _ := after.LastSeen() + if !seen.After(stale.UTC()) { + t.Error("a prose-only commit mention must not gate the keep-alive refresh") + } +} + +// The gate has to recognise the same bead Upsert would, or an agent's cosmetic +// title drift ("run #3279" -> "run #3291") would slip identical evidence past +// it every cycle — exactly the drift beads.UpsertTitleKey exists to fold. +func TestPersistAsBeadsGateFollowsTitleDrift(t *testing.T) { + store, err := beads.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("creating store: %v", err) + } + stores := map[string]*beads.Store{"quality": store} + + PersistAsBeads([]Finding{{ + Agent: "quality", Severity: "high", + Title: "pr-verifier.yml failing (run #3279)", + ProvenanceSHA: provenanceOfOne, + }}, stores) + b := store.List(beads.ListFilter{})[0] + stale := time.Now().Add(-10 * 24 * time.Hour) + if err := store.SetLastSeenAt(b.ID, stale); err != nil { + t.Fatalf("stamping bead: %v", err) + } + + PersistAsBeads([]Finding{{ + Agent: "quality", Severity: "high", + Title: "pr-verifier.yml failing (run #3291)", + ProvenanceSHA: provenanceOfOne, + }}, stores) + + if n := len(store.List(beads.ListFilter{})); n != 1 { + t.Fatalf("drifted re-report created a second bead: store holds %d", n) + } + after, err := store.Get(b.ID) + if err != nil { + t.Fatalf("re-reading bead: %v", err) + } + seen, _ := after.LastSeen() + if !seen.Equal(stale.UTC()) { + t.Errorf("title drift let identical evidence refresh LastSeenAt (now %s)", seen) + } +} + +// Provenance recorded on the bead must survive the round trip into the digest, +// so a finding persisted with explicit provenance is captioned on the next +// cycle without re-parsing prose. +func TestBuildDigestFromBeadsCarriesProvenance(t *testing.T) { + store, err := beads.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("creating store: %v", err) + } + stores := map[string]*beads.Store{"quality": store} + PersistAsBeads([]Finding{{ + Agent: "quality", Severity: "high", Type: "coverage-gap", + Title: "contrib/aib has no test coverage", + ProvenanceSHA: provenanceOfOne, + }}, stores) + + d := BuildDigestFromBeads(stores, "advisory", DigestOptions{ + Snapshot: &Snapshot{Owner: "Danathar", Repo: "atomic-image-builder", SHA: analyzedAtSHA}, + }) + if len(d.ByAgent["quality"]) != 1 { + t.Fatalf("digest holds %d findings, want 1", len(d.ByAgent["quality"])) + } + f := d.ByAgent["quality"][0] + if f.ProvenanceSHA != provenanceOfOne { + t.Errorf("provenance SHA = %q, want %q", f.ProvenanceSHA, provenanceOfOne) + } + if !f.ProvenanceStale { + t.Error("a finding computed at an older commit must be marked stale in the built digest") + } +} diff --git a/src/pkg/agent/agent_unit_test.go b/src/pkg/agent/agent_unit_test.go index 2875a5a33..5697fee49 100644 --- a/src/pkg/agent/agent_unit_test.go +++ b/src/pkg/agent/agent_unit_test.go @@ -1,6 +1,7 @@ package agent import ( + "context" "log/slog" "os" "path/filepath" @@ -664,7 +665,7 @@ func TestStopNotFound(t *testing.T) { func TestRestartNotFound(t *testing.T) { m := NewManager(map[string]config.AgentConfig{}, slog.Default(), ProjectContext{}) - err := m.Restart(nil, "nonexistent") + err := m.Restart(context.TODO(), "nonexistent") if err == nil { t.Error("should error for nonexistent agent") } diff --git a/src/pkg/agent/agy_launch_test.go b/src/pkg/agent/agy_launch_test.go index faeca69e8..59f36c75d 100644 --- a/src/pkg/agent/agy_launch_test.go +++ b/src/pkg/agent/agy_launch_test.go @@ -8,76 +8,60 @@ package agent // - a configured model is passed as --model --effort , // because agy silently IGNORES --model without --effort — dropping the // effort flag would make the configured model a no-op while looking fine. +// +// This test used to launch a real tmux pane with an agy stub and poll +// CaptureFullLog for ~35s waiting for the typed command to echo back. That is +// environment- and timing-dependent: in CI the pane frequently never +// materialized, the capture came back EMPTY, and the whole package failed — +// which zeroed pkg/agent's coverage score and re-fired the coverage floor +// issue even though no package had actually regressed (#5299). +// +// The flag contract lives in backendLaunchCmd, a pure function, so it is +// asserted directly here. The assertions themselves are unchanged: same flags, +// same model, same reason strings. import ( - "os" - "path/filepath" "strings" "testing" - "time" - - "github.com/kubestellar/hive/pkg/config" ) -// installAgyStub writes an agy stub into the stub bin dir already on PATH -// (TestMain does not pre-create one — agy postdates the original stub list). -// It renders the ❯ ready marker so readiness gates resolve and no launch -// goroutine outlives the test, then echoes stdin like the other stubs. -func installAgyStub(t *testing.T) { +// agyLaunchCmd builds the agy launch command the way Start does for an agent +// with no ToolsConfig: normalize the configured model for the backend, then +// hand it to backendLaunchCmd. Keeping the normalization step means the test +// still covers the model plumbing, not just the fmt.Sprintf. +func agyLaunchCmd(t *testing.T, model string) string { t.Helper() - p := filepath.Join(stubBinDir, "agy") - script := "#!/bin/sh\nprintf '\\342\\235\\257 ready\\n'\nexec cat\n" - if err := os.WriteFile(p, []byte(script), 0o755); err != nil { - t.Fatalf("writing agy stub: %v", err) - } - t.Cleanup(func() { _ = os.Remove(p) }) + const backend = "agy" + isInference := IsInferenceBackend(backend) + return backendLaunchCmd("agy", normalizeModelNameForBackend(model, backend, isInference), backend, isInference) } -// TestStart_AgyLaunchCommandLine launches a real agy-backed agent (stub -// binary, real tmux) and asserts the command line actually typed into the -// pane carries the #3910 flag contract. The pane is read via CaptureFullLog -// so wrapped lines are joined before matching. +// TestStart_AgyLaunchCommandLine asserts the command line agy is launched with +// carries the #3910 flag contract. func TestStart_AgyLaunchCommandLine(t *testing.T) { - if !tmuxAvailable() { - t.Skip("tmux not available") - } - t.Setenv("HIVE_WORK_DIR", t.TempDir()) - forceFastPaneShell(t) - installAgyStub(t) - // "gemini-pro" survives normalizeModelName unchanged (no trailing digit // segment), so the assertion below sees the configured model verbatim. - m := NewManager(map[string]config.AgentConfig{ - "worker": makeAgentConfig("agy", "gemini-pro"), - }, discardLogger(), ProjectContext{}) - - if err := m.Start(t.Context(), "worker"); err != nil { - t.Fatalf("Start(agy): %v", err) - } - defer cleanupAgent(t, m, "worker") + cmd := agyLaunchCmd(t, "gemini-pro") - // The launch line is typed into the pane in chunks; poll the joined - // capture until the full command is visible. - deadline := time.Now().Add(30 * time.Second) - var pane string - for time.Now().Before(deadline) { - out, err := m.CaptureFullLog("worker") - if err == nil && strings.Contains(out, "--effort") { - pane = out - break - } - time.Sleep(500 * time.Millisecond) + if !strings.Contains(cmd, "--dangerously-skip-permissions") { + t.Errorf("agy launched without --dangerously-skip-permissions — it will block on a per-tool approval prompt no one answers; cmd: %q", cmd) } - if pane == "" { - out, _ := m.CaptureFullLog("worker") - t.Fatalf("agy launch command never appeared in the pane; capture: %q", out) + if !strings.Contains(cmd, "--model gemini-pro --effort "+agyDefaultEffort) { + t.Errorf("agy launched without '--model gemini-pro --effort %s' — agy silently ignores --model without --effort, so the configured model would never take effect; cmd: %q", + agyDefaultEffort, cmd) } +} + +// TestAgyLaunchCommandLine_NoModel pins the other half of the contract: with no +// model configured, agy still gets the bypass flag, and it must NOT be given a +// bare --model/--effort pair built from an empty model. +func TestAgyLaunchCommandLine_NoModel(t *testing.T) { + cmd := agyLaunchCmd(t, "") - if !strings.Contains(pane, "--dangerously-skip-permissions") { - t.Error("agy launched without --dangerously-skip-permissions — it will block on a per-tool approval prompt no one answers") + if !strings.Contains(cmd, "--dangerously-skip-permissions") { + t.Errorf("agy must get --dangerously-skip-permissions even with no model configured; cmd: %q", cmd) } - if !strings.Contains(pane, "--model gemini-pro --effort "+agyDefaultEffort) { - t.Errorf("agy launched without '--model gemini-pro --effort %s' — agy silently ignores --model without --effort, so the configured model would never take effect; pane: %q", - agyDefaultEffort, pane) + if strings.Contains(cmd, "--model") || strings.Contains(cmd, "--effort") { + t.Errorf("agy with no configured model must not be passed --model/--effort; cmd: %q", cmd) } } diff --git a/src/pkg/agent/authprobe.go b/src/pkg/agent/authprobe.go index 354e290af..c2e0521c2 100644 --- a/src/pkg/agent/authprobe.go +++ b/src/pkg/agent/authprobe.go @@ -287,13 +287,14 @@ func (m *Manager) AgentAuthState(agentName string, uid int, backend string, runn return false, false } - // (4) FILE PROBE, agent-own home first, shared legacy path second. + // (4) FILE PROBE, agent-own home first, shared legacy path second. The + // POSITIVE half lives in credentialFileProves so the login detector can ask + // the same question without inheriting rules 1-3 (#5291). + proven := m.credentialFileProves(agentName, uid, backend) switch backend { case "claude": - for _, p := range agentClaudeCredentialPaths(agentName, uid, backend) { - if claude.HasValidToken(p) { - return true, true - } + if proven { + return true, true } // A found+valid token is positive proof (above). Its ABSENCE, however, is // NOT proof of "needs login" for Claude: unlike copilot/codex, Claude can @@ -310,30 +311,14 @@ func (m *Manager) AgentAuthState(agentName string, uid int, backend string, runn // pane-scan needsLogin signal at (3), which outranks this. return false, false case "copilot": - m.mu.RLock() - tok := m.copilotAuthToken - m.mu.RUnlock() - if tok != "" { + if proven { return true, true } - if _, err := os.Stat(copilotUserTokenProbePath); err == nil { - return true, true - } - for _, p := range agentCopilotConfigPaths(agentName, uid, backend) { - if copilotCredentialFileHasTokens(p) { - return true, true - } - } return false, true case "codex": - if codexEnvHasCredentials() { + if proven { return true, true } - for _, p := range agentCodexAuthPaths(agentName, uid, backend) { - if codexAuthFileHasCredentials(p) { - return true, true - } - } return false, true default: // gemini and any other interactive backend: we have no reliable probe, @@ -360,3 +345,99 @@ func (m *Manager) AgentAuthAvailable(agentName string) (available, known bool) { proc.paneMu.RUnlock() return m.AgentAuthState(agentName, proc.UID, backend, proc.State == StateRunning, needsLogin) } + +// credentialFileProves answers ONLY the positive half of the file probe: is +// there, right now, on-disk (or in-process) evidence that this agent's backend +// is authenticated? +// +// It is deliberately one-directional. `false` means "no proof", NOT "logged +// out" — Claude in particular can be authenticated with no credentials file +// this process can read (a live in-memory session, a per-UID HOME, a keychain), +// which is why AgentAuthState reports UNKNOWN rather than needs-login when this +// comes back false for claude. +// +// Split out of AgentAuthState for kubestellar/hive#5291. The login detector +// needs this question and must NOT get AgentAuthState's answer, whose +// precedence rules are built for a dashboard badge: rule 2 short-circuits to +// "unknown" for any RUNNING agent (the detector only ever scans running +// agents), and rule 3 lets the pane's own login text outrank the credential +// file (which is precisely the evidence the detector must not trust on its +// own). Sharing the code rather than copying it keeps the two answers from +// drifting the way #4699 describes. +func (m *Manager) credentialFileProves(agentName string, uid int, backend string) bool { + switch backend { + case "claude": + for _, p := range agentClaudeCredentialPaths(agentName, uid, backend) { + if claude.HasUsableToken(p) { + return true + } + } + case "copilot": + if m != nil { + m.mu.RLock() + tok := m.copilotAuthToken + m.mu.RUnlock() + if tok != "" { + return true + } + } + if _, err := os.Stat(copilotUserTokenProbePath); err == nil { + return true + } + for _, p := range agentCopilotConfigPaths(agentName, uid, backend) { + if copilotCredentialFileHasTokens(p) { + return true + } + } + case "codex": + if codexEnvHasCredentials() { + return true + } + for _, p := range agentCodexAuthPaths(agentName, uid, backend) { + if codexAuthFileHasCredentials(p) { + return true + } + } + } + return false +} + +// AgentHasValidCredential reports whether this agent's backend is DEMONSTRABLY +// authenticated right now (kubestellar/hive#5291). +// +// Positive evidence only, and that asymmetry is the whole point. The login +// detector uses it to decide when NOT to pause: proof of a working credential +// means a login prompt on screen is residue or a stuck CLI, which is the +// token-restart heal's job, not an operator's. Anything less than proof — +// including "we cannot check this backend at all" — returns false and leaves +// the detector's existing behaviour untouched. +// +// One honest limitation: only claude's credential carries an expiry this can +// verify (claude.HasUsableToken). Copilot and codex are checked for the +// PRESENCE of tokens, so a stale-but-present copilot token reads as valid here. +// That is the same trade the manager's own token-restart heal already makes in +// configHasTokens(), and it fails in the safe direction for this caller: the +// heal restarts the CLI, which is a recovery attempt, rather than the detector +// pausing the agent, which is not. +// +// For claude the question asked is "can a restart still use this?", not "is +// the access token live right now?". An access token that has aged out under a +// long-running session leaves a refresh grant that the next CLI start redeems, +// so proof survives a routine expiry — which is the state a busy hive spends +// part of every day in. +func (m *Manager) AgentHasValidCredential(agentName string) bool { + if m == nil { + return false + } + m.mu.RLock() + proc := m.agents[agentName] + m.mu.RUnlock() + if proc == nil { + return false + } + backend := proc.Config.Backend + if proc.BackendOverride != "" { + backend = proc.BackendOverride + } + return m.credentialFileProves(agentName, proc.UID, backend) +} diff --git a/src/pkg/agent/authprobe_credential_gate_test.go b/src/pkg/agent/authprobe_credential_gate_test.go new file mode 100644 index 000000000..e91153636 --- /dev/null +++ b/src/pkg/agent/authprobe_credential_gate_test.go @@ -0,0 +1,198 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kubestellar/hive/pkg/config" +) + +// writeExpiredClaudeCreds writes a credentials.json whose OAuth token expired +// in the past — the shape the login detector MUST still pause on. +func writeExpiredClaudeCreds(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + body := map[string]any{ + "claudeAiOauth": map[string]any{ + "accessToken": "stale-token", + "expiresAt": time.Now().Add(-1 * time.Hour).UnixMilli(), + }, + } + data, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write: %v", err) + } +} + +// TestCredentialFileProves_ClaudeExpiryIsHonoured is the core of the #5291 gate: +// the detector may only skip a pause on PROOF of a working credential, and for +// claude that means an unexpired token — the one backend where expiry is +// actually knowable from the file. +func TestCredentialFileProves_ClaudeExpiryIsHonoured(t *testing.T) { + emptySharedPaths(t) + m := &Manager{} + home := t.TempDir() + t.Setenv("HOME", home) + cred := filepath.Join(home, ".claude", ".credentials.json") + + if m.credentialFileProves("supervisor", 0, "claude") { + t.Fatal("no credential file anywhere must not read as proof") + } + + writeExpiredClaudeCreds(t, cred) + if m.credentialFileProves("supervisor", 0, "claude") { + t.Fatal("an EXPIRED token must not read as proof — that agent genuinely needs a login") + } + + writeClaudeCreds(t, cred) + if !m.credentialFileProves("supervisor", 0, "claude") { + t.Fatal("a fresh token is exactly the proof the detector must stand down for") + } +} + +// TestCredentialFileProves_SharedPathCountsForTheAgent reproduces the incident's +// own shape: the operator's /login refreshed the SHARED credential, and the +// agent whose pane still showed login chrome must be recognised as +// authenticated through that shared file. +func TestCredentialFileProves_SharedPathCountsForTheAgent(t *testing.T) { + emptySharedPaths(t) + m := &Manager{} + t.Setenv("HOME", t.TempDir()) // the agent's own home is empty + + if m.credentialFileProves("supervisor", 0, "claude") { + t.Fatal("precondition: nothing should be proven yet") + } + writeClaudeCreds(t, sharedClaudeCredentialPath) + if !m.credentialFileProves("supervisor", 0, "claude") { + t.Fatal("the shared credential the operator just refreshed must count for the agent") + } +} + +// TestCredentialFileProves_UncheckableBackendsStayUnproven pins the deliberate +// limit: a backend with no credential file this process can read yields no +// proof, so the detector keeps its existing behaviour for it rather than +// silently never pausing. +func TestCredentialFileProves_UncheckableBackendsStayUnproven(t *testing.T) { + emptySharedPaths(t) + emptyCodexSharedPath(t) + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEX_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + m := &Manager{} + + for _, backend := range append([]string{"gemini", "bob", "agy", ""}, config.InferenceBackends...) { + if m.credentialFileProves("supervisor", 0, backend) { + t.Errorf("backend %q has no checkable credential file — it must not claim proof", backend) + } + } +} + +// TestCredentialFileProves_CopilotAndCodexPresence documents the honest +// asymmetry: these two are checked for the PRESENCE of tokens, not for expiry, +// which is the same trade configHasTokens() already makes for the heal. +func TestCredentialFileProves_CopilotAndCodexPresence(t *testing.T) { + emptySharedPaths(t) + emptyCodexSharedPath(t) + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEX_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + m := &Manager{} + + if m.credentialFileProves("supervisor", 0, "copilot") { + t.Fatal("precondition: no copilot tokens yet") + } + if err := os.MkdirAll(filepath.Dir(sharedCopilotConfigPath), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(sharedCopilotConfigPath, + []byte(`{"copilotTokens":{"github.com":"tok"}}`), 0o600); err != nil { + t.Fatalf("write copilot config: %v", err) + } + if !m.credentialFileProves("supervisor", 0, "copilot") { + t.Fatal("a copilot config carrying tokens must read as proof") + } + + if m.credentialFileProves("supervisor", 0, "codex") { + t.Fatal("precondition: no codex auth yet") + } + writeCodexAuth(t, codexSharedAuthFile, `{"tokens":{"access_token":"tok"}}`) + if !m.credentialFileProves("supervisor", 0, "codex") { + t.Fatal("a codex auth.json carrying tokens must read as proof") + } +} + +// TestAgentHasValidCredential_ResolvesBackendAndNilSafety covers the public +// entry point the detector calls: it must resolve the agent's own backend +// (including a runtime override) and must never panic on an unknown agent or a +// nil manager. +func TestAgentHasValidCredential_ResolvesBackendAndNilSafety(t *testing.T) { + emptySharedPaths(t) + home := t.TempDir() + t.Setenv("HOME", home) + writeClaudeCreds(t, filepath.Join(home, ".claude", ".credentials.json")) + + var nilMgr *Manager + if nilMgr.AgentHasValidCredential("supervisor") { + t.Fatal("a nil manager cannot prove anything") + } + + m := &Manager{agents: map[string]*AgentProcess{}} + if m.AgentHasValidCredential("supervisor") { + t.Fatal("an agent the manager does not know cannot be proven authenticated") + } + + m.agents["supervisor"] = &AgentProcess{Config: config.AgentConfig{Backend: "claude"}} + if !m.AgentHasValidCredential("supervisor") { + t.Fatal("a claude agent with a fresh token must be proven authenticated") + } + + // A runtime backend override must be what gets probed — otherwise an agent + // switched to gemini would keep answering from claude's credential. + m.agents["supervisor"].BackendOverride = "gemini" + if m.AgentHasValidCredential("supervisor") { + t.Fatal("the override backend must be probed, not the configured one") + } +} + +// TestAgentAuthState_UnchangedByTheCredentialExtraction is the refactor guard: +// AgentAuthState's answers must not have moved when its positive file probe was +// split out for the detector to share. +func TestAgentAuthState_UnchangedByTheCredentialExtraction(t *testing.T) { + emptySharedPaths(t) + emptyCodexSharedPath(t) + t.Setenv("CODEX_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + home := t.TempDir() + t.Setenv("HOME", home) + m := &Manager{} + + // claude, nothing on disk: UNKNOWN (absence is not proof of logged-out). + if avail, known := m.AgentAuthState("a", 0, "claude", false, false); avail || known { + t.Fatalf("claude with no file: got (%v,%v), want (false,false)", avail, known) + } + // copilot, nothing on disk: KNOWN needs-login. + if avail, known := m.AgentAuthState("a", 0, "copilot", false, false); avail || !known { + t.Fatalf("copilot with no file: got (%v,%v), want (false,true)", avail, known) + } + // codex, nothing on disk: KNOWN needs-login. + if avail, known := m.AgentAuthState("a", 0, "codex", false, false); avail || !known { + t.Fatalf("codex with no file: got (%v,%v), want (false,true)", avail, known) + } + // claude with a fresh token: authenticated and known. + writeClaudeCreds(t, filepath.Join(home, ".claude", ".credentials.json")) + if avail, known := m.AgentAuthState("a", 0, "claude", false, false); !avail || !known { + t.Fatalf("claude with a fresh token: got (%v,%v), want (true,true)", avail, known) + } + // The pane's own login signal still outranks the file (rule 3). + if avail, known := m.AgentAuthState("a", 0, "claude", false, true); avail || !known { + t.Fatalf("needsLogin must still win: got (%v,%v), want (false,true)", avail, known) + } +} diff --git a/src/pkg/agent/claude_oauth_env_test.go b/src/pkg/agent/claude_oauth_env_test.go new file mode 100644 index 000000000..4eed33a05 --- /dev/null +++ b/src/pkg/agent/claude_oauth_env_test.go @@ -0,0 +1,156 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kubestellar/hive/pkg/config" +) + +// stageSharedClaudeCredential points the shared credential path at a temp file +// holding the given token set, and returns the path. +func stageSharedClaudeCredential(t *testing.T, oauth map[string]any) string { + t.Helper() + path := filepath.Join(t.TempDir(), ".credentials.json") + if oauth != nil { + data, err := json.Marshal(map[string]any{"claudeAiOauth": oauth}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + } + orig := sharedClaudeCredentialPath + sharedClaudeCredentialPath = path + t.Cleanup(func() { sharedClaudeCredentialPath = orig }) + + // Redirect BOTH home roots agentClaudeCredentialPaths can resolve to, or + // the assertions read a live credential instead of the fixture: the shared + // /data/home on a developer box that happens to run a hive, and — for an + // agent with no per-agent UID — the running user's own $HOME. + origHome := sharedAgentHome + sharedAgentHome = filepath.Join(t.TempDir(), "home") + t.Cleanup(func() { sharedAgentHome = origHome }) + t.Setenv("HOME", filepath.Join(t.TempDir(), "user-home")) + + return path +} + +func claudeAgentFixture(t *testing.T, token string) (*Manager, *AgentProcess) { + t.Helper() + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude", Model: "claude-sonnet-5"}, + }, discardLogger(), ProjectContext{ACMMLevel: 5}) + m.mu.Lock() + m.claudeAuthToken = token + m.mu.Unlock() + m.mu.RLock() + agent := m.agents["scanner"] + m.mu.RUnlock() + if agent == nil { + t.Fatal("fixture agent not registered") + } + return m, agent +} + +func envPairsByKey(m *Manager, agent *AgentProcess) map[string]agentEnvPair { + out := map[string]agentEnvPair{} + for _, p := range m.agentEnvPairs(agent) { + out[p.Key] = p + } + return out +} + +// TestClaudeOAuthEnv_NotInjectedOverAReadableCredential is the defect of #5454. +// +// CLAUDE_CODE_OAUTH_TOKEN is a static bearer override: with it set, Claude Code +// uses the value verbatim and never opens the credentials file, so it can never +// refresh. The value hive injected was a snapshot of the SHORT-LIVED access +// token taken once at manager construction, which pinned every claude agent to +// the 8h life of whatever token was on disk at container start. Once the +// agent can read the credential itself (true since per-agent homes, #4619), +// injecting the override only takes away the CLI's ability to recover. +func TestClaudeOAuthEnv_NotInjectedOverAReadableCredential(t *testing.T) { + stageSharedClaudeCredential(t, map[string]any{ + "accessToken": "sk-ant-oat-live", + "expiresAt": time.Now().Add(6 * time.Hour).UnixMilli(), + }) + m, agent := claudeAgentFixture(t, "sk-ant-oat-snapshot-from-boot") + + if _, ok := envPairsByKey(m, agent)["CLAUDE_CODE_OAUTH_TOKEN"]; ok { + t.Fatal("injected the static token override over a credential file the CLI can read and refresh") + } +} + +// The same must hold for the state a fleet enters roughly once a day: the +// access token has aged out, the refresh grant has not, and the next CLI start +// mints a new token from the file. Re-injecting the override here would defeat +// the recovery at the exact moment it was about to happen. +func TestClaudeOAuthEnv_NotInjectedOverARefreshableCredential(t *testing.T) { + stageSharedClaudeCredential(t, map[string]any{ + "accessToken": "sk-ant-oat-aged-out", + "expiresAt": time.Now().Add(-2 * time.Hour).UnixMilli(), + "refreshToken": "sk-ant-ort-live", + "refreshTokenExpiresAt": time.Now().Add(28 * 24 * time.Hour).UnixMilli(), + }) + m, agent := claudeAgentFixture(t, "sk-ant-oat-snapshot-from-boot") + + if _, ok := envPairsByKey(m, agent)["CLAUDE_CODE_OAUTH_TOKEN"]; ok { + t.Fatal("injected the static token override over a credential the CLI was about to refresh") + } +} + +// The job the variable was added for (c5648bc9) must survive: an agent with no +// credential file it can read still gets the dashboard-obtained token, as a +// secret pair so the value never reaches a command line. +func TestClaudeOAuthEnv_StillInjectedWithNoReadableCredential(t *testing.T) { + stageSharedClaudeCredential(t, nil) // path exists in name only; no file written + m, agent := claudeAgentFixture(t, "sk-ant-oat-from-dashboard-login") + + pair, ok := envPairsByKey(m, agent)["CLAUDE_CODE_OAUTH_TOKEN"] + if !ok { + t.Fatal("an agent with no readable credential must still receive the injected token") + } + if pair.Value != "sk-ant-oat-from-dashboard-login" { + t.Fatalf("value = %q", pair.Value) + } + if !pair.Secret { + t.Fatal("the token must stay a secret pair — it must never reach a command line or pane scrollback") + } +} + +// A spent credential (expired, no refresh grant) is not something the CLI can +// recover from, so the fallback still applies. +func TestClaudeOAuthEnv_InjectedOverASpentCredential(t *testing.T) { + stageSharedClaudeCredential(t, map[string]any{ + "accessToken": "sk-ant-oat-spent", + "expiresAt": time.Now().Add(-time.Hour).UnixMilli(), + }) + m, agent := claudeAgentFixture(t, "sk-ant-oat-from-dashboard-login") + + if _, ok := envPairsByKey(m, agent)["CLAUDE_CODE_OAUTH_TOKEN"]; !ok { + t.Fatal("a spent credential leaves nothing for the CLI to refresh; the fallback must still inject") + } +} + +// Non-claude backends never carried this variable and still must not. +func TestClaudeOAuthEnv_NotInjectedForOtherBackends(t *testing.T) { + stageSharedClaudeCredential(t, nil) + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "copilot"}, + }, discardLogger(), ProjectContext{ACMMLevel: 5}) + m.mu.Lock() + m.claudeAuthToken = "sk-ant-oat-from-dashboard-login" + m.mu.Unlock() + m.mu.RLock() + agent := m.agents["scanner"] + m.mu.RUnlock() + + if _, ok := envPairsByKey(m, agent)["CLAUDE_CODE_OAUTH_TOKEN"]; ok { + t.Fatal("a copilot agent must not receive a Claude token") + } +} diff --git a/src/pkg/agent/codex_home_nfs_safety_test.go b/src/pkg/agent/codex_home_nfs_safety_test.go new file mode 100644 index 000000000..ec0f8b675 --- /dev/null +++ b/src/pkg/agent/codex_home_nfs_safety_test.go @@ -0,0 +1,222 @@ +package agent + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// #5379 — NFS-safety structural pin +// --------------------------------------------------------------------------- +// +// WHY THIS TEST IS STRUCTURAL RATHER THAN BEHAVIOURAL. +// +// The bug it guards is invisible to every filesystem CI can mount. The heal +// used os.RemoveAll, which works perfectly on tmpfs, ext4, overlayfs and APFS +// — so a behavioural unit test passed for months while the product stayed +// broken. /data on hosted spokes is an NFSv3 PVC, and there os.RemoveAll +// descends with openat-based directory file descriptors that fail +// "openfdat ...: permission denied" even as root, leaving a renamed agent's +// codex backend dead until someone shells into the pod. +// +// Reproducing that requires a real NFSv3 export, which this suite does not +// have. Rather than write a test that merely proves the new code path runs +// (the failure shape of #5360 and #5370), this pins the MECHANISM: the codex +// home heal must reach the filesystem through an exec of chown/rm, never +// through Go's own tree walkers. If a future refactor "simplifies" it back to +// os.RemoveAll or filepath.WalkDir, this fails immediately and loudly instead +// of shipping a silent regression that only hosted spokes discover. +// +// This test does not skip under any condition. See #5380. + +// codexHealMechanismFuncs are the declarations that perform, or decide on, the +// filesystem repair of a wrong-owner CODEX_HOME. +var codexHealMechanismFuncs = map[string]bool{ + "healCodexHomeOwnership": true, + "chownCodexHomeToAgent": true, + "chownTreeAsRoot": true, + "removeTreeAsRoot": true, + "healForeignCodexConfig": true, +} + +// goTreeWalkers are Go stdlib calls that descend a directory tree using +// openat-based directory file descriptors, which NFSv3 does not reliably +// support. None of them may appear in the codex home heal. +var goTreeWalkers = map[string]string{ + "os.RemoveAll": "descends with openat dirfds; fails on the NFSv3 /data PVC (#5379)", + "filepath.Walk": "walks with openat dirfds; use an exec'd chown/rm instead (#5379)", + "filepath.WalkDir": "walks with openat dirfds; use an exec'd chown/rm instead (#5379)", + "os.ReadDir": "opens a dirfd to enumerate; use an exec'd chown/rm instead (#5379)", + "ioutil.ReadDir": "opens a dirfd to enumerate; use an exec'd chown/rm instead (#5379)", +} + +// TestCodexHomeHealUsesNFSSafeMechanisms parses manager.go and asserts that no +// function responsible for repairing a wrong-owner CODEX_HOME calls a Go +// stdlib tree walker. +func TestCodexHomeHealUsesNFSSafeMechanisms(t *testing.T) { + const src = "manager.go" + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, src, nil, 0) + if err != nil { + t.Fatalf("parsing %s: %v", src, err) + } + + seen := map[string]bool{} + for _, decl := range file.Decls { + name, body := codexHealDeclBody(decl) + if body == nil || !codexHealMechanismFuncs[name] { + continue + } + seen[name] = true + ast.Inspect(body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + qualified, ok := qualifiedCallName(call.Fun) + if !ok { + return true + } + if why, banned := goTreeWalkers[qualified]; banned { + t.Errorf("%s calls %s at %s: %s\n"+ + "The codex home heal must reach the filesystem via an exec'd "+ + "chown/rm (su-exec), not a Go tree walk.", + name, qualified, fset.Position(call.Pos()), why) + } + return true + }) + } + + // Guard the guard: if a function is renamed away, this test would silently + // stop checking anything. + for name := range codexHealMechanismFuncs { + if !seen[name] { + t.Errorf("expected to find %s in %s — was it renamed? "+ + "Update codexHealMechanismFuncs so this pin keeps covering the heal.", name, src) + } + } +} + +// TestCodexHomeHealShellsOutToChownAndRm is the positive half of the pin: the +// NFS-safe mechanisms must actually be exec'd, and the chown must use -h so it +// re-owns the auth.json SYMLINK itself rather than following it into the +// shared credential file. +func TestCodexHomeHealShellsOutToChownAndRm(t *testing.T) { + const src = "manager.go" + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, src, nil, 0) + if err != nil { + t.Fatalf("parsing %s: %v", src, err) + } + + var chownArgs, rmArgs []string + for _, decl := range file.Decls { + name, body := codexHealDeclBody(decl) + if body == nil { + continue + } + switch name { + case "chownTreeAsRoot": + chownArgs = execCommandStringArgs(body) + case "removeTreeAsRoot": + rmArgs = execCommandStringArgs(body) + } + } + + if len(chownArgs) == 0 { + t.Fatal("chownTreeAsRoot must exec a command; found no exec.Command call") + } + if len(rmArgs) == 0 { + t.Fatal("removeTreeAsRoot must exec a command; found no exec.Command call") + } + + joinedChown := strings.Join(chownArgs, " ") + if !strings.Contains(joinedChown, "su-exec") { + t.Errorf("chown must go through su-exec like the rest of setupCodexHome, got %q", joinedChown) + } + if !strings.Contains(joinedChown, "chown") { + t.Errorf("chownTreeAsRoot must exec chown, got %q", joinedChown) + } + // -h / --no-dereference: auth.json is a symlink to the SHARED credential + // file. Following it would rewrite ownership of every agent's login. + if !strings.Contains(joinedChown, "-Rh") && !strings.Contains(joinedChown, "--no-dereference") { + t.Errorf("chown must pass -h so it does not follow the shared auth.json symlink, got %q", joinedChown) + } + if !strings.Contains(joinedChown, "-R") { + t.Errorf("chown must be recursive to re-own the whole tree, got %q", joinedChown) + } + + joinedRm := strings.Join(rmArgs, " ") + if !strings.Contains(joinedRm, "su-exec") { + t.Errorf("rm must go through su-exec like the rest of setupCodexHome, got %q", joinedRm) + } + if !strings.Contains(joinedRm, "rm") || !strings.Contains(joinedRm, "-rf") { + t.Errorf("removeTreeAsRoot must exec `rm -rf` (the mechanism verified on the affected NFS mount), got %q", joinedRm) + } +} + +// codexHealDeclBody returns the name and body of a decl that is either a +// func declaration or a `var name = func(...){...}` assignment. +func codexHealDeclBody(decl ast.Decl) (string, ast.Node) { + switch d := decl.(type) { + case *ast.FuncDecl: + if d.Body == nil { + return "", nil + } + return d.Name.Name, d.Body + case *ast.GenDecl: + if d.Tok != token.VAR { + return "", nil + } + for _, spec := range d.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok || len(vs.Names) != 1 || len(vs.Values) != 1 { + continue + } + lit, ok := vs.Values[0].(*ast.FuncLit) + if !ok { + continue + } + return vs.Names[0].Name, lit.Body + } + } + return "", nil +} + +// qualifiedCallName renders a call target as "pkg.Func" for selector calls. +func qualifiedCallName(fun ast.Expr) (string, bool) { + sel, ok := fun.(*ast.SelectorExpr) + if !ok { + return "", false + } + ident, ok := sel.X.(*ast.Ident) + if !ok { + return "", false + } + return ident.Name + "." + sel.Sel.Name, true +} + +// execCommandStringArgs collects the string literal arguments of any +// exec.Command call inside body. +func execCommandStringArgs(body ast.Node) []string { + var args []string + ast.Inspect(body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if name, ok := qualifiedCallName(call.Fun); !ok || name != "exec.Command" { + return true + } + for _, arg := range call.Args { + if lit, ok := arg.(*ast.BasicLit); ok && lit.Kind == token.STRING { + args = append(args, strings.Trim(lit.Value, `"`)) + } + } + return true + }) + return args +} diff --git a/src/pkg/agent/coverage_boost_test.go b/src/pkg/agent/coverage_boost_test.go index 6ed416995..0b83b2de9 100644 --- a/src/pkg/agent/coverage_boost_test.go +++ b/src/pkg/agent/coverage_boost_test.go @@ -272,6 +272,35 @@ func TestAgentEnvPairs_WithAdvisoryIssue(t *testing.T) { } } +// Inference-routed claude sessions get the CLI telemetry switches so the +// CLI stops sending event-logging / error-report traffic to the gateway; +// subscription sessions do not (Anthropic's own telemetry is legitimate there). +func TestAgentEnvPairs_InferenceQuietCLIEnv(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "inf": {Backend: "vllm", Model: "llama-70b"}, + "sub": {Backend: "claude", Model: "sonnet"}, + }, discardLogger(), ProjectContext{}) + + has := func(pairs []agentEnvPair, key string) bool { + for _, p := range pairs { + if p.Key == key && p.Value == "1" { + return true + } + } + return false + } + inf := m.agentEnvPairs(&AgentProcess{Name: "inf", Config: config.AgentConfig{Backend: "vllm", Model: "llama-70b"}}) + sub := m.agentEnvPairs(&AgentProcess{Name: "sub", Config: config.AgentConfig{Backend: "claude", Model: "sonnet"}}) + for _, key := range inferenceQuietCLIEnv { + if !has(inf, key) { + t.Errorf("inference backend should set %s=1", key) + } + if has(sub, key) { + t.Errorf("subscription backend should not set %s", key) + } + } +} + func TestAgentEnvPairs_NonInference_NoAnthropicVars(t *testing.T) { m := NewManager(map[string]config.AgentConfig{ "scanner": {Backend: "claude", Model: "sonnet"}, @@ -1660,28 +1689,16 @@ func TestReadCoveragePreamble_DefaultTarget(t *testing.T) { } // --------------------------------------------------------------------------- -// configHasTokens — write to actual path +// configHasTokens — via the redirectable shared path // --------------------------------------------------------------------------- func TestConfigHasTokens_WithActualFile(t *testing.T) { - dir := filepath.Dir(sharedCopilotConfigPath) - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Skipf("cannot create config dir %s: %v", dir, err) - } - - // Save original if it exists - original, origErr := os.ReadFile(sharedCopilotConfigPath) - defer func() { - if origErr == nil { - os.WriteFile(sharedCopilotConfigPath, original, 0o660) - } else { - os.Remove(sharedCopilotConfigPath) - } - }() + cleanup := configTestHelper(t) + defer cleanup() cfg := `{"copilotTokens": {"github.com": {"token": "gho_test"}}}` if err := os.WriteFile(sharedCopilotConfigPath, []byte(cfg), 0o660); err != nil { - t.Skipf("cannot write config file: %v", err) + t.Fatalf("cannot write config file: %v", err) } if !configHasTokens() { @@ -1689,20 +1706,21 @@ func TestConfigHasTokens_WithActualFile(t *testing.T) { } } +// configTestHelper redirects sharedCopilotConfigPath to a file inside +// t.TempDir() and returns a cleanup that restores the original path. +// +// It must NEVER touch the production path: on a live hive host +// /data/home/.copilot/config.json holds the real shared Copilot credentials, +// and the previous save/overwrite/restore approach both clobbered them for the +// duration of the test (or forever, if the test binary died mid-run) and made +// these tests flaky — chmod/write on a foreign-owned live file fails with +// EPERM. sharedCopilotConfigPath is a var precisely so tests can redirect it +// (see the comment on its declaration in manager.go). func configTestHelper(t *testing.T) (cleanup func()) { t.Helper() - dir := filepath.Dir(sharedCopilotConfigPath) - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Skipf("cannot create config dir %s: %v", dir, err) - } - original, origErr := os.ReadFile(sharedCopilotConfigPath) - return func() { - if origErr == nil { - os.WriteFile(sharedCopilotConfigPath, original, 0o660) - } else { - os.Remove(sharedCopilotConfigPath) - } - } + orig := sharedCopilotConfigPath + sharedCopilotConfigPath = filepath.Join(t.TempDir(), "config.json") + return func() { sharedCopilotConfigPath = orig } } func TestConfigHasTokens_EmptyTokens_AtPath(t *testing.T) { diff --git a/src/pkg/agent/credential_watchdog_test.go b/src/pkg/agent/credential_watchdog_test.go index 9a5b82450..fb5812279 100644 --- a/src/pkg/agent/credential_watchdog_test.go +++ b/src/pkg/agent/credential_watchdog_test.go @@ -98,8 +98,30 @@ func writeClaudeCredsWithExpiry(t *testing.T, path, token string, expiresAtMilli } } -// TestClaudeTokenUsable proves the Claude probe distinguishes absent ("missing") -// from present-but-expired ("invalid or expired") from valid. +// writeClaudeCredsRefreshable writes a credentials file whose access token has +// expired but whose refresh grant has not — the state a Claude fleet enters +// roughly once a day, since access tokens live 8h. +func writeClaudeCredsRefreshable(t *testing.T, path string) { + t.Helper() + body := map[string]any{ + "claudeAiOauth": map[string]any{ + "accessToken": "sk-ant-oat-old", + "expiresAt": time.Now().Add(-2 * time.Hour).UnixMilli(), + "refreshToken": "sk-ant-ort-live", + "refreshTokenExpiresAt": time.Now().Add(28 * 24 * time.Hour).UnixMilli(), + }, + } + data, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} + +// TestClaudeTokenUsable proves the Claude probe distinguishes absent +// ("missing") from a spent login from a credential that still works. func TestClaudeTokenUsable(t *testing.T) { path := filepath.Join(t.TempDir(), ".credentials.json") @@ -107,11 +129,12 @@ func TestClaudeTokenUsable(t *testing.T) { t.Fatalf("absent file: expected (false,\"missing\"), got (%v,%q)", ok, reason) } - // Present but expired (expiresAt in the past). + // Expired access token, no refresh grant: genuinely spent, only a human + // can fix it, and the watchdog's operator alert is correct. past := time.Now().Add(-time.Hour).UnixMilli() writeClaudeCredsWithExpiry(t, path, "sk-ant-oat-old", past) - if ok, reason := claudeTokenUsable(path); ok || reason != "invalid or expired" { - t.Fatalf("expired file: expected (false,\"invalid or expired\"), got (%v,%q)", ok, reason) + if ok, reason := claudeTokenUsable(path); ok || reason != "login expired (no usable refresh grant)" { + t.Fatalf("expired file: expected (false,\"login expired (no usable refresh grant)\"), got (%v,%q)", ok, reason) } // Present and valid (expiresAt in the future). @@ -120,6 +143,15 @@ func TestClaudeTokenUsable(t *testing.T) { if ok, _ := claudeTokenUsable(path); !ok { t.Fatal("valid file: expected usable") } + + // The regression this probe carried until 2026-09-01: an access token that + // merely aged out is NOT an unusable credential. The refresh grant beside + // it mints a new one on the next CLI start, so alerting here prescribed an + // interactive login for a fleet that only needed a restart. + writeClaudeCredsRefreshable(t, path) + if ok, reason := claudeTokenUsable(path); !ok { + t.Fatalf("expired-but-refreshable file: expected usable, got (%v,%q)", ok, reason) + } } // TestEvalCredentialWatch_NotInUseIsSkipped proves a backend with no agent is diff --git a/src/pkg/agent/fleet_breaker_test.go b/src/pkg/agent/fleet_breaker_test.go index cb76488de..6a204d98c 100644 --- a/src/pkg/agent/fleet_breaker_test.go +++ b/src/pkg/agent/fleet_breaker_test.go @@ -1,6 +1,7 @@ package agent import ( + "context" "log/slog" "testing" @@ -131,7 +132,7 @@ func TestReleaseBreaker_ResumesOnlyBreakerSet(t *testing.T) { t.Fatalf("re-pause reviewer: %v", err) } - m.ReleaseBreaker(nil) + m.ReleaseBreaker(context.TODO()) // scanner: breaker paused it, operator never touched it → resumed. if p, _, _ := m.breakerAgentPaused("scanner"); p { @@ -186,14 +187,14 @@ func TestReleaseBreaker_Idempotent(t *testing.T) { }) // Release with nothing engaged: no-op, nil result. - if resumed := m.ReleaseBreaker(nil); resumed != nil { + if resumed := m.ReleaseBreaker(context.TODO()); resumed != nil { t.Errorf("release with no breaker engaged should be nil, got %v", resumed) } m.EngageBreaker() - m.ReleaseBreaker(nil) + m.ReleaseBreaker(context.TODO()) // Second release: breaker already disengaged → no-op. - if resumed := m.ReleaseBreaker(nil); resumed != nil { + if resumed := m.ReleaseBreaker(context.TODO()); resumed != nil { t.Errorf("double-release should be nil, got %v", resumed) } if engaged, _ := m.BreakerState(); engaged { @@ -236,7 +237,7 @@ func TestRestoreBreaker_SurvivesReload(t *testing.T) { } // A later release resumes only the restored breaker set; prepaused stays. - m.ReleaseBreaker(nil) + m.ReleaseBreaker(context.TODO()) if p, _, _ := m.breakerAgentPaused("scanner"); p { t.Error("scanner should resume on post-restore release") } diff --git a/src/pkg/agent/helpers_coverage_test.go b/src/pkg/agent/helpers_coverage_test.go index 786af677d..613354b52 100644 --- a/src/pkg/agent/helpers_coverage_test.go +++ b/src/pkg/agent/helpers_coverage_test.go @@ -274,22 +274,27 @@ func TestTrajectoryAgents(t *testing.T) { // --------------------------------------------------------------------------- func TestConfigHasTokens_NoFiles(t *testing.T) { - // Neither /data/home/.claude nor /data/home/.copilot exists here. + // Redirect both shared credential paths to absent temp files so the + // negative assertion holds even on live hosts where /data/home/.claude + // and /data/home/.copilot exist. + emptySharedPaths(t) if configHasTokens() { - t.Skip("shared token files present on this host; skipping negative assertion") + t.Error("configHasTokens should return false when neither shared file exists") } } func TestCopilotConfigHasTokens_NoFile(t *testing.T) { + emptySharedPaths(t) if copilotConfigHasTokens() { - t.Skip("shared copilot config present; skipping negative assertion") + t.Error("copilotConfigHasTokens should return false when the shared config is absent") } } func TestClearExpiredTokens_NoFile(t *testing.T) { // Missing config.json -> ReadFile error is returned. + emptySharedPaths(t) if err := clearExpiredTokens(); err == nil { - t.Skip("shared copilot config present; clearExpiredTokens succeeded") + t.Error("clearExpiredTokens should return the ReadFile error when config.json is absent") } } diff --git a/src/pkg/agent/kick_async.go b/src/pkg/agent/kick_async.go new file mode 100644 index 000000000..2cad8b290 --- /dev/null +++ b/src/pkg/agent/kick_async.go @@ -0,0 +1,269 @@ +package agent + +// Asynchronous kick dispatch (#5325). +// +// SendKick is synchronous and its slow leg — waitForInputPromptForAgent — is +// bounded by inputPromptTimeout (120s). A dashboard handler that calls it +// inline therefore outlives any normal ingress/proxy idle timeout (commonly +// 60s), so the proxy answers 504 while the wait is still running. The wait then +// finishes server-side, the prompt IS typed, and the agent runs the session — +// but the operator was told the kick failed. The natural response to a false +// failure is to click Kick again, which delivers the prompt TWICE; on a +// hold-gated lane that means duplicate advisory comments and beads. +// +// The fix is to take the prompt wait off the request path. SendKickAsync keeps +// every FAST, deterministic precondition on the caller's goroutine — agent +// exists, sandbox routing, state is running, tmux session exists — so a +// genuinely un-kickable agent still fails synchronously and is still reported +// as a failure. Only the slow legs (crash-restart recovery, the input-prompt +// wait, and the typing itself) move to a background goroutine. +// +// Exactly-once delivery is enforced by an in-flight guard keyed on agent name: +// a second SendKickAsync for an agent whose dispatch is still running does NOT +// start a second delivery. This is the property that makes the async contract +// safe for a UI that used to see false failures — even a retry that predates +// this fix's UI changes cannot double-type. +// +// Outcome is published two ways, both off the request path: +// - KickDispatchState(name) — a polled snapshot for the dashboard. +// - the existing kick observer — "kick-delivered" still fires from +// deliverKickLocked exactly as before. +// +// Locking: SendKickAsync must NOT be called with m.mu held. It takes m.mu for +// the precondition check, releases it, and the background goroutine then calls +// the same lock-taking helpers SendKick uses. Nothing here re-enters m.mu on a +// goroutine that already holds it — the repo has had startup deadlocks from +// exactly that mistake (see the isGatewayBackend comment in manager.go). + +import ( + "context" + "fmt" + "sync" + "time" +) + +// Kick dispatch phases. A dispatch is INDETERMINATE while it is pending: the +// prompt may still be delivered. Only KickPhaseFailed is a definitive failure. +const ( + // KickPhasePending means the kick passed its preconditions and a delivery + // goroutine is waiting for the CLI's input prompt. + KickPhasePending = "pending" + // KickPhaseDelivered means the message was typed into the agent's pane. + KickPhaseDelivered = "delivered" + // KickPhaseFailed means delivery will not happen: the CLI never reached + // its input prompt within inputPromptTimeout, a restart failed, or the + // agent disappeared mid-flight. + KickPhaseFailed = "failed" +) + +// KickDispatch is the observable outcome of one asynchronous kick. +type KickDispatch struct { + // Agent is the resolved agent name. + Agent string `json:"agent"` + // Phase is one of KickPhasePending / KickPhaseDelivered / KickPhaseFailed. + Phase string `json:"phase"` + // Error carries the failure reason when Phase is KickPhaseFailed. It is + // empty in every other phase. + Error string `json:"error,omitempty"` + // QueuedAt is when the dispatch passed its preconditions. + QueuedAt time.Time `json:"queuedAt"` + // SettledAt is when the dispatch reached a terminal phase. Zero while + // pending. + SettledAt time.Time `json:"settledAt,omitempty"` +} + +// Pending reports whether this dispatch is still in flight — the state in +// which the outcome is INDETERMINATE and must never be rendered as a failure. +func (d KickDispatch) Pending() bool { return d.Phase == KickPhasePending } + +// kickDispatchRegistry holds the latest dispatch per agent plus the in-flight +// guard. It is deliberately independent of m.mu: the background delivery +// goroutine settles a dispatch while holding no manager lock at all, and a +// concurrent poll of KickDispatchState must never contend with the launch +// path. +type kickDispatchRegistry struct { + mu sync.Mutex + byAgent map[string]*KickDispatch +} + +func (r *kickDispatchRegistry) begin(name string) (*KickDispatch, bool) { + r.mu.Lock() + defer r.mu.Unlock() + if r.byAgent == nil { + r.byAgent = make(map[string]*KickDispatch) + } + if cur, ok := r.byAgent[name]; ok && cur.Pending() { + // Already in flight. Return the existing dispatch and refuse to start + // a second delivery — this is the exactly-once guarantee. + return cur, false + } + d := &KickDispatch{Agent: name, Phase: KickPhasePending, QueuedAt: time.Now()} + r.byAgent[name] = d + return d, true +} + +func (r *kickDispatchRegistry) settle(name, phase, errMsg string) { + r.mu.Lock() + defer r.mu.Unlock() + d, ok := r.byAgent[name] + if !ok || !d.Pending() { + return + } + d.Phase = phase + d.Error = errMsg + d.SettledAt = time.Now() +} + +func (r *kickDispatchRegistry) get(name string) (KickDispatch, bool) { + r.mu.Lock() + defer r.mu.Unlock() + d, ok := r.byAgent[name] + if !ok { + return KickDispatch{}, false + } + return *d, true +} + +// KickDispatchState returns the most recent asynchronous kick dispatch for an +// agent and whether one exists. The dashboard polls this to report the true +// outcome after answering the POST with 202. +func (m *Manager) KickDispatchState(name string) (KickDispatch, bool) { + return m.kickDispatches.get(name) +} + +// SendKickAsync validates a kick's preconditions synchronously and then +// performs the slow delivery in the background, returning as soon as the kick +// is queued. +// +// The returned bool reports whether THIS call started a new delivery. False +// with a nil error means a delivery for the same agent was already in flight +// and this call was deduplicated — the operator's second click is a no-op, not +// a second prompt. +// +// A non-nil error is a genuine, definitive failure (agent unknown, paused, +// stopped, no tmux session, sandbox kick rejected) and callers should report it +// as such. Sandbox-backed agents keep the synchronous path entirely: their +// kick has no pane wait, so there is no slow leg to move off the request. +// +// MUST NOT be called with m.mu held. +func (m *Manager) SendKickAsync(name string, message string) (started bool, err error) { + m.mu.Lock() + + agent, ok := m.agents[name] + if !ok { + m.mu.Unlock() + return false, fmt.Errorf("agent %s not found", name) + } + + // Sandbox kicks start a container, not a pane wait. They are already fast + // and fully synchronous, so run them inline and report the real result. + if m.agentSandboxEnabledLocked(agent) { + sErr := m.startSandboxKickLocked(agent, message) + m.mu.Unlock() + if sErr != nil { + return false, sErr + } + // Record it as already delivered so a client polling the dispatch + // state settles immediately instead of waiting out its poll budget on + // a kick that never had an asynchronous leg. + if _, fresh := m.kickDispatches.begin(name); fresh { + m.kickDispatches.settle(name, KickPhaseDelivered, "") + } + return true, nil + } + + if agent.State != StateRunning { + m.mu.Unlock() + return false, fmt.Errorf("agent %s cannot be kicked: %s", name, notRunningReason(agent)) + } + + if !m.tmuxSessionExistsForAgent(agent) { + session := agent.tmuxSession + m.mu.Unlock() + return false, fmt.Errorf("tmux session %s not found", session) + } + + m.mu.Unlock() + + // Claim the in-flight slot BEFORE spawning, so two concurrent callers can + // never both spawn. The loser returns started=false with no error. + if _, fresh := m.kickDispatches.begin(name); !fresh { + m.logger.Info("kick already in flight, not delivering again", "name", name) + return false, nil + } + + go func() { + if dErr := m.deliverKickAsync(name, message); dErr != nil { + m.kickDispatches.settle(name, KickPhaseFailed, dErr.Error()) + m.logger.Warn("async kick delivery failed", "name", name, "error", dErr) + return + } + m.kickDispatches.settle(name, KickPhaseDelivered, "") + }() + + return true, nil +} + +// deliverKickAsync is the slow half of SendKickAsync, run on its own +// goroutine. It re-checks liveness under the lock (the agent can be paused or +// restarted between queueing and delivery), recovers a crashed or +// consent-wedged CLI, waits for the input prompt, and types the message. +// +// It holds NO lock on entry and holds none on return; every m.mu acquisition +// below is balanced within this function, mirroring SendKick's unlock/relock +// dance around the two slow waits. +func (m *Manager) deliverKickAsync(name, message string) error { + m.mu.Lock() + agent, ok := m.agents[name] + if !ok { + m.mu.Unlock() + return fmt.Errorf("agent %s not found", name) + } + if agent.State != StateRunning { + reason := notRunningReason(agent) + m.mu.Unlock() + return fmt.Errorf("agent %s cannot be kicked: %s", name, reason) + } + + // Detect a crashed CLI (bare shell) or a CLI stuck on a consent screen and + // restart before sending — identical to SendKick's recovery. A consent pane + // contains "❯" so it passes the marker check, but a kick typed into it is + // consumed by the menu, or by bash once "No, exit" exits the CLI. + pane := m.captureVisiblePaneForAgent(agent) + if !paneHasCLIMarker(pane) || paneShowsConsentScreen(pane) { + m.logger.Warn("agent CLI crashed or stuck on consent screen, restarting before kick", + "name", name, "consent_screen", paneShowsConsentScreen(pane)) + m.mu.Unlock() + if err := m.Restart(context.Background(), name); err != nil { + return fmt.Errorf("failed to restart crashed agent %s: %w", name, err) + } + if !m.waitForCLIReadyForAgent(agent) { + return fmt.Errorf("agent %s CLI did not become ready after restart", name) + } + m.mu.Lock() + agent, ok = m.agents[name] + if !ok { + m.mu.Unlock() + return fmt.Errorf("agent %s disappeared after restart", name) + } + } + + // Wait for the input prompt (❯) before sending — the CLI may be showing a + // trust prompt or still initializing even though the pane matched a broad + // marker like "Copilot". This is the leg that can take up to + // inputPromptTimeout and is exactly why this function is not on the request + // path. Exhausting it is a genuine failure and is reported as one. + m.mu.Unlock() + if !m.waitForInputPromptForAgent(agent) { + return fmt.Errorf("agent %s CLI did not reach input prompt", name) + } + + m.mu.Lock() + defer m.mu.Unlock() + agent, ok = m.agents[name] + if !ok { + return fmt.Errorf("agent %s disappeared while waiting for input prompt", name) + } + m.deliverKickLocked(agent, message, "send-kick") + return nil +} diff --git a/src/pkg/agent/kick_async_test.go b/src/pkg/agent/kick_async_test.go new file mode 100644 index 000000000..f807f895c --- /dev/null +++ b/src/pkg/agent/kick_async_test.go @@ -0,0 +1,205 @@ +package agent + +import ( + "testing" + "time" + + "github.com/kubestellar/hive/pkg/config" +) + +// Tests for the asynchronous kick path (#5325). +// +// The property that matters most is exactly-once delivery. The whole reason +// this path exists is that operators, shown a false 504 failure, clicked Kick +// again — so a fix that returned fast but allowed two deliveries would have +// made the real damage (duplicate advisory comments and beads on a hold-gated +// lane) easier to cause, not harder. + +// TestSendKickAsync_RejectsUnknownAgentSynchronously asserts a definitively +// impossible kick still fails on the caller's goroutine. Preconditions that are +// instant and deterministic must not be deferred behind a 202. +func TestSendKickAsync_RejectsUnknownAgentSynchronously(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{}, discardLogger(), ProjectContext{}) + + started, err := m.SendKickAsync("ghost", "hello") + if err == nil { + t.Fatal("SendKickAsync for an unknown agent returned no error") + } + if started { + t.Error("SendKickAsync reported a started delivery for an unknown agent") + } + if _, ok := m.KickDispatchState("ghost"); ok { + t.Error("a rejected kick left a dispatch record; nothing was ever queued") + } +} + +// TestSendKickAsync_RejectsNotRunningAgentSynchronously covers the state the +// dashboard sees most often for a genuinely failed kick: the agent exists but +// is not running. That is still a 400-worthy synchronous error, not a queue. +func TestSendKickAsync_RejectsNotRunningAgentSynchronously(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, discardLogger(), ProjectContext{}) + + started, err := m.SendKickAsync("scanner", "hello") + if err == nil { + t.Fatal("SendKickAsync for a non-running agent returned no error") + } + if started { + t.Error("SendKickAsync reported a started delivery for a non-running agent") + } +} + +// TestKickDispatchRegistry_DedupesInFlightDelivery is the exactly-once +// guarantee, tested at the guard itself so it does not need a live tmux pane. +// The second begin() for an agent whose dispatch is still pending must return +// fresh=false, which is what stops handleKick from spawning a second delivery. +func TestKickDispatchRegistry_DedupesInFlightDelivery(t *testing.T) { + var r kickDispatchRegistry + + first, fresh := r.begin("scanner") + if !fresh { + t.Fatal("first begin was not fresh") + } + if !first.Pending() { + t.Fatalf("first dispatch phase = %q, want pending", first.Phase) + } + + second, fresh := r.begin("scanner") + if fresh { + t.Error("a second begin while pending was treated as fresh — the prompt would be delivered twice") + } + if second != first { + t.Error("the deduplicated call did not return the in-flight dispatch") + } + + // A different agent is independent: dedup is per agent, not global. + if _, fresh := r.begin("quality"); !fresh { + t.Error("a kick for a different agent was wrongly deduplicated") + } +} + +// TestKickDispatchRegistry_SettlesAndAllowsNextKick asserts a settled dispatch +// stops being pending, records its outcome, and releases the in-flight slot so +// a legitimate LATER kick is not blocked forever by the previous one. +func TestKickDispatchRegistry_SettlesAndAllowsNextKick(t *testing.T) { + var r kickDispatchRegistry + + r.begin("scanner") + r.settle("scanner", KickPhaseDelivered, "") + + got, ok := r.get("scanner") + if !ok { + t.Fatal("no dispatch recorded") + } + if got.Phase != KickPhaseDelivered { + t.Errorf("phase = %q, want %q", got.Phase, KickPhaseDelivered) + } + if got.Pending() { + t.Error("a delivered dispatch still reports pending") + } + if got.SettledAt.IsZero() { + t.Error("a settled dispatch has no SettledAt") + } + if got.Error != "" { + t.Errorf("a delivered dispatch carries an error: %q", got.Error) + } + + if _, fresh := r.begin("scanner"); !fresh { + t.Error("a kick after the previous one settled was wrongly deduplicated") + } +} + +// TestKickDispatchRegistry_SettleIsIdempotent guards the delivery goroutine's +// only write. A late second settle (say, from a retry path added later) must +// not overwrite a recorded success with a failure — that would resurrect the +// exact symptom in the issue, a succeeded kick reported as failed. +func TestKickDispatchRegistry_SettleIsIdempotent(t *testing.T) { + var r kickDispatchRegistry + + r.begin("scanner") + r.settle("scanner", KickPhaseDelivered, "") + r.settle("scanner", KickPhaseFailed, "late bogus failure") + + got, _ := r.get("scanner") + if got.Phase != KickPhaseDelivered { + t.Errorf("phase = %q after a late settle, want %q to survive", got.Phase, KickPhaseDelivered) + } + if got.Error != "" { + t.Errorf("a late settle injected an error onto a delivered kick: %q", got.Error) + } +} + +// TestKickDispatchRegistry_RecordsFailureReason asserts a genuine delivery +// failure — the CLI never reaching its input prompt within inputPromptTimeout — +// is still reported as a failure with its reason intact. Moving the wait off +// the request path must not lose real failures. +func TestKickDispatchRegistry_RecordsFailureReason(t *testing.T) { + var r kickDispatchRegistry + + r.begin("scanner") + r.settle("scanner", KickPhaseFailed, "agent scanner CLI did not reach input prompt") + + got, _ := r.get("scanner") + if got.Phase != KickPhaseFailed { + t.Fatalf("phase = %q, want %q", got.Phase, KickPhaseFailed) + } + if got.Pending() { + t.Error("a failed dispatch still reports pending") + } + if got.Error == "" { + t.Error("a failed dispatch lost its reason") + } +} + +// TestSendKickAsync_DeliversAndSettlesDelivered is the end-to-end success path +// against a real, ready tmux pane: the call returns promptly, and the delivery +// settles as delivered out of band. +func TestSendKickAsync_DeliversAndSettlesDelivered(t *testing.T) { + if !tmuxAvailable() { + t.Skip("tmux not available") + } + m := NewManager(map[string]config.AgentConfig{ + "cxa": {Backend: "claude"}, + }, discardLogger(), ProjectContext{}) + + m.mu.RLock() + agent := m.agents["cxa"] + m.mu.RUnlock() + + session := "hive-asynckick-ready" + agent.tmuxSession = session + newRawTmuxSession(t, session) + paneInject(t, session, "goose is ready") + + m.mu.Lock() + agent.State = StateRunning + m.mu.Unlock() + + started, err := m.SendKickAsync("cxa", "do the work") + if err != nil { + t.Fatalf("SendKickAsync: %v", err) + } + if !started { + t.Fatal("SendKickAsync did not start a delivery") + } + + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + d, ok := m.KickDispatchState("cxa") + if ok && !d.Pending() { + if d.Phase != KickPhaseDelivered { + t.Fatalf("dispatch phase = %q (%s), want %q", d.Phase, d.Error, KickPhaseDelivered) + } + m.mu.RLock() + lastMsg := agent.LastKickMessage + m.mu.RUnlock() + if lastMsg != "do the work" { + t.Errorf("LastKickMessage = %q", lastMsg) + } + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatal("kick dispatch never settled") +} diff --git a/src/pkg/agent/kick_logs_dir_test.go b/src/pkg/agent/kick_logs_dir_test.go new file mode 100644 index 000000000..7236ca5da --- /dev/null +++ b/src/pkg/agent/kick_logs_dir_test.go @@ -0,0 +1,41 @@ +package agent + +// Tests for Manager.SetKickLogDir (kick_logs.go): explicit override, and the +// documented contract that an empty dir restores the env/default resolution +// NewManager performs via kickLogSettingsFromEnv. + +import "testing" + +func TestSetKickLogDir_ExplicitOverride(t *testing.T) { + m, _, orig := kickLogTestManager(t, "output") + if m.kickLogDir != orig { + t.Fatalf("precondition: kickLogDir = %q, want %q", m.kickLogDir, orig) + } + + next := t.TempDir() + m.SetKickLogDir(next) + if m.kickLogDir != next { + t.Fatalf("kickLogDir = %q, want %q", m.kickLogDir, next) + } +} + +func TestSetKickLogDir_EmptyRestoresEnvResolution(t *testing.T) { + envDir := t.TempDir() + t.Setenv(kickLogDirEnv, envDir) + + m, _, _ := kickLogTestManager(t, "output") + m.SetKickLogDir("") + if m.kickLogDir != envDir { + t.Fatalf("kickLogDir after empty reset = %q, want env dir %q", m.kickLogDir, envDir) + } +} + +func TestSetKickLogDir_EmptyFallsBackToDefaultWithoutEnv(t *testing.T) { + t.Setenv(kickLogDirEnv, "") + + m, _, _ := kickLogTestManager(t, "output") + m.SetKickLogDir("") + if m.kickLogDir != defaultKickLogDir { + t.Fatalf("kickLogDir after empty reset = %q, want default %q", m.kickLogDir, defaultKickLogDir) + } +} diff --git a/src/pkg/agent/manager.go b/src/pkg/agent/manager.go index 92bb8dab6..a7a0f7afc 100644 --- a/src/pkg/agent/manager.go +++ b/src/pkg/agent/manager.go @@ -493,6 +493,14 @@ type Manager struct { // observer is always invoked on its own goroutine. See kick_observer.go. kickObserver atomic.Pointer[func(agentName, event, detail string)] + // kickDispatches tracks asynchronous kick dispatches (#5325): the in-flight + // guard that makes delivery exactly-once, and the latest outcome per agent + // so the dashboard can report the true result after answering the POST with + // 202. It carries its OWN mutex rather than living under m.mu, because the + // delivery goroutine settles a dispatch from a context that holds no + // manager lock and must not contend with the launch path. See kick_async.go. + kickDispatches kickDispatchRegistry + inferenceRouteCallback func(agentName, backend, model string) clearInferenceRouteCallback func(agentName string) @@ -717,12 +725,13 @@ func (m *Manager) CopilotToken() string { // BackendAuthAvailable reports whether shared credentials exist for a CLI // backend, so the dashboard can show honest auth state even for agents with // no running pane (e.g. on-demand agents that never launched). Claude checks -// the credentials file (with expiry); Copilot checks the cached token. For -// backends we cannot introspect it returns (false, false) = unknown. +// the credentials file (a live access token, or an expired one whose refresh +// grant is still good — see claude.HasUsableToken); Copilot checks the cached +// token. For backends we cannot introspect it returns (false, false) = unknown. func (m *Manager) BackendAuthAvailable(backend string) (available, known bool) { switch backend { case "claude": - return claude.HasValidToken(claude.CredentialsPath), true + return claude.HasUsableToken(claude.CredentialsPath), true case "copilot": m.mu.RLock() tok := m.copilotAuthToken @@ -1068,12 +1077,12 @@ func writeAgentCredFile(path, token string, agentUID int) error { } if agentUID > 0 { if err := os.Chown(tmpPath, agentUID, -1); err != nil { - os.Remove(tmpPath) + _ = os.Remove(tmpPath) // best-effort cleanup; the chown error is what's returned return fmt.Errorf("chown cred cache: %w", err) } } if err := os.Rename(tmpPath, path); err != nil { - os.Remove(tmpPath) + _ = os.Remove(tmpPath) // best-effort cleanup; the rename error is what's returned return fmt.Errorf("rename cred cache: %w", err) } return nil @@ -1182,18 +1191,23 @@ func copilotTokenUsable(path string) (bool, string) { return true, "" } -// claudeTokenUsable reports whether the Claude credentials file holds a valid, -// non-expired token. Unlike copilot's, a Claude credential can be PRESENT but -// unusable (expired), so a bare presence check is insufficient — it delegates -// to claude.HasValidToken, which parses the file and checks expiry. It -// distinguishes an absent file ("missing") from a present-but-stale one -// ("invalid or expired") for a more actionable alert. +// claudeTokenUsable reports whether the Claude credentials file can still put +// agents to work. Unlike copilot's, a Claude credential can be PRESENT but +// unusable, so a bare presence check is insufficient — it delegates to +// claude.HasUsableToken. It distinguishes an absent file ("missing") from one +// that is genuinely spent ("login expired") for a more actionable alert. +// +// An access token that has merely aged out is NOT unusable: the refresh grant +// beside it mints a new one on the next CLI start, with no operator involved. +// Reporting that state as unusable is what made this watchdog prescribe an +// interactive login every time a hive ran longer than a Claude access token +// lives — roughly once a day, for a credential that was fine. func claudeTokenUsable(path string) (bool, string) { if _, err := os.Stat(path); err != nil { return false, "missing" } - if !claude.HasValidToken(path) { - return false, "invalid or expired" + if !claude.HasUsableToken(path) { + return false, "login expired (no usable refresh grant)" } return true, "" } @@ -2560,81 +2574,7 @@ func (m *Manager) launchInTmux(ctx context.Context, agent *AgentProcess) error { m.logger.Warn("agent has both tools and mode set; tools takes precedence", "agent", agent.Name) } } else { - switch backend { - case "claude": - bareFlag := "" - if isInference { - bareFlag = fmt.Sprintf(" --bare --settings %s", claudeInferenceSettingsPath) - } - base := fmt.Sprintf("%s --model %s --dangerously-skip-permissions%s", binary, model, bareFlag) - // Deny ALL GitHub MCP write tools in EVERY mode: agents author via the - // App-gated gh wrapper, never as the user via the MCP. Mode governs the - // gh-wrapper/proxy layer only, not what the MCP may write. - launchCmd = base + claudeGitHubWriteDenyFlags + claudeHostStateDenyFlags() - case "copilot": - // model arrives here already canonicalized by normalizeModelName - // (CanonicalizeCopilotModel: separator drift like claude-fable.5 is - // normalized to the CLI-accepted claude-fable-5, #4262) and is then - // passed as-is to `copilot --model %s`. It may be a - // concrete id OR the auto-selection sentinel "auto" (copilotAutoModel - // in cli_models.go), which lets the Copilot CLI pick/adjust the model - // per task. Nothing here assumes a concrete id, so the sentinel flows - // through unchanged. - // PRIMARY defense against authoring as the login USER via the MCP: - // we do NOT pass --enable-all-github-mcp-tools. Copilot CLI's built-in - // GitHub MCP server is READ-ONLY BY DEFAULT (v0.0.350+), so the write - // tools (create_issue/create_pull_request/…) are never registered. - // READ tools (get_issue/list/search) stay available in that read-only - // default, so nothing here disables useful lookups. All GitHub writes - // must go through the App-gated gh wrapper / hive-open-pr. - // copilotGitHubWriteDenyFlags is applied as belt-and-suspenders (with - // the CORRECT `github-mcp-server(` server name) on top of the read-only - // default. This is identical across ModeIssuesAndPRs / ModeIssuesOnly / - // advisory — the mode never changes what the MCP can write (it never - // legitimately should), it only governs the separate, unchanged - // gh-wrapper/proxy layer that still reads Mode for the App-gated writes. - launchCmd = fmt.Sprintf("%s --model %s --no-auto-update --allow-all%s", - binary, model, copilotGitHubWriteDenyFlags) - case "gemini": - launchCmd = fmt.Sprintf("%s --model %s", binary, model) - case "agy": - // Antigravity CLI (Google's Gemini CLI replacement). Needs - // --dangerously-skip-permissions or it blocks on a per-tool - // approval prompt that no one is attached to answer — the same - // contract as claude's bypass flag, and the value already used for - // agy in config/backends.conf. - // - // An unrecognised --model is NOT fatal here: agy warns - // ("model X is not recognized ... Using \"Gemini 3.6 Flash\" - // instead") and continues on its default, so a stale model carried - // over from another provider degrades to a warning rather than a - // dead agent. - // - // --effort is REQUIRED whenever --model is given. Without it agy - // warns "--model requires --effort (available: low, medium, - // high)" and silently ignores the model, so the configured model - // would never actually take effect. "low" matches the effort agy - // itself falls back to, keeping behaviour unchanged while making - // the model selection real. - launchCmd = fmt.Sprintf("%s --dangerously-skip-permissions", binary) - if model != "" { - launchCmd = fmt.Sprintf("%s --model %s --effort %s", launchCmd, model, agyDefaultEffort) - } - case "pi": - // pi takes the model as a CLI flag, not a subcommand. Without - // this case the launch command never receives the configured - // model (previously it also hit the goose binary via the alias). - launchCmd = fmt.Sprintf("%s --model %s", binary, model) - case "goose": - launchCmd = fmt.Sprintf("%s run -s", binary) - if model != "" { - launchCmd = fmt.Sprintf("%s --model %s", launchCmd, model) - } - case bobBackend: - launchCmd = bobLaunchCmd(binary) - default: - launchCmd = binary - } + launchCmd = backendLaunchCmd(binary, model, backend, isInference) } if mcpFlags := connectionMCPFlags(agent.Config.Connections, backend); mcpFlags != "" { @@ -6101,7 +6041,7 @@ func isVisualNoise(s string) bool { if t == "" { return true } - if strings.Trim(t, "─━─") == "" { + if strings.Trim(t, "─━") == "" { return true } if strings.HasPrefix(t, "/data/agents/") && !strings.Contains(t, " ") { @@ -6531,11 +6471,51 @@ func paneShowsTransientAPIError(lines []string) bool { return false } +// claudeCredentialReachable reports whether a usable Claude credential exists +// at the locations this agent's CLI will look — its per-UID home first, then +// the shared path its ~/.claude symlink resolves to. +// +// It is a REACHABILITY check, not a permission check, and the distinction is +// worth stating: this runs in the hive process, so it proves the file is there +// and parseable, not that the agent's UID can open it. The deployment keeps +// those the same — the entrypoint's inotify guard chowns /data/home/.claude to +// dev:node and holds it group-readable on every write, precisely so every +// agent UID can read it (#4619). If that ever drifts, an agent lands at a login +// prompt with no injected token instead of a working one; that is a loud, +// alerting state, not a silent one, which is the right direction to fail in. +// +// HasUsableToken, not HasValidToken: an access token that has aged out is +// exactly the case the CLI fixes for itself on start, by redeeming the refresh +// grant beside it. Treating that state as "no credential here" would re-inject +// the static override precisely when the CLI was about to recover, which is the +// failure this guard exists to prevent. +func claudeCredentialReachable(agent *AgentProcess, backend string) bool { + if agent == nil { + return false + } + for _, p := range agentClaudeCredentialPaths(agent.Name, agent.UID, backend) { + if claude.HasUsableToken(p) { + return true + } + } + return false +} + // configHasTokens returns true if either the Copilot config or Claude -// credentials file contains a valid token. Used to decide whether an agent -// stuck on a login prompt can be auto-restarted. +// credentials file holds a credential a restart can still use. Used to decide +// whether an agent stuck on a login prompt can be auto-restarted. +// +// claude.HasUsableToken, not HasValidToken: the single most common reason a +// Claude agent sits at "Please run /login" is that its access token aged out +// under a long-lived tmux session. Claude Code pins the token it read at +// startup for the life of the process — it neither re-reads the file nor +// refreshes mid-session — so the pane 401s while the refresh grant on disk is +// still good for weeks. That is EXACTLY the case this heal was built for, and +// gating it on HasValidToken excluded it: the file said "expired", the heal +// stood down, and the operator was paged to redo a login that a restart would +// have made unnecessary. func configHasTokens() bool { - if claude.HasValidToken(sharedClaudeCredentialPath) { + if claude.HasUsableToken(sharedClaudeCredentialPath) { return true } return copilotConfigHasTokens() @@ -6760,7 +6740,7 @@ var githubTokenLogin = func(token string) string { if err != nil { return "" } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "" } @@ -7486,11 +7466,21 @@ func (m *Manager) setupCodexHome(agent *AgentProcess) { // dir was written as dev/root and the agent EACCESes on it at startup — the // same failure class cavemanNpmCachePath removes foreign-owned caches for. // +// The primary repair is an in-place `chown -R` to the agent UID (#5379): an +// agent RENAME is the common trigger — the per-agent CODEX_HOME keeps the +// PREVIOUS agent's owner — and a rename should not discard the lane's codex +// state (cache/, .tmp/, history). Chowning also avoids walking the tree from +// Go entirely, which matters because /data on hosted spokes is an NFSv3 PVC +// where os.RemoveAll's openat-based descent fails with EACCES even as root. +// +// Only when the chown itself fails do we fall back to a rebuild, and that +// rebuild shells out to `rm -rf` (via su-exec, the same idiom the rest of +// setupCodexHome uses) rather than os.RemoveAll, for the same NFSv3 reason. // Hive never writes config.toml, so any content is operator-authored: the -// heal salvages it when the manager can read it and returns the bytes for -// setupCodexHome to write back as the agent after the re-mkdir. A dir that -// cannot be rebuilt (root-owned, no write access) is left alone with an -// Error log naming the owner and the manual fix. +// rebuild path salvages it when the manager can read it and returns the bytes +// for setupCodexHome to write back as the agent after the re-mkdir. A dir that +// can be neither chowned nor removed is left alone with an Error log naming +// the owner and the manual fix. func (m *Manager) healCodexHomeOwnership(agent *AgentProcess, dir, agentUser string) []byte { owner := fileOwnerUID(dir) if owner < 0 { @@ -7500,10 +7490,20 @@ func (m *Manager) healCodexHomeOwnership(agent *AgentProcess, dir, agentUser str return m.healForeignCodexConfig(agent, dir, agentUser) } // Codex's app-server requires the current UID to own CODEX_HOME itself, - // so a foreign-owned dir can only be rebuilt, not patched around. + // so a foreign-owned dir must be re-owned (preferred) or rebuilt. + chownErr := m.chownCodexHomeToAgent(agent, dir) + if chownErr == nil { + m.logger.Warn("re-owned codex home that was owned by the wrong UID (agent rename); codex state preserved", "agent", agent.Name, "dir", dir, "ownerUID", owner, "wantUID", agent.UID) + // healForeignCodexConfig is still the right follow-up: the recursive + // chown fixed every entry it could reach, but a config.toml that is a + // symlink or otherwise skipped stays foreign-owned, and that narrower + // heal removes it (salvaging content) so codex can read its config. + return m.healForeignCodexConfig(agent, dir, agentUser) + } + m.logger.Warn("could not chown codex home to the agent; falling back to rebuild", "agent", agent.Name, "dir", dir, "ownerUID", owner, "wantUID", agent.UID, "error", chownErr) cfgPath := filepath.Join(dir, "config.toml") salvaged, readErr := os.ReadFile(cfgPath) - if err := os.RemoveAll(dir); err != nil { + if err := removeTreeAsRoot(dir); err != nil { m.logger.Error("codex home is owned by the wrong UID and could not be rebuilt; codex will fail until it is chowned or removed manually", "agent", agent.Name, "dir", dir, "ownerUID", owner, "wantUID", agent.UID, "error", err) return nil } @@ -7514,6 +7514,56 @@ func (m *Manager) healCodexHomeOwnership(agent *AgentProcess, dir, agentUser str return salvaged } +// codexHomeChownUserSpec is the identity the recursive chown runs as. Only +// root can give a directory away to another UID, and the manager runs as dev, +// so this goes through the same SUID su-exec helper every other UID switch in +// setupCodexHome uses (see the Dockerfile C6 note: su-exec is 4750 +// root:hive-launch, exec-able by dev but NOT by any agent UID). +const codexHomeChownUserSpec = "root" + +// chownCodexHomeToAgent recursively gives CODEX_HOME to the agent UID. +// +// NFS SAFETY (#5379): this MUST shell out. /data on hosted spokes is NFSv3, +// where Go's own tree walks (os.RemoveAll, filepath.WalkDir + os.Lchown) fail +// mid-descent with "openfdat ...: permission denied" because openat-based +// directory descriptors are not reliably supported there. `chown -R` in +// coreutils does not use that access pattern and is verified working on the +// affected mount. Do not "simplify" this back into a Go walk. +// +// -h chowns symlinks THEMSELVES rather than following them: auth.json is a +// symlink into the SHARED credential file, which must keep its own ownership +// and must never be rewritten through. +func (m *Manager) chownCodexHomeToAgent(agent *AgentProcess, dir string) error { + return chownTreeAsRoot(dir, fmt.Sprintf("%d:%d", agent.UID, os.Getgid())) +} + +// chownTreeAsRoot is a var, not a plain func, ONLY so tests can substitute a +// harness that performs the same re-owning without the SUID helper (which +// exists only inside the image). Production always uses the exec below. +var chownTreeAsRoot = func(dir, spec string) error { + cmd := exec.Command("su-exec", codexHomeChownUserSpec, "chown", "-Rh", spec, dir) + if output, err := cmd.CombinedOutput(); err != nil { + return outputErr(fmt.Sprintf("chown -Rh %s %s", spec, dir), err, output) + } + return nil +} + +// removeTreeAsRoot deletes a tree the manager may not own. +// +// NFS SAFETY (#5379): os.RemoveAll CANNOT be used here. It descends with +// openat-based directory file descriptors, which fail with EACCES on the +// NFSv3-backed /data PVC even for root — the exact wedge that left a renamed +// agent's codex backend dead for days. A shell `rm -rf` on the identical path +// succeeds immediately. Keep this as an exec, not a Go walk. +// It is a var for the same test-substitution reason as chownTreeAsRoot. +var removeTreeAsRoot = func(dir string) error { + cmd := exec.Command("su-exec", codexHomeChownUserSpec, "rm", "-rf", dir) + if output, err := cmd.CombinedOutput(); err != nil { + return outputErr(fmt.Sprintf("rm -rf %s", dir), err, output) + } + return nil +} + // healForeignCodexConfig handles the agent-owned-dir case: a config.toml // owned by another UID (written by a codex run as dev/root with CODEX_HOME // pointed at this agent's dir). The agent owns the dir, so it can unlink the @@ -8138,7 +8188,7 @@ func readInferenceConfigFile(path string) ([]byte, error) { if err != nil { return nil, err } - defer f.Close() + defer func() { _ = f.Close() }() // read-only fd; nothing to lose on close error return io.ReadAll(f) } @@ -8168,7 +8218,7 @@ func writeInferenceConfigFile(path string, data []byte) error { return err } if _, err := f.Write(data); err != nil { - f.Close() + _ = f.Close() // best-effort cleanup; the write error is what's returned return err } return f.Close() @@ -8182,7 +8232,7 @@ func writeAgentStateFile(path string, data []byte) error { return err } if _, err := f.Write(data); err != nil { - f.Close() + _ = f.Close() // best-effort cleanup; the write error is what's returned return err } // O_CREATE honours the mode only when the file did not already exist (and @@ -8196,7 +8246,7 @@ func writeAgentStateFile(path string, data []byte) error { // symlink and the mode change applied to the link target (TOCTOU, #3175). // f.Chmod acts on the inode we opened, closing that window. if err := f.Chmod(agentStateFileMode); err != nil { - f.Close() + _ = f.Close() // best-effort cleanup; the chmod error is what's returned return err } return f.Close() @@ -8561,6 +8611,15 @@ type agentEnvPair struct { Secret bool } +// inferenceQuietCLIEnv is the set of Claude CLI switches exported to +// inference-routed sessions so the CLI stops emitting non-inference traffic +// (telemetry, error reporting, nonessential lookups) to its Anthropic host. +var inferenceQuietCLIEnv = []string{ + "DISABLE_TELEMETRY", + "DISABLE_ERROR_REPORTING", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", +} + func (m *Manager) agentEnvPairs(agent *AgentProcess) []agentEnvPair { model := agent.Config.Model if agent.ModelOverride != "" { @@ -8659,6 +8718,16 @@ func (m *Manager) agentEnvPairs(agent *AgentProcess) []agentEnvPair { // at most N completion tokens"); a future enhancement could parse it // to auto-adjust per-model instead of using a universal floor. vars = append(vars, agentEnvPair{"CLAUDE_CODE_MAX_OUTPUT_TOKENS", strconv.Itoa(inferenceMaxOutputTokensDefault), false}) + // The Claude CLI sends telemetry batches, error reports, and other + // non-inference traffic to its configured Anthropic host. Routed at + // an OpenAI-compatible gateway that traffic has nowhere useful to go + // (the proxy now answers it locally rather than forwarding it — see + // classifyInferencePath), so switch it off at the source. Only for + // inference-routed sessions: subscription/Anthropic-direct sessions + // keep Anthropic's own telemetry. + for _, v := range inferenceQuietCLIEnv { + vars = append(vars, agentEnvPair{v, "1", false}) + } } if m.copilotAuthToken != "" { vars = append(vars, agentEnvPair{"COPILOT_GITHUB_TOKEN", m.copilotAuthToken, true}) @@ -8691,7 +8760,34 @@ func (m *Manager) agentEnvPairs(agent *AgentProcess) []agentEnvPair { // Nil for advisory agents and for hives with no Linear credential, so a // GitHub-only hive sees no change. vars = append(vars, m.linearEnvPairs(agent)...) - if m.claudeAuthToken != "" && backend == "claude" { + // CLAUDE_CODE_OAUTH_TOKEN is a LAST RESORT, not the normal delivery path. + // + // Claude Code treats this variable as a static bearer token: when it is + // set the CLI uses it verbatim, never opens ~/.claude/.credentials.json, + // and therefore never refreshes. Measured in-container (2026-09-01): with + // the variable set to a bad value and a perfectly good credentials file + // beside it, the CLI answered "401 OAuth access token is invalid" — there + // is no fallback to the file. + // + // m.claudeAuthToken is a snapshot of the SHORT-LIVED access token, taken + // once at manager construction and refreshed only by ReloadClaudeToken() + // after a dashboard login. Injecting it therefore pinned every claude + // agent to the remaining life of whatever access token happened to be on + // disk when the container started — Claude access tokens live 8h, so the + // whole fleet 401'd within a day of every restart and the only recovery + // hive offered was an operator re-login, once per agent. That is the daily + // re-authentication treadmill of #5454. + // + // It is also unnecessary since per-agent homes (#4619): every agent's + // ~/.claude is a symlink to the shared /data/home/.claude, so the CLI can + // read the credential itself — and redeem its refresh grant on start, + // which is the one thing the env var makes impossible. + // + // So inject ONLY when the agent has no credential file it can read. That + // keeps the variable doing the job it was added for (#c5648bc9: deliver a + // dashboard-obtained token to an agent that cannot see the file) and stops + // it overriding a credential that can still refresh itself. + if m.claudeAuthToken != "" && backend == "claude" && !claudeCredentialReachable(agent, backend) { vars = append(vars, agentEnvPair{"CLAUDE_CODE_OAUTH_TOKEN", m.claudeAuthToken, true}) } // bob reads its key from BOBSHELL_API_KEY. Secret: true keeps the value off @@ -9438,7 +9534,7 @@ func killAgentProcesses(uid int, logger *slog.Logger) int { break } } - f.Close() + _ = f.Close() // read-only /proc status fd; nothing to lose on close error if ownerUID != uid { continue @@ -9914,6 +10010,91 @@ func toolRulesToLaunchCmd(binary, model, backend string, tools *config.ToolsConf } } +// backendLaunchCmd builds the per-backend CLI command used when an agent has no +// explicit ToolsConfig. It is the default-path counterpart to +// toolRulesToLaunchCmd and is deliberately pure — no Manager, no tmux, no +// process — so the flag contract each backend depends on can be asserted +// directly in tests instead of by polling a live pane for typed output. +func backendLaunchCmd(binary, model, backend string, isInference bool) string { + var launchCmd string + switch backend { + case "claude": + bareFlag := "" + if isInference { + bareFlag = fmt.Sprintf(" --bare --settings %s", claudeInferenceSettingsPath) + } + base := fmt.Sprintf("%s --model %s --dangerously-skip-permissions%s", binary, model, bareFlag) + // Deny ALL GitHub MCP write tools in EVERY mode: agents author via the + // App-gated gh wrapper, never as the user via the MCP. Mode governs the + // gh-wrapper/proxy layer only, not what the MCP may write. + launchCmd = base + claudeGitHubWriteDenyFlags + claudeHostStateDenyFlags() + case "copilot": + // model arrives here already canonicalized by normalizeModelName + // (CanonicalizeCopilotModel: separator drift like claude-fable.5 is + // normalized to the CLI-accepted claude-fable-5, #4262) and is then + // passed as-is to `copilot --model %s`. It may be a + // concrete id OR the auto-selection sentinel "auto" (copilotAutoModel + // in cli_models.go), which lets the Copilot CLI pick/adjust the model + // per task. Nothing here assumes a concrete id, so the sentinel flows + // through unchanged. + // PRIMARY defense against authoring as the login USER via the MCP: + // we do NOT pass --enable-all-github-mcp-tools. Copilot CLI's built-in + // GitHub MCP server is READ-ONLY BY DEFAULT (v0.0.350+), so the write + // tools (create_issue/create_pull_request/…) are never registered. + // READ tools (get_issue/list/search) stay available in that read-only + // default, so nothing here disables useful lookups. All GitHub writes + // must go through the App-gated gh wrapper / hive-open-pr. + // copilotGitHubWriteDenyFlags is applied as belt-and-suspenders (with + // the CORRECT `github-mcp-server(` server name) on top of the read-only + // default. This is identical across ModeIssuesAndPRs / ModeIssuesOnly / + // advisory — the mode never changes what the MCP can write (it never + // legitimately should), it only governs the separate, unchanged + // gh-wrapper/proxy layer that still reads Mode for the App-gated writes. + launchCmd = fmt.Sprintf("%s --model %s --no-auto-update --allow-all%s", + binary, model, copilotGitHubWriteDenyFlags) + case "gemini": + launchCmd = fmt.Sprintf("%s --model %s", binary, model) + case "agy": + // Antigravity CLI (Google's Gemini CLI replacement). Needs + // --dangerously-skip-permissions or it blocks on a per-tool + // approval prompt that no one is attached to answer — the same + // contract as claude's bypass flag, and the value already used for + // agy in config/backends.conf. + // + // An unrecognised --model is NOT fatal here: agy warns + // ("model X is not recognized ... Using \"Gemini 3.6 Flash\" + // instead") and continues on its default, so a stale model carried + // over from another provider degrades to a warning rather than a + // dead agent. + // + // --effort is REQUIRED whenever --model is given. Without it agy + // warns "--model requires --effort (available: low, medium, + // high)" and silently ignores the model, so the configured model + // would never actually take effect. "low" matches the effort agy + // itself falls back to, keeping behaviour unchanged while making + // the model selection real. + launchCmd = fmt.Sprintf("%s --dangerously-skip-permissions", binary) + if model != "" { + launchCmd = fmt.Sprintf("%s --model %s --effort %s", launchCmd, model, agyDefaultEffort) + } + case "pi": + // pi takes the model as a CLI flag, not a subcommand. Without + // this case the launch command never receives the configured + // model (previously it also hit the goose binary via the alias). + launchCmd = fmt.Sprintf("%s --model %s", binary, model) + case "goose": + launchCmd = fmt.Sprintf("%s run -s", binary) + if model != "" { + launchCmd = fmt.Sprintf("%s --model %s", launchCmd, model) + } + case bobBackend: + launchCmd = bobLaunchCmd(binary) + default: + launchCmd = binary + } + return launchCmd +} + // connectionMCPFlags builds MCP-related launch flags from connection configs. func connectionMCPFlags(conns []config.ConnectionConfig, backend string) string { var flags string diff --git a/src/pkg/agent/manager3_coverage_test.go b/src/pkg/agent/manager3_coverage_test.go index 805826092..6fe7d8fce 100644 --- a/src/pkg/agent/manager3_coverage_test.go +++ b/src/pkg/agent/manager3_coverage_test.go @@ -2,6 +2,7 @@ package agent import ( "context" + "fmt" "os" "path/filepath" "strings" @@ -137,13 +138,74 @@ func TestHealCodexHomeOwnership_AbsentDirNoOp(t *testing.T) { } } -// TestHealCodexHomeOwnership_ForeignDirRebuiltWithSalvage pins the wedge -// this heal exists for: a CODEX_HOME owned by another identity survives on -// /data across restarts and codex refuses to start. The heal must remove -// the dir and hand back the operator-authored config.toml for the re-create -// to restore. The test dir is owned by the test UID while the agent wants a -// different UID, which is exactly the foreign-owner shape. -func TestHealCodexHomeOwnership_ForeignDirRebuiltWithSalvage(t *testing.T) { +// stubCodexHealMechanisms replaces the two root-privileged helpers for the +// duration of a test. The real ones go through su-exec, which exists only +// inside the hive image; substituting them lets the tests below exercise the +// heal's DECISION logic (chown first, rebuild only on failure) on any host. +// chownOK/removeOK select whether each mechanism succeeds. The returned +// counters record how many times each was invoked. +func stubCodexHealMechanisms(t *testing.T, chownOK, removeOK bool) (chownCalls, removeCalls *int) { + t.Helper() + origChown, origRemove := chownTreeAsRoot, removeTreeAsRoot + t.Cleanup(func() { chownTreeAsRoot, removeTreeAsRoot = origChown, origRemove }) + chownCalls, removeCalls = new(int), new(int) + chownTreeAsRoot = func(dir, spec string) error { + *chownCalls++ + if !chownOK { + return fmt.Errorf("stub: chown refused") + } + return nil // a real chown would re-own; ownership is not observable here + } + removeTreeAsRoot = func(dir string) error { + *removeCalls++ + if !removeOK { + return fmt.Errorf("stub: rm refused") + } + return os.RemoveAll(dir) // local FS in tests; production shells out + } + return chownCalls, removeCalls +} + +// TestHealCodexHomeOwnership_ForeignDirChownedNotRemoved is the #5379 +// regression test. A CODEX_HOME left owned by the PREVIOUS agent after a +// rename must be re-owned IN PLACE — the lane's codex state (cache/, history) +// must survive, and no removal may be attempted. Before the fix this path +// called os.RemoveAll, which additionally cannot succeed on the NFSv3 /data +// PVC at all. +func TestHealCodexHomeOwnership_ForeignDirChownedNotRemoved(t *testing.T) { + chownCalls, removeCalls := stubCodexHealMechanisms(t, true, true) + m, agent := codexHealTestManager(t, os.Getuid()+12345) + dir := filepath.Join(t.TempDir(), "codex-cxa") + if err := os.MkdirAll(filepath.Join(dir, "cache"), 0o755); err != nil { + t.Fatal(err) + } + const cfg = "model = \"gpt-5.1-codex\"\n" + if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + if got := m.healCodexHomeOwnership(agent, dir, "hive-cxa"); got != nil { + t.Errorf("chown path preserves the dir, so nothing needs salvaging; got %q", got) + } + if *chownCalls != 1 { + t.Errorf("foreign-owned home must be chowned once, got %d calls", *chownCalls) + } + if *removeCalls != 0 { + t.Errorf("a successful chown must not fall back to removal, got %d calls", *removeCalls) + } + if _, err := os.Lstat(filepath.Join(dir, "cache")); err != nil { + t.Errorf("codex state must survive the re-own, stat err=%v", err) + } + content, err := os.ReadFile(filepath.Join(dir, "config.toml")) + if err != nil || string(content) != cfg { + t.Errorf("config.toml must survive the re-own in place, content=%q err=%v", content, err) + } +} + +// TestHealCodexHomeOwnership_ChownFailureFallsBackToRebuild pins that the +// rebuild is still reachable when the chown cannot run, and that it salvages +// the operator-authored config.toml on the way out. +func TestHealCodexHomeOwnership_ChownFailureFallsBackToRebuild(t *testing.T) { + chownCalls, removeCalls := stubCodexHealMechanisms(t, false, true) m, agent := codexHealTestManager(t, os.Getuid()+12345) dir := filepath.Join(t.TempDir(), "codex-cxa") if err := os.MkdirAll(dir, 0o755); err != nil { @@ -157,8 +219,35 @@ func TestHealCodexHomeOwnership_ForeignDirRebuiltWithSalvage(t *testing.T) { if string(got) != cfg { t.Errorf("readable config.toml must be salvaged before the rebuild, got %q", got) } + if *chownCalls != 1 || *removeCalls != 1 { + t.Errorf("expected one chown attempt then one removal, got chown=%d remove=%d", *chownCalls, *removeCalls) + } if _, err := os.Lstat(dir); !os.IsNotExist(err) { - t.Errorf("foreign-owned codex home must be removed, stat err=%v", err) + t.Errorf("fallback rebuild must remove the dir, stat err=%v", err) + } +} + +// TestHealCodexHomeOwnership_BothMechanismsFailStaysLoud pins the constraint +// that an unrepairable home keeps its loud ERROR and salvages nothing — +// silence here would hide a dead agent. +func TestHealCodexHomeOwnership_BothMechanismsFailStaysLoud(t *testing.T) { + chownCalls, removeCalls := stubCodexHealMechanisms(t, false, false) + m, agent := codexHealTestManager(t, os.Getuid()+12345) + dir := filepath.Join(t.TempDir(), "codex-cxa") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte("x = 1\n"), 0o644); err != nil { + t.Fatal(err) + } + if got := m.healCodexHomeOwnership(agent, dir, "hive-cxa"); got != nil { + t.Errorf("an unrepairable home must salvage nothing, got %q", got) + } + if *chownCalls != 1 || *removeCalls != 1 { + t.Errorf("both mechanisms must be attempted, got chown=%d remove=%d", *chownCalls, *removeCalls) + } + if _, err := os.Lstat(dir); err != nil { + t.Errorf("an unrepairable home must be left alone for manual repair, stat err=%v", err) } } diff --git a/src/pkg/agent/manager_coverage2_test.go b/src/pkg/agent/manager_coverage2_test.go index 9c85fdcd7..c49f68a25 100644 --- a/src/pkg/agent/manager_coverage2_test.go +++ b/src/pkg/agent/manager_coverage2_test.go @@ -1,6 +1,7 @@ package agent import ( + "context" "encoding/json" "fmt" "os" @@ -1140,11 +1141,10 @@ func TestSnapshot_CopiesPaneCapture(t *testing.T) { m.mu.Unlock() status, _ := m.GetStatus("scanner") - // PaneLines should return a copy - lines := status.PaneLines(10) - if len(lines) == 0 { - // OK if filtered out — the point is no panic - } + // PaneLines should return a copy. Filtering may legitimately drop lines, + // so the only invariant this smoke test checks is that the call above + // does not panic. + _ = status.PaneLines(10) } // --------------------------------------------------------------------------- @@ -1333,7 +1333,7 @@ func TestDefaultAgentMode_AllLevels(t *testing.T) { } } -// SuffixForLevel, AgentMode booleans, and ParseAgentMode are tested in mode_test.go +// AgentMode booleans and ParseAgentMode are tested in mode_test.go. // --------------------------------------------------------------------------- // ClearAllModeOverrides — verify modes cleared @@ -1790,7 +1790,7 @@ func TestConcurrentPauseResume_NoPanic(t *testing.T) { go func() { for j := 0; j < 50; j++ { m.Pause("a", "test", "testing") - m.Resume(nil, "a", "test", "testing") + m.Resume(context.TODO(), "a", "test", "testing") m.IsPaused("a") m.GetStatus("a") } diff --git a/src/pkg/agent/manager_coverage_test.go b/src/pkg/agent/manager_coverage_test.go index fd85d348f..d261c8212 100644 --- a/src/pkg/agent/manager_coverage_test.go +++ b/src/pkg/agent/manager_coverage_test.go @@ -1,6 +1,7 @@ package agent import ( + "context" "os" "testing" "time" @@ -206,7 +207,7 @@ func TestAgentEnvPairs_WithOverrides(t *testing.T) { func TestRestart_NotFound(t *testing.T) { m := NewManager(map[string]config.AgentConfig{}, discardLogger(), ProjectContext{}) - err := m.Restart(nil, "nonexistent") + err := m.Restart(context.TODO(), "nonexistent") if err == nil { t.Error("expected error for nonexistent agent") } @@ -255,7 +256,7 @@ func TestStart_AlreadyRunning(t *testing.T) { m.agents["scanner"].State = StateRunning m.mu.Unlock() - err := m.Start(nil, "scanner") + err := m.Start(context.TODO(), "scanner") if err == nil { t.Fatal("expected error for already running agent") } @@ -315,7 +316,7 @@ func TestResume_NotPaused(t *testing.T) { }, discardLogger(), ProjectContext{}) // Agent is in Stopped state (not paused), Resume should be no-op - err := m.Resume(nil, "scanner", "test", "test resume") + err := m.Resume(context.TODO(), "scanner", "test", "test resume") if err != nil { t.Fatalf("Resume: %v", err) } @@ -323,7 +324,7 @@ func TestResume_NotPaused(t *testing.T) { func TestResume_NotFound(t *testing.T) { m := NewManager(map[string]config.AgentConfig{}, discardLogger(), ProjectContext{}) - err := m.Resume(nil, "nonexistent", "test", "test resume") + err := m.Resume(context.TODO(), "nonexistent", "test", "test resume") if err == nil { t.Error("expected error") } diff --git a/src/pkg/agent/mode.go b/src/pkg/agent/mode.go index 5cc57eb82..5c639002e 100644 --- a/src/pkg/agent/mode.go +++ b/src/pkg/agent/mode.go @@ -6,7 +6,7 @@ type AgentMode int const ( ModeAdvisory AgentMode = iota // Advisory beads only, governor posts digests ModeIssuesOnly // Open issues, no PRs - ModeIssuesAndPRs // Issues + PRs (hold-labeled at L5) + ModeIssuesAndPRs // Issues + PRs, without merge authority ModeIssuesPRsMerge // Issues + PRs + auto-merge on green CI ) @@ -54,18 +54,6 @@ func (m AgentMode) Suffix() string { return "-advisory" } -// SuffixForLevel returns the policy file suffix adjusted for ACMM level. -// ISSUES_AND_PRS uses "-holdgated" only at L5 (hold-labeled PRs) and "-full" at all other levels. -func (m AgentMode) SuffixForLevel(level int) string { - if m == ModeIssuesAndPRs { - if level == 5 { - return "-holdgated" - } - return "-full" - } - return m.Suffix() -} - func (m AgentMode) CanCreateIssues() bool { return m >= ModeIssuesOnly } func (m AgentMode) CanCreatePRs() bool { return m >= ModeIssuesAndPRs } func (m AgentMode) CanMerge() bool { return m >= ModeIssuesPRsMerge } diff --git a/src/pkg/agent/mode_test.go b/src/pkg/agent/mode_test.go index 253b9c7da..52a538c6a 100644 --- a/src/pkg/agent/mode_test.go +++ b/src/pkg/agent/mode_test.go @@ -55,27 +55,6 @@ func TestAgentModeSuffix(t *testing.T) { } } -func TestSuffixForLevel(t *testing.T) { - tests := []struct { - mode AgentMode - level int - want string - }{ - {ModeIssuesAndPRs, 3, "-full"}, - {ModeIssuesAndPRs, 4, "-full"}, - {ModeIssuesAndPRs, 5, "-holdgated"}, - {ModeIssuesAndPRs, 6, "-full"}, - {ModeAdvisory, 3, "-advisory"}, - {ModeIssuesOnly, 4, "-issues"}, - {ModeIssuesPRsMerge, 6, "-automerge"}, - } - for _, tt := range tests { - if got := tt.mode.SuffixForLevel(tt.level); got != tt.want { - t.Errorf("AgentMode(%d).SuffixForLevel(%d) = %q, want %q", tt.mode, tt.level, got, tt.want) - } - } -} - func TestAgentModeCapabilities(t *testing.T) { tests := []struct { mode AgentMode diff --git a/src/pkg/agent/permissions_watcher.go b/src/pkg/agent/permissions_watcher.go index 607a2f615..6f5edc423 100644 --- a/src/pkg/agent/permissions_watcher.go +++ b/src/pkg/agent/permissions_watcher.go @@ -375,7 +375,7 @@ func fixModeFile(path string, logger *slog.Logger) { // ELOOP for a planted symlink, EACCES for a file we do not own: skip. return } - defer f.Close() + defer func() { _ = f.Close() }() // read-only fd; nothing to lose on close error fi, err := f.Stat() if err != nil || !fi.Mode().IsRegular() { return diff --git a/src/pkg/agent/session_missing_test.go b/src/pkg/agent/session_missing_test.go index 9454b13ac..f2d752498 100644 --- a/src/pkg/agent/session_missing_test.go +++ b/src/pkg/agent/session_missing_test.go @@ -157,7 +157,7 @@ func TestSessionMissingReleasesTheLock(t *testing.T) { done := make(chan struct{}) go func() { m.mu.Lock() - m.mu.Unlock() + m.mu.Unlock() //nolint:staticcheck // SA2001: the empty critical section IS the test — it proves no reader lock leaked (see comment above). close(done) }() <-done diff --git a/src/pkg/agent/stuck_login_diagnosis.go b/src/pkg/agent/stuck_login_diagnosis.go index 686c2db9a..9f5c085f3 100644 --- a/src/pkg/agent/stuck_login_diagnosis.go +++ b/src/pkg/agent/stuck_login_diagnosis.go @@ -37,10 +37,14 @@ func (m *Manager) diagnoseStuckLogin(agent *AgentProcess) string { tokenRestartMaxAttempts, backend, home) } + // HasUsableToken, matching the gate the heal itself used to get here + // (configHasTokens): the diagnosis must describe the credential the + // restarts were attempted against, and a routinely-expired-but-refreshable + // one is a credential those restarts could legitimately have used. credPath := "" credValid := false for _, p := range agentClaudeCredentialPaths(agent.Name, uid, backend) { - if claude.HasValidToken(p) { + if claude.HasUsableToken(p) { credPath, credValid = p, true break } diff --git a/src/pkg/agent/tmux_history_limit_test.go b/src/pkg/agent/tmux_history_limit_test.go index 02ce12761..3846b690b 100644 --- a/src/pkg/agent/tmux_history_limit_test.go +++ b/src/pkg/agent/tmux_history_limit_test.go @@ -27,7 +27,7 @@ func TestNewSessionCommandsRaisesHistoryBeforePaneCreation(t *testing.T) { setIdx := idx("set-option") sepIdx := idx(";") newIdx := idx("new-session") - if !(setIdx < sepIdx && sepIdx < newIdx) { + if setIdx >= sepIdx || sepIdx >= newIdx { t.Fatalf("want set-option before %q before new-session, got %v", ";", cmds) } diff --git a/src/pkg/agent/watchdog_fleet.go b/src/pkg/agent/watchdog_fleet.go index c9ebda36f..9a22098b5 100644 --- a/src/pkg/agent/watchdog_fleet.go +++ b/src/pkg/agent/watchdog_fleet.go @@ -99,6 +99,23 @@ func (f WatchdogFleet) Observe(name string) (watchdog.Observation, error) { backend := effectiveBackend(agent) running := agent.State == StateRunning authAvailable, authKnown := f.M.AgentAuthState(name, agent.UID, backend, running, needsLogin) + // Positive-evidence-only probe (#5291), deliberately separate from + // AgentAuthState above: it answers "is this backend demonstrably able to + // authenticate?" without letting the pane's own login chrome outrank the + // credential. The reconciler needs that unclouded answer to tell a + // credential a restart can fix from one only a human can. + // + // CLAUDE ONLY, and the restriction is the point. credentialFileProves + // verifies an EXPIRY only for claude; it answers copilot and codex by the + // PRESENCE of a token file, and presence is not proof of usability. The + // reconciler uses this to decide whether to page an operator, so a + // stale-but-present copilot token reading as "proven" would silence the + // alert that is the only thing telling a human their fleet is logged out. + // #5291's login detector can live with presence-only evidence because + // suppressing a PAUSE hands the pane to the restart heal; suppressing a + // PAGE hands it to nobody. Every non-claude backend therefore keeps its + // pre-existing behaviour exactly. + credentialProven := backend == "claude" && f.M.AgentHasValidCredential(name) // StartedAt dates the current launch so the reconciler can suppress dead // verdicts during boot. Copied by value: the field is a pointer the @@ -120,6 +137,7 @@ func (f WatchdogFleet) Observe(name string) (watchdog.Observation, error) { StartedAt: startedAt, AuthAvailable: authAvailable, AuthKnown: authKnown, + CredentialProven: credentialProven, }, nil } diff --git a/src/pkg/agent/watchdog_fleet_test.go b/src/pkg/agent/watchdog_fleet_test.go index 8e6137904..41282b03c 100644 --- a/src/pkg/agent/watchdog_fleet_test.go +++ b/src/pkg/agent/watchdog_fleet_test.go @@ -441,3 +441,48 @@ func TestNewestMtimeBounds(t *testing.T) { t.Fatalf("newestMtime = %v ok=%v, want shallow file %v (depth bound must hold)", got, ok, want) } } + +// TestWatchdogObserveCredentialProvenIsClaudeOnly pins the restriction that +// keeps the reconciler's alert suppression honest. +// +// Observation.CredentialProven exists so the watchdog can tell "login prompt +// over a credential a restart can redeem" from "genuinely logged out" and skip +// paging an operator for the former. That is only safe where the evidence is +// proof of USABILITY. credentialFileProves verifies an expiry for claude, but +// answers copilot and codex by the PRESENCE of a token file — and a +// stale-but-present copilot token is precisely the state an operator must be +// told about. Letting presence read as proof would silence the alert that is +// the only signal their fleet is logged out. +func TestWatchdogObserveCredentialProvenIsClaudeOnly(t *testing.T) { + stageSharedClaudeCredential(t, map[string]any{ + "accessToken": "sk-ant-oat-live", + "expiresAt": time.Now().Add(4 * time.Hour).UnixMilli(), + }) + m, panes := newWatchdogTestManager(t, map[string]string{ + "scanner": "claude", + "helper": "copilot", + }) + // Presence-only evidence for copilot: credentialFileProves returns true on + // a held token without ever checking whether it still works. + m.SetCopilotToken("gho_stale_but_present") + (*panes)["scanner"] = "❯ " + (*panes)["helper"] = "❯ " + + fleet := WatchdogFleet{M: m} + + obs, err := fleet.Observe("scanner") + if err != nil { + t.Fatalf("observe claude agent: %v", err) + } + if !obs.CredentialProven { + t.Fatal("claude agent with a live credential must report CredentialProven: its expiry is verifiable") + } + + obs, err = fleet.Observe("helper") + if err != nil { + t.Fatalf("observe copilot agent: %v", err) + } + if obs.CredentialProven { + t.Fatal("copilot evidence is presence-only and must never read as proof — doing so suppresses the operator's re-authentication alert") + } +} diff --git a/src/pkg/agent/write_as_user_test.go b/src/pkg/agent/write_as_user_test.go new file mode 100644 index 000000000..fe48800a7 --- /dev/null +++ b/src/pkg/agent/write_as_user_test.go @@ -0,0 +1,181 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// These tests cover the two su-exec-backed helpers in manager.go that were at +// 0%/33% coverage: writeFileAsUser and (*Manager).healForeignCodexConfig. +// Both shell out to the su-exec binary, so each test installs a stub su-exec +// at the front of PATH — the same seam TestEnsureTmuxSession_IncludesStderr +// already uses — keeping everything hermetic (no real user switching). + +// installSuExecScript writes a custom su-exec stub into the stub bin dir +// already on PATH (see TestMain), mirroring installSuExecStub in +// relaunch_mint_test.go but with a caller-supplied script so failure modes +// can be simulated. The script receives su-exec's argv (userSpec cmd args...) +// verbatim. +func installSuExecScript(t *testing.T, script string) { + t.Helper() + p := filepath.Join(stubBinDir, "su-exec") + if err := os.WriteFile(p, []byte(script), 0o755); err != nil { + t.Fatalf("writing su-exec stub: %v", err) + } + t.Cleanup(func() { _ = os.Remove(p) }) +} + +// passthroughSuExec drops the userSpec argument and executes the wrapped +// command as the current user — the hermetic stand-in for a real su-exec. +const passthroughSuExec = `#!/bin/sh +shift +exec "$@" +` + +func TestWriteFileAsUser_WritesContent(t *testing.T) { + installSuExecScript(t, passthroughSuExec) + + // A path with spaces and a single quote proves the `sh -c 'cat > "$1"'` + // form keeps the path out of shell parsing entirely, as documented. + dir := t.TempDir() + path := filepath.Join(dir, `it's a spaced file.json`) + content := []byte("line one\nline two\n") + + if err := writeFileAsUser("1234:1000", path, content); err != nil { + t.Fatalf("writeFileAsUser: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading back %s: %v", path, err) + } + if string(got) != string(content) { + t.Errorf("content mismatch: got %q want %q", got, content) + } +} + +func TestWriteFileAsUser_ErrorIncludesContext(t *testing.T) { + // A failing su-exec must surface prefix, error, and captured output via + // outputErr so the operator log names the path, the user, and the cause. + installSuExecScript(t, `#!/bin/sh +echo "su-exec: getpwnam(hive-ghost): Success" >&2 +exit 1 +`) + + err := writeFileAsUser("hive-ghost", "/nonexistent/target", []byte("x")) + if err == nil { + t.Fatal("expected error from failing su-exec, got nil") + } + msg := err.Error() + for _, want := range []string{"writing /nonexistent/target as hive-ghost", "getpwnam(hive-ghost)"} { + if !strings.Contains(msg, want) { + t.Errorf("error %q missing %q", msg, want) + } + } +} + +// healManager builds the minimal Manager the heal path needs (just a logger). +func healManager() *Manager { + return &Manager{logger: discardLogger()} +} + +func TestHealForeignCodexConfig_AbsentConfigIsNoop(t *testing.T) { + m := healManager() + dir := t.TempDir() // no config.toml inside + agent := &AgentProcess{Name: "scout", UID: os.Getuid()} + + if got := m.healForeignCodexConfig(agent, dir, "hive-scout"); got != nil { + t.Errorf("absent config.toml must heal to nil, got %q", got) + } +} + +func TestHealForeignCodexConfig_OwnConfigLeftAlone(t *testing.T) { + m := healManager() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + if err := os.WriteFile(cfgPath, []byte("model = \"o4\"\n"), 0o644); err != nil { + t.Fatal(err) + } + // The agent "owns" the file (its UID is ours), so nothing is foreign. + agent := &AgentProcess{Name: "scout", UID: os.Getuid()} + + if got := m.healForeignCodexConfig(agent, dir, "hive-scout"); got != nil { + t.Errorf("agent-owned config.toml must heal to nil, got %q", got) + } + if _, err := os.Stat(cfgPath); err != nil { + t.Errorf("agent-owned config.toml must not be removed: %v", err) + } +} + +func TestHealForeignCodexConfig_SalvagesAndRemovesForeignConfig(t *testing.T) { + installSuExecScript(t, passthroughSuExec) + + m := healManager() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + content := "model = \"operator-authored\"\n" + if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + // The file is owned by the test uid; give the agent a DIFFERENT uid so + // the config counts as foreign without needing a privileged chown. + agent := &AgentProcess{Name: "scout", UID: os.Getuid() + 1} + + got := m.healForeignCodexConfig(agent, dir, "hive-scout") + if string(got) != content { + t.Errorf("salvaged content: got %q want %q", got, content) + } + if _, err := os.Stat(cfgPath); !os.IsNotExist(err) { + t.Errorf("foreign config.toml must be removed, stat err = %v", err) + } +} + +func TestHealForeignCodexConfig_RemoveFailureReturnsNil(t *testing.T) { + // su-exec refuses: the heal must give up (nil) and leave the file for the + // documented manual fix rather than pretending it salvaged anything. + installSuExecScript(t, `#!/bin/sh +exit 1 +`) + + m := healManager() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + if err := os.WriteFile(cfgPath, []byte("keep me"), 0o644); err != nil { + t.Fatal(err) + } + agent := &AgentProcess{Name: "scout", UID: os.Getuid() + 1} + + if got := m.healForeignCodexConfig(agent, dir, "hive-scout"); got != nil { + t.Errorf("failed removal must return nil, got %q", got) + } + if _, err := os.Stat(cfgPath); err != nil { + t.Errorf("file must survive a failed removal: %v", err) + } +} + +func TestHealForeignCodexConfig_UnreadableForeignConfigRemovedWithoutSalvage(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("root can read 0o000 files; the unreadable branch needs an unprivileged uid") + } + installSuExecScript(t, passthroughSuExec) + + m := healManager() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + if err := os.WriteFile(cfgPath, []byte("secret"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(cfgPath, 0o000); err != nil { + t.Fatal(err) + } + agent := &AgentProcess{Name: "scout", UID: os.Getuid() + 1} + + got := m.healForeignCodexConfig(agent, dir, "hive-scout") + if got != nil { + t.Errorf("unreadable config must salvage nothing, got %q", got) + } + if _, err := os.Stat(cfgPath); !os.IsNotExist(err) { + t.Errorf("unreadable foreign config must still be removed, stat err = %v", err) + } +} diff --git a/src/pkg/agentparse/agentparse.go b/src/pkg/agentparse/agentparse.go index 4174a5267..d81011703 100644 --- a/src/pkg/agentparse/agentparse.go +++ b/src/pkg/agentparse/agentparse.go @@ -97,9 +97,10 @@ func ParseTable(lines []string, categories map[string]bool) []Item { for _, c := range cleaned { if c != "" { nonEmpty++ - if nonEmpty == 1 { + switch nonEmpty { + case 1: currentTitle += " " + c - } else if nonEmpty == 2 { + case 2: currentDefault += " " + c } } @@ -207,7 +208,7 @@ func TitleFromBdCreate(line string) (string, bool) { m := bdCreateTitleRe.FindStringSubmatch(line) // len(m) < 2 is defensive: the regex has one capture group, so a non-nil // match always has len 2. It cannot be exercised in a test. - if m == nil || len(m) < 2 { + if len(m) < 2 { return "", false } title := strings.TrimSpace(m[1]) diff --git a/src/pkg/apiproxy/proxy.go b/src/pkg/apiproxy/proxy.go index 56be059f3..00c0b63f4 100644 --- a/src/pkg/apiproxy/proxy.go +++ b/src/pkg/apiproxy/proxy.go @@ -187,8 +187,8 @@ func (p *Proxy) wrapSSEBody(orig io.ReadCloser, path string, status int) io.Read pr, pw := io.Pipe() go func() { - defer orig.Close() - defer pw.Close() + defer func() { _ = orig.Close() }() // upstream response body, already fully read here + defer func() { _ = pw.Close() }() // io.PipeWriter.Close never returns a non-nil error lineCount := 0 scanner := bufio.NewScanner(orig) diff --git a/src/pkg/auth/oidc_provider.go b/src/pkg/auth/oidc_provider.go index 9d344ef19..2a07eeaf4 100644 --- a/src/pkg/auth/oidc_provider.go +++ b/src/pkg/auth/oidc_provider.go @@ -245,7 +245,7 @@ func (p *Provider) enrichFromUserInfo(ctx context.Context, c *Claims, accessToke if err != nil { return } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return } @@ -301,7 +301,7 @@ func (p *Provider) fetchIDToken(ctx context.Context, code, redirectURI string) ( if err != nil { return "", "", fmt.Errorf("token exchange: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) if resp.StatusCode != http.StatusOK { return "", "", fmt.Errorf("token endpoint returned %d", resp.StatusCode) @@ -599,7 +599,7 @@ func fetchDiscovery(ctx context.Context, issuer string) (*discoveryDoc, error) { if err != nil { return nil, fmt.Errorf("OIDC discovery: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("OIDC discovery returned %d", resp.StatusCode) } @@ -636,7 +636,7 @@ func fetchJWKS(ctx context.Context, jwksURL string) (map[string]*rsa.PublicKey, if err != nil { return nil, fmt.Errorf("JWKS fetch: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("JWKS returned %d", resp.StatusCode) } diff --git a/src/pkg/beads/beads.go b/src/pkg/beads/beads.go index a4b203b7b..c7fd558e5 100644 --- a/src/pkg/beads/beads.go +++ b/src/pkg/beads/beads.go @@ -99,7 +99,7 @@ func (ft *flexTime) UnmarshalJSON(b []byte) error { } func (ft flexTime) MarshalJSON() ([]byte, error) { - return json.Marshal(ft.Time.Format(time.RFC3339Nano)) + return json.Marshal(ft.Format(time.RFC3339Nano)) } type Bead struct { @@ -264,6 +264,13 @@ func upsertTitleKey(title string) string { return b.String() } +// UpsertTitleKey exposes the match key Upsert uses, for callers that must know +// which bead a report WOULD land on before writing it. The advisory provenance +// gate needs exactly that: it has to recognise the bead a re-report would +// refresh, including the cosmetic title drift Upsert folds, or it could only +// ever fire on byte-identical titles. +func UpsertTitleKey(title string) string { return upsertTitleKey(title) } + // Upsert records a finding without duplicating it: if an OPEN bead of the same // type already carries an equivalent title, its LastSeenAt is refreshed (and // its priority raised if this report is more severe) and that bead is returned; @@ -396,8 +403,14 @@ func (s *Store) appendArchiveEntry(b *Bead) bool { if err != nil { return false } - defer f.Close() if _, err := f.Write(append(data, '\n')); err != nil { + _ = f.Close() // best-effort cleanup; the write already failed + return false + } + // This function's whole contract is "did the record actually reach disk" — + // a deferred, error-ignored Close would let a failed flush report success, + // so the caller deletes the in-memory bead believing it is safely archived. + if err := f.Close(); err != nil { return false } return true @@ -712,7 +725,7 @@ func (s *Store) loadRetired() { if err != nil { return } - defer f.Close() + defer func() { _ = f.Close() }() // read-only fd; nothing to lose on close error // bufio.Reader, not Scanner: a Scanner stops PERMANENTLY on ErrTooLong, so a // single oversized entry would discard every retirement recorded after it // rather than just that one. Bead titles are unbounded and agent-influenced, @@ -790,16 +803,16 @@ func (s *Store) persist(_ *Bead) error { // makes 0600, so widen explicitly. _ = tmp.Chmod(0660) if _, err := tmp.Write(data); err != nil { - tmp.Close() - os.Remove(tmpPath) + _ = tmp.Close() // best-effort cleanup; the write error is what's returned + _ = os.Remove(tmpPath) // best-effort cleanup; the write error is what's returned return fmt.Errorf("writing tmp beads: %w", err) } if err := tmp.Close(); err != nil { - os.Remove(tmpPath) + _ = os.Remove(tmpPath) // best-effort cleanup; the close error is what's returned return fmt.Errorf("closing tmp beads: %w", err) } if err := os.Rename(tmpPath, path); err != nil { - os.Remove(tmpPath) + _ = os.Remove(tmpPath) // best-effort cleanup; the rename error is what's returned return err } return nil @@ -910,11 +923,16 @@ func (s *Store) Archive(id string) error { if err != nil { return fmt.Errorf("opening archive file: %w", err) } - defer f.Close() - if _, err := f.Write(append(data, '\n')); err != nil { + _ = f.Close() // best-effort cleanup; the write error is what's returned return fmt.Errorf("writing archive entry: %w", err) } + // The archive is the durability record for a bead about to be deleted from + // memory below — an ignored Close error here would let a failed flush look + // like a successful archive, and the record would be gone from both places. + if err := f.Close(); err != nil { + return fmt.Errorf("closing archive file: %w", err) + } delete(s.beads, id) // Retire ONLY a bead that actually reached a terminal state. The retired set diff --git a/src/pkg/beads/claim_handoff_test.go b/src/pkg/beads/claim_handoff_test.go new file mode 100644 index 000000000..7ca75afbb --- /dev/null +++ b/src/pkg/beads/claim_handoff_test.go @@ -0,0 +1,132 @@ +package beads + +import "testing" + +// These are CHARACTERIZATION tests: they pin down what `bd ready` + `bd update +// --claim` does today, because the step-3 handoff evaluation for RFC #4002 +// (src/docs/design/agent-turn-handoff.md) rests on it. That issue's hard +// problem 2 says cross-process handoff "must reuse the existing atomic +// offer->claim path" rather than parallel it. The path exists; the atomicity +// does not. Nothing here asserts that today's behaviour is desirable — only +// that a handoff design may not assume otherwise. +// +// Each test skips with a rewrite instruction if the guarantee it says is +// missing ever arrives, so a later compare-and-set lands as a signal to update +// the evaluation rather than as an unexplained red build. + +// TestClaimDoesNotRejectAnAlreadyClaimedBead pins the mutual-exclusion gap. +// Claim writes StatusInProgress unconditionally through Update, so the second +// claimant is told it succeeded. The cross-process flock in xproc_lock.go +// serializes the two WRITES; it does not make the second one a no-op, because +// nothing compares against the prior status. +func TestClaimDoesNotRejectAnAlreadyClaimedBead(t *testing.T) { + dir := t.TempDir() + + // Two stores over one directory stand in for two processes — the shape a + // handoff creates when a replacement adopts a task whose previous holder + // has not actually exited yet. + first, err := NewStore(dir) + if err != nil { + t.Fatalf("creating first store: %v", err) + } + bead, err := first.Create("resume the interrupted turn", TypeTask, PriorityHigh, "contributor", "") + if err != nil { + t.Fatalf("creating bead: %v", err) + } + + second, err := NewStore(dir) + if err != nil { + t.Fatalf("creating second store: %v", err) + } + + if err := first.Claim(bead.ID); err != nil { + t.Fatalf("first claim: %v", err) + } + if err := second.Claim(bead.ID); err != nil { + t.Skipf("second claim was rejected (%v) — Claim has become a compare-and-set; "+ + "rewrite this test as the exclusion guarantee it now provides", err) + } + + // Both callers were told they hold the task. That is the window a + // queue-based handoff would inherit if it claimed through this path. + after, err := second.Get(bead.ID) + if err != nil { + t.Fatalf("reading bead back: %v", err) + } + if after.Status != StatusInProgress { + t.Fatalf("status = %q, want %q", after.Status, StatusInProgress) + } +} + +// TestClaimRecordsNoClaimant pins the second half of the gap: a claim leaves no +// trace of WHO claimed. Actor is set at Create and describes who the bead is +// addressed to, not who holds it now, and Claim does not touch it. +// +// This is why re-entry cannot currently distinguish "I already hold this, +// resume it" from "somebody else holds this, leave it alone" — the distinction +// a handoff lease has to make, and the reason the step-3 evaluation puts an +// owner and a lease in the turn envelope rather than reading ownership off the +// bead. +func TestClaimRecordsNoClaimant(t *testing.T) { + store, err := NewStore(t.TempDir()) + if err != nil { + t.Fatalf("creating store: %v", err) + } + bead, err := store.Create("resume the interrupted turn", TypeTask, PriorityHigh, "contributor", "") + if err != nil { + t.Fatalf("creating bead: %v", err) + } + if err := store.Claim(bead.ID); err != nil { + t.Fatalf("claim: %v", err) + } + + claimed, err := store.Get(bead.ID) + if err != nil { + t.Fatalf("reading bead back: %v", err) + } + if claimed.Actor != "contributor" { + t.Fatalf("Actor = %q, want it unchanged at %q — Actor is the addressee, and a "+ + "claim that rewrote it would destroy the assignment", claimed.Actor, "contributor") + } + for _, key := range []string{"claimed_by", "claim_holder", "lease_owner", "holder"} { + if got := claimed.Meta(key); got != "" { + t.Skipf("metadata %q = %q — a claimant is now recorded; "+ + "the step-3 evaluation's premise needs revisiting", key, got) + } + } +} + +// TestReadyOffersTheSameBeadToRepeatedReaders pins the offer side. Ready is a +// pure read with no reservation, so polling it twice — as two processes sharing +// one actor identity do — hands the same task out twice. Any exclusion would +// have to come from the claim, which the first test shows does not provide it. +func TestReadyOffersTheSameBeadToRepeatedReaders(t *testing.T) { + dir := t.TempDir() + store, err := NewStore(dir) + if err != nil { + t.Fatalf("creating store: %v", err) + } + bead, err := store.Create("resume the interrupted turn", TypeTask, PriorityHigh, "contributor", "") + if err != nil { + t.Fatalf("creating bead: %v", err) + } + + other, err := NewStore(dir) + if err != nil { + t.Fatalf("creating second store: %v", err) + } + + readers := map[string]*Store{"first reader": store, "second reader": other} + for name, s := range readers { + found := false + for _, b := range s.Ready("contributor") { + if b.ID == bead.ID { + found = true + } + } + if !found { + t.Fatalf("%s: bead %s absent from Ready — the offer side has gained a "+ + "reservation and the step-3 evaluation needs revisiting", name, bead.ID) + } + } +} diff --git a/src/pkg/beads/upsert_titlekey_export_test.go b/src/pkg/beads/upsert_titlekey_export_test.go new file mode 100644 index 000000000..89d28d921 --- /dev/null +++ b/src/pkg/beads/upsert_titlekey_export_test.go @@ -0,0 +1,35 @@ +package beads + +// Tests for the exported UpsertTitleKey wrapper (beads.go). The advisory +// provenance gate relies on it returning EXACTLY the key Upsert matches on, +// including cosmetic-drift folding — so pin the exported contract against the +// internal upsertTitleKey, not a reconstruction of it. + +import "testing" + +func TestUpsertTitleKey_MatchesInternalKey(t *testing.T) { + titles := []string{ + "scanner: flaky run #3279 detected", + "scanner: flaky run #3291 detected", // cosmetic drift folds to same key + "UPPER Case Title", + "1234 5678", // no letters: falls back to trimmed lowercase + "", + } + for _, title := range titles { + if got, want := UpsertTitleKey(title), upsertTitleKey(title); got != want { + t.Errorf("UpsertTitleKey(%q) = %q, want internal key %q", title, got, want) + } + } +} + +func TestUpsertTitleKey_FoldsCosmeticDrift(t *testing.T) { + a := UpsertTitleKey("scanner: flaky run #3279 detected") + b := UpsertTitleKey("scanner: flaky run #3291 detected") + if a != b { + t.Fatalf("cosmetic drift not folded: %q vs %q", a, b) + } + c := UpsertTitleKey("scanner: DIFFERENT words entirely") + if a == c { + t.Fatalf("semantically different titles collided on key %q", a) + } +} diff --git a/src/pkg/beads/xproc_lock.go b/src/pkg/beads/xproc_lock.go index b36cf59e2..724a96282 100644 --- a/src/pkg/beads/xproc_lock.go +++ b/src/pkg/beads/xproc_lock.go @@ -35,7 +35,7 @@ func (s *Store) lockAndRefresh() func() { return func() {} } if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { - f.Close() + _ = f.Close() // best-effort cleanup; degrades to unserialized behavior per the doc comment above return func() {} } s.refreshFromDisk() @@ -87,7 +87,7 @@ func (s *Store) refreshFromDisk() { s.beads[b.ID] = b continue } - if b.UpdatedAt.Time.After(cur.UpdatedAt.Time) { + if b.UpdatedAt.After(cur.UpdatedAt.Time) { s.beads[b.ID] = b } } diff --git a/src/pkg/channels/webhook.go b/src/pkg/channels/webhook.go index 217f6eda3..3211f5ec0 100644 --- a/src/pkg/channels/webhook.go +++ b/src/pkg/channels/webhook.go @@ -108,7 +108,7 @@ func (w *WebhookReceiver) ServeHTTP(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "application/json") rw.WriteHeader(http.StatusOK) - fmt.Fprintf(rw, `{"ok":true,"event":%q,"triggered":%d}`, fullEvent, triggered) + _, _ = fmt.Fprintf(rw, `{"ok":true,"event":%q,"triggered":%d}`, fullEvent, triggered) // best-effort; client may already be gone } func matchesEvent(events []string, eventType, fullEvent string) bool { diff --git a/src/pkg/classify/classifier_test.go b/src/pkg/classify/classifier_test.go index fa83a8642..77a810add 100644 --- a/src/pkg/classify/classifier_test.go +++ b/src/pkg/classify/classifier_test.go @@ -378,10 +378,9 @@ func TestClassifyAll_MutatesSlice(t *testing.T) { result := ClassifyAll(issues) - // ClassifyAll returns the same (mutated) slice - if &result[0] != &issues[0] { - // Underlying array might differ due to slice semantics; check values instead - } + // ClassifyAll returns the same (mutated) slice. The underlying array + // might differ due to slice semantics, so the mutation is verified by + // checking values below rather than pointer identity. if result[0].ComplexityTier != string(TierSimple) { t.Errorf("issue[0]: want ComplexityTier %q, got %q", TierSimple, result[0].ComplexityTier) diff --git a/src/pkg/claude/oauth.go b/src/pkg/claude/oauth.go index dd1015dfd..47ef515a5 100644 --- a/src/pkg/claude/oauth.go +++ b/src/pkg/claude/oauth.go @@ -68,13 +68,20 @@ type Credentials struct { } // OAuthTokens holds the token set stored inside the credentials file. +// +// RefreshTokenExpiresAt is carried even though hive never mints it: Claude +// Code writes it, and a struct that dropped the field would silently delete +// the only evidence that distinguishes "this login is over" from "this access +// token aged out and the next CLI start will mint a new one" — see +// HasUsableToken. type OAuthTokens struct { - AccessToken string `json:"accessToken"` - RefreshToken string `json:"refreshToken,omitempty"` - ExpiresAt int64 `json:"expiresAt"` - Scopes []string `json:"scopes"` - SubscriptionType string `json:"subscriptionType,omitempty"` - RateLimitTier string `json:"rateLimitTier,omitempty"` + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken,omitempty"` + ExpiresAt int64 `json:"expiresAt"` + RefreshTokenExpiresAt int64 `json:"refreshTokenExpiresAt,omitempty"` + Scopes []string `json:"scopes"` + SubscriptionType string `json:"subscriptionType,omitempty"` + RateLimitTier string `json:"rateLimitTier,omitempty"` } // tokenResponse is the raw response from the Claude token endpoint. @@ -149,7 +156,7 @@ func ExchangeCode(code, codeVerifier, redirectURI string) (*OAuthTokens, error) if err != nil { return nil, fmt.Errorf("token exchange request: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -203,27 +210,84 @@ func WriteCredentials(tokens *OAuthTokens, path string) error { return nil } -// ReadAccessToken reads the access token from the credentials file. -// Returns empty string if the file doesn't exist or is malformed. -func ReadAccessToken(path string) string { +// LoadTokens parses the credentials file and returns the stored token set, or +// nil when the file is absent, malformed, or carries no token block. It never +// applies an expiry rule — callers decide what a stale token means to them. +func LoadTokens(path string) *OAuthTokens { data, err := os.ReadFile(path) if err != nil { - return "" + return nil } var creds Credentials if err := json.Unmarshal(data, &creds); err != nil { - return "" + return nil } - if creds.ClaudeAIOAuth == nil { + return creds.ClaudeAIOAuth +} + +// expired reports whether a millisecond epoch stamp is in the past. A zero or +// negative stamp is "no expiry recorded", which is never treated as expired — +// fabricating an expiry from a missing one is how a working credential gets +// declared dead. +func expired(unixMilli int64, now time.Time) bool { + return unixMilli > 0 && unixMilli < now.UnixMilli() +} + +// ReadAccessToken reads the access token from the credentials file. +// Returns empty string if the file doesn't exist or is malformed. +// +// An EXPIRED access token reads as absent, deliberately: every caller of this +// function wants a string to put in an Authorization header, and sending an +// expired one manufactures a 401. Callers asking the different question — "can +// this credential still get an agent working?" — must use HasUsableToken. +func ReadAccessToken(path string) string { + tokens := LoadTokens(path) + if tokens == nil { return "" } - if creds.ClaudeAIOAuth.ExpiresAt > 0 && creds.ClaudeAIOAuth.ExpiresAt < time.Now().UnixMilli() { + if expired(tokens.ExpiresAt, time.Now()) { return "" } - return creds.ClaudeAIOAuth.AccessToken + return tokens.AccessToken } // HasValidToken returns true if a valid, non-expired Claude token exists. func HasValidToken(path string) bool { return ReadAccessToken(path) != "" } + +// HasUsableToken reports whether this credential can put an agent back to work +// WITHOUT a human completing an OAuth flow — either the access token is still +// live, or it has aged out but the refresh grant behind it has not. +// +// The distinction is not academic; it is the difference between the two +// recoveries hive can prescribe, and they are nothing alike: +// +// - refreshable → RESTART THE CLI. Claude Code redeems the refresh token +// when a process starts, so a relaunch mints a new access token from the +// file already on disk. Measured on a live hive (2026-09-01): a credential +// whose access token had expired eight hours earlier produced a working +// agent on the first restart, with a refresh grant still 28 days from its +// own expiry. +// - not refreshable → OPERATOR LOGIN. Nothing on disk can mint a token; a +// human has to authenticate. +// +// Claude access tokens live 8 hours (measured: mint-to-expiresAt on a live +// credential), so the refreshable state is the ROUTINE +// one — a hive whose agents run longer than half a day enters it daily. Every +// hive decision that reads "expired" as "logged out" therefore pages a human +// once a day for a credential that would have healed itself on a restart. +// +// Positive evidence only: an unreadable or absent file returns false, leaving +// callers exactly where they were before they asked. +func HasUsableToken(path string) bool { + tokens := LoadTokens(path) + if tokens == nil { + return false + } + now := time.Now() + if tokens.AccessToken != "" && !expired(tokens.ExpiresAt, now) { + return true + } + return tokens.RefreshToken != "" && !expired(tokens.RefreshTokenExpiresAt, now) +} diff --git a/src/pkg/claude/usable_token_test.go b/src/pkg/claude/usable_token_test.go new file mode 100644 index 000000000..f2a442af4 --- /dev/null +++ b/src/pkg/claude/usable_token_test.go @@ -0,0 +1,134 @@ +package claude + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +// writeCredFile stages a credentials file with the given token set. +func writeCredFile(t *testing.T, tokens *OAuthTokens) string { + t.Helper() + path := filepath.Join(t.TempDir(), ".credentials.json") + data, err := json.Marshal(Credentials{ClaudeAIOAuth: tokens}) + if err != nil { + t.Fatalf("marshal credentials: %v", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write credentials: %v", err) + } + return path +} + +func ms(d time.Duration) int64 { return time.Now().Add(d).UnixMilli() } + +// TestHasUsableToken_ExpiredButRefreshable pins the incident this function was +// added for (kubestellar/hive, 2026-09-01): a six-agent fleet went to +// "needs re-authentication" overnight because every hive predicate read the +// aged-out access token as "logged out", while the refresh grant beside it was +// four weeks from expiring and a single CLI restart recovered the fleet. +func TestHasUsableToken_ExpiredButRefreshable(t *testing.T) { + path := writeCredFile(t, &OAuthTokens{ + AccessToken: "expired-access", + ExpiresAt: ms(-2 * time.Hour), + RefreshToken: "live-refresh", + RefreshTokenExpiresAt: ms(28 * 24 * time.Hour), + }) + if HasValidToken(path) { + t.Fatal("HasValidToken must stay false for an expired access token — callers put its result in an Authorization header") + } + if !HasUsableToken(path) { + t.Fatal("HasUsableToken must be true: the refresh grant is live, so a CLI restart recovers this with no operator login") + } +} + +func TestHasUsableToken_LiveAccessToken(t *testing.T) { + path := writeCredFile(t, &OAuthTokens{ + AccessToken: "live-access", + ExpiresAt: ms(4 * time.Hour), + }) + if !HasUsableToken(path) { + t.Fatal("a live access token is usable even with no refresh grant recorded") + } +} + +// TestHasUsableToken_NoRefreshGrantStaysUnusable is the half that must NOT +// change: with nothing on disk that can mint a token, the loud operator alert +// is the correct outcome and this function must not suppress it. +func TestHasUsableToken_NoRefreshGrantStaysUnusable(t *testing.T) { + path := writeCredFile(t, &OAuthTokens{ + AccessToken: "expired-access", + ExpiresAt: ms(-time.Minute), + }) + if HasUsableToken(path) { + t.Fatal("an expired access token with no refresh grant is spent; only a human can fix it") + } +} + +func TestHasUsableToken_ExpiredRefreshGrantIsUnusable(t *testing.T) { + path := writeCredFile(t, &OAuthTokens{ + AccessToken: "expired-access", + ExpiresAt: ms(-48 * time.Hour), + RefreshToken: "stale-refresh", + RefreshTokenExpiresAt: ms(-time.Hour), + }) + if HasUsableToken(path) { + t.Fatal("a refresh grant past its own expiry cannot mint anything") + } +} + +// TestHasUsableToken_MissingExpiryIsNotExpiry guards the direction the whole +// change fails safely in: an absent stamp means "not recorded", and inventing +// an expiry from it would declare a working credential dead. +func TestHasUsableToken_MissingExpiryIsNotExpiry(t *testing.T) { + path := writeCredFile(t, &OAuthTokens{ + AccessToken: "expired-access", + ExpiresAt: ms(-time.Hour), + RefreshToken: "refresh-with-no-recorded-expiry", + }) + if !HasUsableToken(path) { + t.Fatal("a refresh grant with no recorded expiry must be treated as live, not as expired") + } +} + +// TestHasUsableToken_PositiveEvidenceOnly: anything short of a parseable +// credential leaves the caller exactly where it was. +func TestHasUsableToken_PositiveEvidenceOnly(t *testing.T) { + dir := t.TempDir() + missing := filepath.Join(dir, "absent.json") + if HasUsableToken(missing) { + t.Fatal("an absent file is not evidence of anything") + } + malformed := filepath.Join(dir, "malformed.json") + if err := os.WriteFile(malformed, []byte("{not json"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if HasUsableToken(malformed) { + t.Fatal("a malformed file is not evidence of anything") + } + empty := writeCredFile(t, nil) + if HasUsableToken(empty) { + t.Fatal("a credentials file with no token block is not evidence of anything") + } +} + +// TestRefreshTokenExpiresAtRoundTrips: WriteCredentials must not silently drop +// the only field that distinguishes a refreshable credential from a spent one. +func TestRefreshTokenExpiresAtRoundTrips(t *testing.T) { + path := filepath.Join(t.TempDir(), ".credentials.json") + want := int64(1790668886454) + if err := WriteCredentials(&OAuthTokens{ + AccessToken: "a", + ExpiresAt: ms(time.Hour), + RefreshToken: "r", + RefreshTokenExpiresAt: want, + }, path); err != nil { + t.Fatalf("write: %v", err) + } + got := LoadTokens(path) + if got == nil || got.RefreshTokenExpiresAt != want { + t.Fatalf("refreshTokenExpiresAt lost on round trip: got %+v", got) + } +} diff --git a/src/pkg/config/backend_validation_test.go b/src/pkg/config/backend_validation_test.go index ff4dba6fc..7b77bb990 100644 --- a/src/pkg/config/backend_validation_test.go +++ b/src/pkg/config/backend_validation_test.go @@ -17,6 +17,16 @@ func TestValidateBackend_AcceptsWatsonx(t *testing.T) { } } +// Ranging over SupportedBackends() alone is tautological (#5388): +// SupportedBackends() is exactly CLIBackends ++ InferenceBackends, and +// ValidateBackend returns nil early iff IsCLIBackend(b) || IsInferenceBackend(b). +// Every element it yields satisfies that disjunction by construction, so the +// gateway branch and the error-formatting branch below it are unreachable for +// these inputs and no edit to either list can fail the loop. +// +// The loop is kept — it is cheap and it does pin the early-return path — but +// the assertions that can actually fail are the ones after it: a rejection +// that must happen, and the gateway acceptance path the loop never reaches. func TestValidateBackend_AcceptsAllSupported(t *testing.T) { var g GovernorConfig // Empty means "hive default" and must stay valid. @@ -28,6 +38,13 @@ func TestValidateBackend_AcceptsAllSupported(t *testing.T) { t.Errorf("ValidateBackend(%q) = %v, want nil", b, err) } } + + // A name in NEITHER list and matching no gateway must be rejected. Without + // this, a ValidateBackend rewritten to `return nil` unconditionally would + // pass every assertion above. + if err := g.ValidateBackend("definitely-not-a-backend"); err == nil { + t.Error("ValidateBackend(definitely-not-a-backend) = nil, want an error — validation accepts anything") + } } func TestValidateBackend_RejectsUnknownWithHelpfulMessage(t *testing.T) { diff --git a/src/pkg/config/bob_test.go b/src/pkg/config/bob_test.go index af91cf9bd..38c93cbd2 100644 --- a/src/pkg/config/bob_test.go +++ b/src/pkg/config/bob_test.go @@ -86,12 +86,12 @@ func TestBobConfigResolveAPIKey(t *testing.T) { } source := c.ResolveAPIKeySource() - switch { - case tc.wantSource == "": + switch tc.wantSource { + case "": if source != "" { t.Errorf("ResolveAPIKeySource() = %q, want empty", source) } - case tc.wantSource == "file:": + case "file:": if len(source) < len("file:") || source[:len("file:")] != "file:" { t.Errorf("ResolveAPIKeySource() = %q, want a file: source", source) } diff --git a/src/pkg/config/checkout_root_test.go b/src/pkg/config/checkout_root_test.go new file mode 100644 index 000000000..846abbc5b --- /dev/null +++ b/src/pkg/config/checkout_root_test.go @@ -0,0 +1,52 @@ +package config + +import ( + "path/filepath" + "testing" +) + +// TestCheckoutRootFor pins ProjectConfig.CheckoutRootFor (config.go), which +// maps a monitored repo to its host-local checkout root (kubestellar/hive#5227) +// and was previously untested at 0% coverage. The traversal guard matters: +// the result is a filesystem path built from config strings, and a name of +// ".." or one carrying a separator must never escape CheckoutsDir. +func TestCheckoutRootFor(t *testing.T) { + cases := []struct { + name string + dir string + repo string + want string + }{ + {"bare repo name", "/data/checkouts", "hive", filepath.Join("/data/checkouts", "hive")}, + {"org-qualified slug uses name only", "/data/checkouts", "kubestellar/hive", filepath.Join("/data/checkouts", "hive")}, + {"deep slug uses last segment", "/data/checkouts", "gitlab.com/group/sub/repo", filepath.Join("/data/checkouts", "repo")}, + {"empty checkouts dir is a no-op", "", "hive", ""}, + {"whitespace-only checkouts dir is a no-op", " ", "hive", ""}, + {"empty repo is a no-op", "/data/checkouts", "", ""}, + {"slug with trailing slash has no name", "/data/checkouts", "kubestellar/", ""}, + {"dot name refused", "/data/checkouts", ".", ""}, + {"dotdot name refused", "/data/checkouts", "..", ""}, + {"org-qualified dotdot refused", "/data/checkouts", "kubestellar/..", ""}, + {"backslash in name refused", "/data/checkouts", `evil\name`, ""}, + {"org-qualified backslash refused", "/data/checkouts", `org/..\evil`, ""}, + {"surrounding whitespace trimmed", " /data/checkouts ", " hive ", filepath.Join("/data/checkouts", "hive")}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := &ProjectConfig{CheckoutsDir: tc.dir} + if got := p.CheckoutRootFor(tc.repo); got != tc.want { + t.Fatalf("CheckoutRootFor(%q) with CheckoutsDir=%q = %q, want %q", tc.repo, tc.dir, got, tc.want) + } + }) + } +} + +// A refused or unconfigured lookup must return exactly "" — the documented +// no-op sentinel — never a partial path the caller might join or stat. +func TestCheckoutRootFor_NoOpIsEmptyString(t *testing.T) { + p := &ProjectConfig{} + if got := p.CheckoutRootFor("kubestellar/hive"); got != "" { + t.Fatalf("unconfigured CheckoutRootFor = %q, want empty string", got) + } +} diff --git a/src/pkg/config/config.go b/src/pkg/config/config.go index e502f1a7a..2b04183b2 100644 --- a/src/pkg/config/config.go +++ b/src/pkg/config/config.go @@ -659,6 +659,18 @@ type ProjectConfig struct { // polarity is Governor.Labels.Exempt, which wins on conflict. Absent/empty // = no filtering, the pre-existing behavior. See IssueFilterConfig. IssueFilter IssueFilterConfig `yaml:"issue_filter,omitempty"` + // CheckoutsDir is a host-local directory holding one checkout per monitored + // repo, as "/" — the bare name from Repos, without + // the org. It is how an operator supplies the per-repo checkout root the + // AGENTS.md convention needs (kubestellar/hive#5227): Hive agents work over + // the API and keep no clones of their own, so without this there is no local + // path for the scheduler to read a repo's AGENTS.md from. + // + // Optional and additive. Empty (the default) means no checkout root, which + // is exactly the previous behavior — AGENTS.md injection stays a no-op. A + // directory that is absent or holds no AGENTS.md is also a no-op; nothing + // here can fail a kick. See CheckoutRootFor. + CheckoutsDir string `yaml:"checkouts_dir,omitempty"` } const ( @@ -679,6 +691,31 @@ func (p *ProjectConfig) ForgeKind() string { return p.Forge } +// CheckoutRootFor returns the host-local checkout root for one monitored repo, +// or "" when none is configured. repo may be a bare name ("hive") or an +// org-qualified slug ("kubestellar/hive"); only the name portion is used, since +// CheckoutsDir is keyed by bare repo name. +// +// Returning "" is the no-op case and is deliberately the default: a hive that +// never sets checkouts_dir behaves exactly as it did before this existed. +func (p *ProjectConfig) CheckoutRootFor(repo string) string { + dir := strings.TrimSpace(p.CheckoutsDir) + name := strings.TrimSpace(repo) + if i := strings.LastIndex(name, "/"); i >= 0 { + name = name[i+1:] + } + if dir == "" || name == "" { + return "" + } + // Refuse a name that would escape CheckoutsDir. A repo name comes from + // config rather than from a forge, but this is a filesystem path built from + // a string and the guard costs nothing. + if name == "." || name == ".." || strings.ContainsAny(name, `/\`) { + return "" + } + return filepath.Join(dir, name) +} + // PRsAllowed returns whether agents may open pull requests. Defaults to true. func (p *ProjectConfig) PRsAllowed() bool { if p.OpenPRs != nil { @@ -1000,6 +1037,20 @@ type AgentConfig struct { // Connections declares external service integrations (MCP servers, APIs, knowledge sources). Connections []ConnectionConfig `yaml:"connections,omitempty" json:"connections,omitempty"` + // Skills names reusable "how to do X" skills to resolve out of the hive's + // skill registry (pkg/skillreg, loaded from the host-local skills directory) + // and inject into this agent's kick context. Names are resolved at kick + // time, so editing a skill file takes effect on the next kick without a + // restart. An unknown name is skipped, not fatal: a typo degrades the kick + // rather than blocking the agent. + // + // This is deliberately host-local rather than per-repo. Hive agents work + // over the GitHub API and have no guaranteed per-repo checkout, so a + // repo-declared skills directory would resolve to nothing on most kicks; + // the registry directory is the same kind of operator-managed volume as + // /data/policies and is present on every hive host. + Skills []string `yaml:"skills,omitempty" json:"skills,omitempty"` + // Managed is true for agents loaded from the overlay directory (not base config). Managed bool `yaml:"-" json:"managed"` @@ -4148,7 +4199,7 @@ func ParseEnvFile(path string) (map[string]string, error) { if err != nil { return nil, err } - defer f.Close() + defer func() { _ = f.Close() }() // read-only fd; nothing to lose on close error result := make(map[string]string) scanner := bufio.NewScanner(f) @@ -4865,7 +4916,7 @@ func IsInferenceBackend(backend string) bool { // a backend here without also updating the shell side (or, if it genuinely // belongs on only one side, documenting why in cliBackendExceptions) fails // that test. -var CLIBackends = []string{"claude", "copilot", "goose", "codex", "pi", "bob", "aider", "gemini", "agy"} +var CLIBackends = []string{"claude", "copilot", "goose", "codex", "pi", "bob", "aider", "gemini", "agy", "opencode", "kilo"} // IsCLIBackend returns true if the backend launches an agentic CLI binary. func IsCLIBackend(backend string) bool { @@ -4946,7 +4997,7 @@ func (c *Config) validate() error { // empty github block validate and silently boot a hive with no credentials // at all. Only a forge the operator actually wrote counts. if c.GitHub.Token == "" && c.GitHub.AppID == 0 && - !(strings.TrimSpace(c.GitHub.Forge_) != "" && c.GitHub.ResolvedAppID() != 0) { + (strings.TrimSpace(c.GitHub.Forge_) == "" || c.GitHub.ResolvedAppID() == 0) { return fmt.Errorf("github.token, github.app_id or github.forge is required") } if err := c.Governor.LiteLLM.Validate(); err != nil { @@ -5355,10 +5406,10 @@ func (c *Config) saveLocked() error { } } else { if _, err := f.Write(data); err != nil { - f.Close() + _ = f.Close() // best-effort cleanup; the write error is what's recorded srcErr = fmt.Errorf("writing config: %w", err) } else if err := f.Sync(); err != nil { - f.Close() + _ = f.Close() // best-effort cleanup; the sync error is what's recorded srcErr = fmt.Errorf("syncing config: %w", err) } else if err := f.Close(); err != nil { srcErr = fmt.Errorf("closing config: %w", err) @@ -5374,11 +5425,15 @@ func (c *Config) saveLocked() error { // renamed or removed here — see RuntimeConfigFileLegacy. runtimePath := RuntimeConfigFile var runtimeErr error - if err := os.WriteFile(runtimePath, data, 0o644); err != nil { + // 0600, not 0644: the marshaled config carries dashboard.auth_token (and + // github.token in PAT mode), and /data is world-traversable on hive + // hosts, so a group/world-readable runtime config hands the dashboard + // owner credential to every unprivileged agent user (#5331). + if err := os.WriteFile(runtimePath, data, 0o600); err != nil { // Common cause: init container created the file as root, runtime user // can't overwrite. Remove and retry so runtime state is not silently lost. - os.Remove(runtimePath) - if retryErr := os.WriteFile(runtimePath, data, 0o644); retryErr != nil { + _ = os.Remove(runtimePath) // best-effort; the retry's own WriteFile error is what's recorded below + if retryErr := os.WriteFile(runtimePath, data, 0o600); retryErr != nil { runtimeErr = retryErr log.Printf("[config] warning: failed to write PVC runtime config to %s (even after remove): %v", runtimePath, retryErr) } else { @@ -5386,6 +5441,12 @@ func (c *Config) saveLocked() error { } } else { log.Printf("[config] PVC runtime config written to %s", runtimePath) + // os.WriteFile's mode only applies when it CREATES the file; a + // pre-existing world-readable inode (every hive deployed before + // this fix) keeps its old 0644 bits, so tighten explicitly. + if chmodErr := os.Chmod(runtimePath, 0o600); chmodErr != nil { + log.Printf("[config] warning: failed to tighten permissions on %s: %v", runtimePath, chmodErr) + } } overlayErr := c.saveDashboardOverlay() @@ -5456,6 +5517,17 @@ var DashboardOverlayFile = "/data/hive.yaml.dashboard" // production always uses the fixed in-cluster path. var saTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" +// SetSATokenFileForTest points IsKubernetesPod's serviceaccount-token probe +// at path and returns a restore func. Out-of-package tests that need the +// non-Kubernetes branch call this with a non-existent path (alongside +// clearing KUBERNETES_SERVICE_HOST) so they stay hermetic on hosts that +// really are pods — in-cluster CI runners and dev hives. +func SetSATokenFileForTest(path string) func() { + orig := saTokenFile + saTokenFile = path + return func() { saTokenFile = orig } +} + // IsKubernetesPod reports whether the process is running inside a // Kubernetes pod (mirrors the entrypoint's IS_KUBERNETES detection). func IsKubernetesPod() bool { @@ -5501,19 +5573,27 @@ func (c *Config) saveDashboardOverlay() error { return err } tmpPath := DashboardOverlayFile + ".tmp" - const overlayFileMode = 0o644 + // 0600, not 0644: dashboardOverlayBytes only folds the dashboard auth + // token back to its env form when it matches a bootstrap env var — a + // dashboard-minted token is persisted verbatim, so the overlay is not + // reliably secret-free (#5331). + const overlayFileMode = 0o600 f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, overlayFileMode) if err != nil { log.Printf("[config] warning: failed to open dashboard overlay temp file %s (dashboard saves will not survive pod restarts): %v", tmpPath, err) return err } + // OpenFile's mode only applies on create; a leftover 0644 tmp file from a + // crash before this fix would otherwise carry its old bits through the + // rename. Best-effort: the rename below installs whatever mode f has. + _ = f.Chmod(overlayFileMode) if _, err := f.Write(data); err != nil { - f.Close() + _ = f.Close() // best-effort cleanup; the write error is what's returned log.Printf("[config] warning: failed to write dashboard overlay temp file %s (dashboard saves will not survive pod restarts): %v", tmpPath, err) return err } if err := f.Sync(); err != nil { - f.Close() + _ = f.Close() // best-effort cleanup; the sync error is what's returned log.Printf("[config] warning: failed to fsync dashboard overlay temp file %s (dashboard saves will not survive pod restarts): %v", tmpPath, err) return err } diff --git a/src/pkg/config/dockerfile_contributor_test.go b/src/pkg/config/dockerfile_contributor_test.go index cd64740bc..ff2663ef9 100644 --- a/src/pkg/config/dockerfile_contributor_test.go +++ b/src/pkg/config/dockerfile_contributor_test.go @@ -27,3 +27,26 @@ func TestContributorDockerfileInstallsPiWithoutCurlPipeShell(t *testing.T) { } } } + +// The image installs claude-code with --ignore-scripts (deliberately — no +// arbitrary postinstall runs during the build), but that also skips the +// package's install.cjs, which links the platform-native binary into bin/. +// Without an explicit postinstall + verification, `claude` in the container +// dies with "claude native binary not installed" on every task while local +// mode works fine. src/Dockerfile Layer 7 fixed this for the spoke image; +// this pins the contributor image's copy of the same fix. +func TestContributorDockerfileLinksClaudeNativeBinary(t *testing.T) { + body, err := os.ReadFile(filepath.Join("..", "..", "Dockerfile.contributor")) + if err != nil { + t.Fatalf("read Dockerfile.contributor: %v", err) + } + dockerfile := string(body) + for _, want := range []string{ + `node "$(npm root -g)/@anthropic-ai/claude-code/install.cjs"`, + "claude --version", + } { + if !strings.Contains(dockerfile, want) { + t.Fatalf("Dockerfile.contributor missing %q — claude's native binary is not linked (or not verified) at build time, so container-mode claude fails at runtime with 'claude native binary not installed'", want) + } + } +} diff --git a/src/pkg/config/entrypoint_boot_test.go b/src/pkg/config/entrypoint_boot_test.go index e5093a6ad..43ff25615 100644 --- a/src/pkg/config/entrypoint_boot_test.go +++ b/src/pkg/config/entrypoint_boot_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "testing" ) @@ -28,6 +29,15 @@ func runBootPrelude(t *testing.T, env map[string]string, files map[string]string // config path cannot be written — a bind-mounted hive.yaml under docker/podman // is the ordinary way that happens. func runBootPreludeRO(t *testing.T, env map[string]string, files map[string]string, readOnly []string) string { + t.Helper() + out, _ := runBootPreludeRoot(t, env, files, readOnly) + return out +} + +// runBootPreludeRoot is runBootPreludeRO that also returns the temp root, so a +// test can inspect the FILES the prelude left behind (permissions, contents) +// and not just what it logged. +func runBootPreludeRoot(t *testing.T, env map[string]string, files map[string]string, readOnly []string) (string, string) { t.Helper() src, err := os.ReadFile(entrypointPath) if err != nil { @@ -40,6 +50,14 @@ func runBootPreludeRO(t *testing.T, env map[string]string, files map[string]stri t.Fatal("could not find the end of the config branch in entrypoint.sh; the marker moved and this test would silently cover nothing") } root := t.TempDir() + // /data always exists on a real hive (it is the PVC mount point), so + // create it even when a case seeds no files into it. Otherwise the + // entrypoint's `cp ... /data/hive.yaml.runtime` fails on the missing + // directory and a permissions assertion would pass/fail for the wrong + // reason. + if err := os.MkdirAll(filepath.Join(root, "data"), 0o755); err != nil { + t.Fatal(err) + } for rel, content := range files { p := filepath.Join(root, rel) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { @@ -53,6 +71,21 @@ func runBootPreludeRO(t *testing.T, env map[string]string, files map[string]stri body := text[:end] body = strings.ReplaceAll(body, `"/data/`, `"`+root+`/data/`) body = strings.ReplaceAll(body, `/etc/hive/hive.yaml`, root+"/etc/hive/hive.yaml") + // Rewrite the serviceaccount-token probe too, so IS_KUBERNETES is decided + // by the test's KUBERNETES_SERVICE_HOST alone. Without this, the real + // in-cluster token file flips every "Docker mode" test into the K8s + // branch on in-cluster CI runners and dev hives. + body = strings.ReplaceAll(body, + "/var/run/secrets/kubernetes.io/serviceaccount/token", + root+"/var/run/secrets/kubernetes.io/serviceaccount/token") + // Map the runtime uid onto the sandbox. The prelude's "is this file owned + // by the runtime user" fast paths compare against the literal uid 1001 + // (dev in the shipped image). Under `go test` every seeded file is owned + // by the CURRENT uid, and a non-root test cannot chown, so on any host + // where tests do not happen to run as uid 1001 hive_harden_runtime_config + // takes its cannot-chown branch by design (#5360) and the 0600 assertions + // fail for a reason that has nothing to do with the hardening under test. + body = strings.ReplaceAll(body, `"1001"`, `"`+strconv.Itoa(os.Getuid())+`"`) for _, rel := range readOnly { p := filepath.Join(root, rel) @@ -69,12 +102,83 @@ func runBootPreludeRO(t *testing.T, env map[string]string, files map[string]stri } cmd := exec.Command("bash", "-c", body) - cmd.Env = append(os.Environ(), "HIVE_CONFIG="+root+"/etc/hive/hive.yaml") + // Drop the host's own KUBERNETES_SERVICE_HOST so IS_KUBERNETES is decided + // by the case's env alone. On an in-cluster runner or a live hive host the + // inherited variable flips every "Docker mode" case into the K8s branch — + // the env-var half of the same leak the serviceaccount-token path rewrite + // above closes for the file half of the probe. + for _, kv := range os.Environ() { + if strings.HasPrefix(kv, "KUBERNETES_SERVICE_HOST=") { + continue + } + cmd.Env = append(cmd.Env, kv) + } + cmd.Env = append(cmd.Env, "HIVE_CONFIG="+root+"/etc/hive/hive.yaml") for k, v := range env { cmd.Env = append(cmd.Env, k+"="+v) } out, _ := cmd.CombinedOutput() - return string(out) + return string(out), root +} + +// TestEntrypointHardensRuntimeConfigItRecreates is the regression guard for +// #5331: every `cp` that (re)creates /data/hive.yaml.runtime must leave it +// 0600. +// +// The boot-time chmod loop only covers files that already exist when the +// script starts. These three branches CREATE the runtime copy from a 0644 +// source (the ConfigMap seed or the bind-mounted hive.yaml), and cp gives a +// newly created destination the source's mode — so without the explicit +// hardening the file is re-widened to 0644 on every boot, after the loop has +// already run. The runtime config carries dashboard.auth_token, and /data is +// world-traversable, so 0644 hands the dashboard owner credential to every +// unprivileged agent uid on the host. +// +// The harness writes its seed files 0644, which is exactly the production +// mode, so this test fails against the unhardened script. +func TestEntrypointHardensRuntimeConfigItRecreates(t *testing.T) { + cases := []struct { + name string + env map[string]string + files map[string]string + }{ + { + // K8s first boot: seeded from the 0644 ConfigMap seed. + name: "k8s seeds runtime from ConfigMap", + env: map[string]string{"KUBERNETES_SERVICE_HOST": "10.0.0.1"}, + files: map[string]string{ + "etc/hive/hive.yaml": "acmm_level: 3\n", + }, + }, + { + // Docker first boot: seeded from the 0644 bind-mounted config. + name: "docker first boot seeds runtime", + env: map[string]string{}, + files: map[string]string{"etc/hive/hive.yaml": "acmm_level: 3\n"}, + }, + { + // Docker migration: the new name is created from legacy .bak. + name: "docker migration seeds runtime from legacy bak", + env: map[string]string{}, + files: map[string]string{ + "etc/hive/hive.yaml": "acmm_level: 3\n", + "data/hive.yaml.bak": "acmm_level: 5\n", + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, root := runBootPreludeRoot(t, tc.env, tc.files, nil) + p := filepath.Join(root, "data/hive.yaml.runtime") + info, err := os.Stat(p) + if err != nil { + t.Fatalf("the prelude did not create the runtime config (%v); this branch no longer covers what the test claims:\n%s", err, out) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("runtime config is %04o, want 0600 — it carries dashboard.auth_token and /data is world-traversable (#5331):\n%s", got, out) + } + }) + } } // TestK8sBootsFromRuntimeConfigNotTheSeed pins the phase-2 precedence: the PVC diff --git a/src/pkg/config/env_vars_doc_parity_test.go b/src/pkg/config/env_vars_doc_parity_test.go new file mode 100644 index 000000000..d89d95335 --- /dev/null +++ b/src/pkg/config/env_vars_doc_parity_test.go @@ -0,0 +1,230 @@ +package config + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// envVarsDocPath is the hand-maintained environment variable reference this +// guard polices. It lives under src/, which matters: v2-tests.yml gates PRs on +// `paths: ['src/**', ...]`, so both this test and the file it checks are inside +// the filter. The #5077 post-mortem (#5388) found two guards that could never +// run because the artifact they checked sat outside their workflow's path +// filter — dashboard/openapi.json and the repo-root NOTICE. This one does not +// have that hole, and an env-vars.md-only PR does run this test. +const envVarsDocPath = "../../docs/env-vars.md" + +// envVarsDocScanRoots are the trees searched for evidence that a documented +// variable is real. Env vars in this repo are consumed from Go, from the +// deployment/entrypoint shell, from the Justfile, and from config templates, so +// all of them count as an implementation. Paths are relative to this package +// (src/pkg/config). +var envVarsDocScanRoots = []string{ + "../..", // the whole src/ tree: Go, src/deploy/entrypoint.sh, manifests + "../../../bin", + "../../../config", + "../../../Justfile", + "../../../install.sh", + "../../../uninstall.sh", +} + +// TestEnvVarsDocDocumentsOnlyRealVariables is the #5407 guard. +// +// src/docs/env-vars.md is a hand-compiled reference whose own header says "the +// code is authoritative". Nothing kept it honest. That is the same shape of +// artifact as dashboard/openapi.json before #5077: a published reference +// consumers are told to trust, with no mechanical tie to the thing it +// describes. In #5077 the spec had drifted into documenting fields the server +// never sent, and three downstream client tasks carried the acceptance +// criterion "mirror the spec, do not invent fields" — mirroring the drift would +// have produced a wrong client. +// +// DIRECTION, and why this one. +// +// This guard is deliberately one-directional: it forbids the reference from +// documenting a variable that nothing reads. It does NOT require the converse +// (every variable read anywhere is documented). That choice is forced by what +// the source actually looks like, not by convenience: +// +// 1. Env var names reach os.Getenv through at least four indirections that no +// static extractor here can follow — package constants (EnvBucket, +// envOCICompartmentID), struct fields resolved from config at runtime +// (gw.APIKeyEnv, c.APIKeyEnv), injected getenv func(string) string +// parameters (src/cmd/apiproxy/main.go), and local helpers that wrap the +// lookup (getEnvOrDefault in src/pkg/hub/oci_fss.go). An AST pass over +// call sites resolves ~105 names; grepping every ALLCAPS string literal in +// src/ yields ~311 candidates, most of which are not env vars at all. +// There is no rule separating the two sets that does not itself need +// hand-maintenance — which is the very failure mode being fixed. +// 2. So a "must be documented" guard would open red against a set of names it +// cannot enumerate correctly, and would demand a large permanent exception +// list of things that merely LOOK like env vars. Per #5384's reasoning, a +// guard that cannot be made green gets skipped or deleted, and then it +// protects nothing. +// +// The direction kept is the one that is both decidable and the one that +// actually bit in #5077: a reference entry that corresponds to nothing real. +// Deciding it needs only "does this exact name appear anywhere in the +// implementation?", which is exact, cheap, and has no false positives that +// require excusing. +// +// The uncovered direction — a newly added env var silently going undocumented — +// is left to the "Keeping this reference current" section of env-vars.md and to +// review. This is a real, acknowledged gap, not an oversight. +func TestEnvVarsDocDocumentsOnlyRealVariables(t *testing.T) { + documented, proposed := documentedEnvVars(t) + if len(documented) == 0 { + t.Fatalf("%s yielded no documented variable rows; the table format changed and this "+ + "guard is no longer reading it — fix the parser, do not delete the test", + envVarsDocPath) + } + + corpus := envVarNameCorpus(t) + if len(corpus) == 0 { + t.Fatalf("scanned %v and found no identifier tokens at all; the scan roots are wrong "+ + "and this guard would vacuously pass", envVarsDocScanRoots) + } + + var problems []string + for _, name := range documented { + if corpus[name] { + continue + } + if _, ok := proposed[name]; ok { + // A row explicitly marked "proposed"/"not implemented" is an + // honest reservation of a name, not a claim that the code reads + // it. Documenting a name as unimplemented is the opposite of the + // #5077 defect, so it is allowed — but only when the row says so + // in the Required column, which is why this is derived from the + // table rather than from a hand-kept list that could go stale. + continue + } + problems = append(problems, name+" is documented in "+envVarsDocPath+ + " but the name appears nowhere in the implementation "+ + "(no Go source, entrypoint/helper shell, Justfile, or config template reads it) — "+ + "remove the row, correct a typo in the name, or, if the variable is planned but "+ + "not yet wired, mark its Required column \"proposed\" the way the Credly rows do") + } + + // A row marked "proposed" that the code now DOES read is stale bookkeeping + // pointing operators at a variable the reference still calls unimplemented. + // Failing on it mirrors how openapi_route_parity_test.go fails on stale + // exceptions rather than letting them rot into cover for real drift. + for name := range proposed { + if corpus[name] { + problems = append(problems, name+" is marked \"proposed\"/not-implemented in "+ + envVarsDocPath+" but the implementation now references it — the feature shipped; "+ + "move the row into the section for the component that reads it and give it a "+ + "real Required/Default") + } + } + + if len(problems) > 0 { + sort.Strings(problems) + t.Fatalf("%s has drifted from the implementation (%d documented, %d marked proposed, "+ + "%d problem(s)):\n %s", + envVarsDocPath, len(documented), len(proposed), len(problems), + strings.Join(problems, "\n ")) + } +} + +// envVarDocRowPattern matches a markdown table row whose first cell is a +// backticked ALLCAPS identifier — the shape every variable row in env-vars.md +// uses. Prose mentions of a variable elsewhere in the file are deliberately not +// matched: only a table row is a claim that the variable exists. +var envVarDocRowPattern = regexp.MustCompile("^\\|\\s*`([A-Z][A-Z0-9_]{2,})`\\s*\\|(.*)$") + +// proposedMarker identifies the Required-column text env-vars.md uses for a +// name that is reserved but not yet implemented. +const proposedMarker = "proposed" + +// documentedEnvVars returns every variable named in a table row of +// env-vars.md, plus the subset whose Required column marks them as proposed +// (mapped to the raw Required cell, for failure messages). +func documentedEnvVars(t *testing.T) (all []string, proposed map[string]string) { + t.Helper() + + raw, err := os.ReadFile(envVarsDocPath) + if err != nil { + t.Fatalf("reading %s: %v", envVarsDocPath, err) + } + proposed = map[string]string{} + seen := map[string]bool{} + for _, line := range strings.Split(string(raw), "\n") { + m := envVarDocRowPattern.FindStringSubmatch(strings.TrimSpace(line)) + if m == nil { + continue + } + name := m[1] + if !seen[name] { + seen[name] = true + all = append(all, name) + } + // m[2] is the remainder of the row; its first cell is Required. + required, _, _ := strings.Cut(m[2], "|") + if strings.Contains(strings.ToLower(required), proposedMarker) { + proposed[name] = strings.TrimSpace(required) + } + } + sort.Strings(all) + return all, proposed +} + +// envVarNameCorpus returns the set of ALLCAPS identifier tokens appearing +// anywhere in the implementation trees. Membership is the test's definition of +// "this variable is real": it is intentionally permissive, because a false +// "real" only weakens the guard for one name, while a false "fictional" would +// fail a PR over a lookup this scanner could not follow. Markdown is excluded +// so documentation cannot vouch for itself. +func envVarNameCorpus(t *testing.T) map[string]bool { + t.Helper() + + tokenPattern := regexp.MustCompile(`[A-Z][A-Z0-9_]{2,}`) + corpus := map[string]bool{} + for _, root := range envVarsDocScanRoots { + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + // A missing optional root (e.g. a trimmed checkout) must not + // silently shrink the corpus into false failures. + if os.IsNotExist(err) { + return nil + } + return err + } + if info.IsDir() { + if info.Name() == ".git" || info.Name() == "node_modules" { + return filepath.SkipDir + } + return nil + } + if strings.EqualFold(filepath.Ext(path), ".md") { + return nil + } + if info.Size() > maxEnvCorpusFileBytes { + return nil + } + data, readErr := os.ReadFile(path) + if readErr != nil { + return nil + } + for _, tok := range tokenPattern.FindAllString(string(data), -1) { + corpus[tok] = true + } + return nil + }) + if err != nil { + t.Fatalf("scanning %s for env var references: %v", root, err) + } + } + return corpus +} + +// maxEnvCorpusFileBytes skips vendored blobs and build artifacts that would +// slow the scan without containing env var lookups. It is generous enough to +// cover every hand-written source file in the repo, including +// src/cmd/hive/main.go. +const maxEnvCorpusFileBytes = 4 << 20 // 4 MiB diff --git a/src/pkg/config/gateway_nginx_websocket_test.go b/src/pkg/config/gateway_nginx_websocket_test.go new file mode 100644 index 000000000..667dcd6bc --- /dev/null +++ b/src/pkg/config/gateway_nginx_websocket_test.go @@ -0,0 +1,193 @@ +package config + +import ( + "strings" + "testing" +) + +type nginxBlock struct { + header string + start, end int + parent int +} + +// parseNginxBlocks builds the small portion of nginx's block structure needed +// by these tests. nginx.conf keeps block delimiters on their own lines (or at +// the end of a block header), so quoted response bodies containing braces do +// not look like syntax here. +func parseNginxBlocks(t *testing.T, conf string) ([]string, []nginxBlock) { + t.Helper() + lines := strings.Split(conf, "\n") + blocks := make([]nginxBlock, 0) + stack := make([]int, 0) + + for lineNumber, raw := range lines { + line := strings.TrimSpace(strings.SplitN(raw, "#", 2)[0]) + if line == "}" { + if len(stack) == 0 { + t.Fatalf("nginx.conf has an unmatched closing brace on line %d", lineNumber+1) + } + blocks[stack[len(stack)-1]].end = lineNumber + stack = stack[:len(stack)-1] + continue + } + if !strings.HasSuffix(line, "{") { + continue + } + + parent := -1 + if len(stack) > 0 { + parent = stack[len(stack)-1] + } + blocks = append(blocks, nginxBlock{ + header: strings.TrimSpace(strings.TrimSuffix(line, "{")), + start: lineNumber, + end: -1, + parent: parent, + }) + stack = append(stack, len(blocks)-1) + } + if len(stack) != 0 { + t.Fatalf("nginx.conf has %d unclosed block(s)", len(stack)) + } + return lines, blocks +} + +func childBlocks(blocks []nginxBlock, parent int) []int { + var children []int + for i := range blocks { + if blocks[i].parent == parent { + children = append(children, i) + } + } + return children +} + +// directDirectives returns directives declared directly in a block, excluding +// nested blocks. That distinction matters because proxy_set_header directives +// are inherited only when the current block declares none of its own. +func directDirectives(lines []string, blocks []nginxBlock, block int, name string) [][]string { + children := childBlocks(blocks, block) + var directives [][]string + for lineNumber := blocks[block].start + 1; lineNumber < blocks[block].end; lineNumber++ { + skipped := false + for _, child := range children { + if lineNumber >= blocks[child].start && lineNumber <= blocks[child].end { + lineNumber = blocks[child].end + skipped = true + break + } + } + if skipped { + continue + } + line := strings.TrimSpace(strings.SplitN(lines[lineNumber], "#", 2)[0]) + fields := strings.Fields(strings.TrimSuffix(line, ";")) + if len(fields) > 0 && fields[0] == name { + directives = append(directives, fields) + } + } + return directives +} + +// selectedLocation models the exact-match-then-longest-prefix rule nginx uses +// for the location forms present in this config. +func selectedLocation(t *testing.T, blocks []nginxBlock, parent int, path string) int { + t.Helper() + best, bestLength := -1, -1 + for _, child := range childBlocks(blocks, parent) { + fields := strings.Fields(blocks[child].header) + if len(fields) < 2 || fields[0] != "location" || strings.HasPrefix(fields[1], "@") { + continue + } + if fields[1] == "=" { + if len(fields) != 3 { + t.Fatalf("cannot parse exact location header %q", blocks[child].header) + } + if fields[2] == path { + return child + } + continue + } + if len(fields) != 2 || strings.HasPrefix(fields[1], "~") || fields[1] == "^~" { + t.Fatalf("test location selector does not support header %q", blocks[child].header) + } + if strings.HasPrefix(path, fields[1]) && len(fields[1]) > bestLength { + best, bestLength = child, len(fields[1]) + } + } + if best == -1 { + return -1 + } + if nested := selectedLocation(t, blocks, best, path); nested != -1 { + return nested + } + return best +} + +func TestGatewayContributorWebSocketUpgrade(t *testing.T) { + lines, blocks := parseNginxBlocks(t, readNginxConf(t)) + + server := -1 + for i := range blocks { + if blocks[i].header == "server" { + server = i + break + } + } + if server == -1 { + t.Fatal("nginx.conf has no server block") + } + + selected := selectedLocation(t, blocks, server, "/api/contribute/ws") + if selected == -1 { + t.Fatal("nginx.conf has no location matching /api/contribute/ws") + } + + // Walk from the server to the selected location, replacing rather than + // merging each declared proxy_set_header set to match nginx inheritance. + var chain []int + for block := selected; block != -1; block = blocks[block].parent { + chain = append(chain, block) + if block == server { + break + } + } + headers := make(map[string]string) + for i := len(chain) - 1; i >= 0; i-- { + directives := directDirectives(lines, blocks, chain[i], "proxy_set_header") + if len(directives) == 0 { + continue + } + headers = make(map[string]string) + for _, directive := range directives { + if len(directive) == 3 { + headers[strings.ToLower(directive[1])] = directive[2] + } + } + } + + for name, want := range map[string]string{ + "upgrade": "$http_upgrade", + "connection": "$connection_upgrade", + } { + if got := headers[name]; got != want { + t.Errorf("%s selects %q with effective %s header %q, want %q", "/api/contribute/ws", blocks[selected].header, name, got, want) + } + } + + mapFound := false + for i := range blocks { + if blocks[i].header != "map $http_upgrade $connection_upgrade" { + continue + } + mapFound = true + defaults := directDirectives(lines, blocks, i, "default") + if len(defaults) != 1 || len(defaults[0]) != 2 || defaults[0][1] != "upgrade" { + t.Error("map $http_upgrade $connection_upgrade must default to upgrade") + } + } + if !mapFound { + t.Fatal("nginx.conf is missing map $http_upgrade $connection_upgrade") + } +} diff --git a/src/pkg/config/layers_test.go b/src/pkg/config/layers_test.go index eee46985f..9ec289bbf 100644 --- a/src/pkg/config/layers_test.go +++ b/src/pkg/config/layers_test.go @@ -1,6 +1,7 @@ package config import ( + "path/filepath" "strings" "testing" ) @@ -271,8 +272,14 @@ func TestSeedIsWritableByNobody(t *testing.T) { // looking for something that does not exist while hiding the file that // actually decides the value. func TestSeedIsWritableOutsideKubernetes(t *testing.T) { - // No KUBERNETES_SERVICE_HOST and (on any normal test host) no - // serviceaccount token — IsKubernetesPod() reads false. + // Force the non-Kubernetes branch explicitly: clear the env probe and + // point the serviceaccount-token probe at a path that does not exist. + // Relying on the host "not looking like a pod" made this test fail on + // in-cluster CI runners and dev hives, where KUBERNETES_SERVICE_HOST is + // set and the real token file exists. + t.Setenv("KUBERNETES_SERVICE_HOST", "") + restore := SetSATokenFileForTest(filepath.Join(t.TempDir(), "no-such-sa-token")) + defer restore() if !LayerSeed.Writable() { t.Error("LayerSeed.Writable() = false outside Kubernetes, want true — RuntimeConfigFile is writable by the spoke") } diff --git a/src/pkg/config/litellm_test.go b/src/pkg/config/litellm_test.go index 210506555..fb03e4aa9 100644 --- a/src/pkg/config/litellm_test.go +++ b/src/pkg/config/litellm_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "slices" "testing" ) @@ -115,12 +116,38 @@ governor: // config.IsInferenceBackend — canonical list // --------------------------------------------------------------------------- +// The want-set is written out literally rather than ranged over from +// InferenceBackends. Ranging over the very slice IsInferenceBackend linearly +// scans is tautological: every element is a member by construction, so the +// scan always hits on that element and NO edit to the list can fail the test. +// Verified (#5388) by replacing the list with +// []string{"vllm", "totally-bogus-backend"} — dropping litellm and watsonx and +// inventing a backend that does not exist — and watching the loop still pass. +// An independent literal is what makes a wrong list a red test. +// +// Adding a real inference backend means adding it in BOTH places, which is the +// point: the second edit is a deliberate confirmation, not a formality. func TestIsInferenceBackend_Config(t *testing.T) { - for _, b := range InferenceBackends { + want := []string{"vllm", "llm-d", "litellm", "watsonx"} + + for _, b := range want { if !IsInferenceBackend(b) { t.Errorf("IsInferenceBackend(%q) = false, want true", b) } } + + // The converse: nothing may quietly JOIN the canonical list either. Without + // this, adding a backend to InferenceBackends alone still passes. + if len(InferenceBackends) != len(want) { + t.Errorf("InferenceBackends = %v (%d entries), want %v (%d). A backend was added or removed without updating this test's want-set.", + InferenceBackends, len(InferenceBackends), want, len(want)) + } + for _, b := range InferenceBackends { + if !slices.Contains(want, b) { + t.Errorf("InferenceBackends contains %q, which this test does not expect. Add it to want above if it is genuinely an inference backend.", b) + } + } + if IsInferenceBackend("claude") { t.Error("IsInferenceBackend(claude) = true, want false") } diff --git a/src/pkg/config/threshold_provenance_test.go b/src/pkg/config/threshold_provenance_test.go index da71660f5..025ed63da 100644 --- a/src/pkg/config/threshold_provenance_test.go +++ b/src/pkg/config/threshold_provenance_test.go @@ -179,13 +179,13 @@ func TestWholeSetProvenanceCannotInvertTheModeLadder(t *testing.T) { surge := afterOperatorEdit.EffectiveThreshold("surge", testRepos) busy := afterOperatorEdit.EffectiveThreshold("busy", testRepos) quiet := afterOperatorEdit.EffectiveThreshold("quiet", testRepos) - if !(surge >= busy && busy >= quiet) { + if surge < busy || busy < quiet { t.Errorf("mode ladder inverted: surge=%d busy=%d quiet=%d", surge, busy, quiet) } // And the pack-seeded set scales as a set, which also cannot invert. p := packSeeded(l4Modes()) surge, busy, quiet = p.EffectiveThreshold("surge", testRepos), p.EffectiveThreshold("busy", testRepos), p.EffectiveThreshold("quiet", testRepos) - if !(surge >= busy && busy >= quiet) { + if surge < busy || busy < quiet { t.Errorf("scaled ladder inverted: surge=%d busy=%d quiet=%d", surge, busy, quiet) } } diff --git a/src/pkg/config/variables_seed_only_test.go b/src/pkg/config/variables_seed_only_test.go index 92656206f..9ea4f36e7 100644 --- a/src/pkg/config/variables_seed_only_test.go +++ b/src/pkg/config/variables_seed_only_test.go @@ -1,6 +1,7 @@ package config import ( + "context" "os" "path/filepath" "testing" @@ -76,7 +77,7 @@ variables: // And a template built from this config must leave ${PWNED} literal (the // resolver was never enabled or defined). reg := cfg.ResolveRegistry(nil) - if out := reg.Expand(nil, "x=${PWNED}", "template", nil); out != "x=${PWNED}" { + if out := reg.Expand(context.TODO(), "x=${PWNED}", "template", nil); out != "x=${PWNED}" { t.Errorf("overlay script var must not resolve, got %q", out) } } @@ -116,7 +117,7 @@ variables: t.Fatal("seed should enable allow_exec") } reg := cfg.ResolveRegistry(nil) - if out := reg.Expand(nil, "${GREET}", "template", nil); out != "hi-from-seed" { + if out := reg.Expand(context.TODO(), "${GREET}", "template", nil); out != "hi-from-seed" { t.Errorf("seed script var should resolve, got %q", out) } } diff --git a/src/pkg/config/watcher.go b/src/pkg/config/watcher.go index 8d5ec3ed9..cbd96493e 100644 --- a/src/pkg/config/watcher.go +++ b/src/pkg/config/watcher.go @@ -57,7 +57,7 @@ func (w *Watcher) Start(ctx context.Context) { w.logger.Error("failed to create fsnotify watcher", "error", err) return } - defer fsw.Close() + defer func() { _ = fsw.Close() }() // best-effort; process/goroutine is tearing down either way if err := fsw.Add(w.path); err != nil { w.logger.Error("failed to watch config file", "path", w.path, "error", err) diff --git a/src/pkg/convergence/mutation/ledger_crossprocess_test.go b/src/pkg/convergence/mutation/ledger_crossprocess_test.go new file mode 100644 index 000000000..dd1831d3d --- /dev/null +++ b/src/pkg/convergence/mutation/ledger_crossprocess_test.go @@ -0,0 +1,109 @@ +package mutation + +import ( + "errors" + "path/filepath" + "testing" + "time" +) + +// These are CHARACTERIZATION tests for the step-3 handoff evaluation of RFC +// #4002 (src/docs/design/agent-turn-handoff.md). The Ledger's CAS is documented +// as serialized by "the ledger mutex" (ledger.go:81) — a sync.Mutex, which is +// per-process. The package is inert today (nothing outside it calls OpenLedger), +// so this is not a live defect; it is the property a cross-process handoff would +// have to close before reusing this ledger as its lease. +// +// Nothing here asserts the current behaviour is desirable. If a later change +// adds cross-process serialization these tests skip with a rewrite instruction +// rather than failing opaquely. + +// TestTwoOpenLedgersBothAcquireTheSameClaim pins the gap. Each handle keeps its +// own in-memory index and its own mutex, so the overlap scan in Acquire consults +// only what THAT handle has seen. Two processes holding the ledger open across +// an acquisition therefore both win — at the same epoch, which is what makes it +// a fencing failure rather than merely a duplicate grant. +func TestTwoOpenLedgersBothAcquireTheSameClaim(t *testing.T) { + path := filepath.Join(t.TempDir(), "claims.json") + claim := TaskClaim("kubestellar/hive", "kubestellar/hive#4002") + now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC) + + // Both handles are opened BEFORE either acquires — the handoff shape, where + // the replacement is already running when the incumbent still holds. + first, err := OpenLedger(path, DefaultMaxWritersPerRepo) + if err != nil { + t.Fatalf("opening first ledger: %v", err) + } + second, err := OpenLedger(path, DefaultMaxWritersPerRepo) + if err != nil { + t.Fatalf("opening second ledger: %v", err) + } + + a, err := first.Acquire(claim, "spoke-a", time.Hour, now) + if err != nil { + t.Fatalf("first acquire: %v", err) + } + b, err := second.Acquire(claim, "spoke-b", time.Hour, now) + if err != nil { + if errors.Is(err, ErrClaimHeld) { + t.Skipf("second acquire was refused (%v) — the ledger has gained "+ + "cross-process serialization; rewrite this test as that guarantee", err) + } + t.Fatalf("second acquire: %v", err) + } + + if a.Epoch != b.Epoch { + t.Fatalf("epochs %d and %d differ — the second handle saw the first's "+ + "entry, so the step-3 evaluation's premise needs revisiting", a.Epoch, b.Epoch) + } + // Two holders at one epoch: ValidateEpoch cannot tell them apart, so the + // fence authorizes both. + if err := first.ValidateEpoch(claim.Key(), a.Epoch, now); err != nil { + t.Fatalf("first holder fenced out at its own epoch: %v", err) + } + if err := second.ValidateEpoch(claim.Key(), b.Epoch, now); err != nil { + t.Fatalf("second holder fenced out at its own epoch: %v", err) + } +} + +// TestReopeningAfterAConcurrentAcquireSeesOnlyTheLastWriter pins the durable +// consequence: persistLocked rewrites the whole file from one handle's index, +// so the loser's record is not merged, it is erased. A third process booting +// afterwards — the actual restart-recovery path — reads a ledger that never +// mentions the first holder. +func TestReopeningAfterAConcurrentAcquireSeesOnlyTheLastWriter(t *testing.T) { + path := filepath.Join(t.TempDir(), "claims.json") + other := TaskClaim("kubestellar/hive", "kubestellar/hive#4000") + claim := TaskClaim("kubestellar/hive", "kubestellar/hive#4002") + now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC) + + first, err := OpenLedger(path, 8) + if err != nil { + t.Fatalf("opening first ledger: %v", err) + } + second, err := OpenLedger(path, 8) + if err != nil { + t.Fatalf("opening second ledger: %v", err) + } + + // Distinct, non-overlapping claims: even with no contention at all, the + // two handles cannot both survive a rewrite. + if _, err := first.Acquire(other, "spoke-a", time.Hour, now); err != nil { + t.Fatalf("first acquire: %v", err) + } + if _, err := second.Acquire(claim, "spoke-b", time.Hour, now); err != nil { + t.Fatalf("second acquire: %v", err) + } + + recovered, err := OpenLedger(path, 8) + if err != nil { + t.Fatalf("reopening ledger: %v", err) + } + if _, ok := recovered.Get(claim.Key()); !ok { + t.Fatalf("last writer's claim %s is missing from the reloaded ledger", claim.Key()) + } + if _, ok := recovered.Get(other.Key()); ok { + t.Skipf("both claims survived — the ledger now merges concurrent writers; " + + "rewrite this test as that guarantee") + } +} diff --git a/src/pkg/dashboard/advisory_overflow_test.go b/src/pkg/dashboard/advisory_overflow_test.go new file mode 100644 index 000000000..66eb37cd9 --- /dev/null +++ b/src/pkg/dashboard/advisory_overflow_test.go @@ -0,0 +1,47 @@ +package dashboard + +import "testing" + +// A fresh server has never posted a capped digest: both counts are zero, so a +// hive that never caps reports 0 overflow, exactly as the contract promises. +func TestAdvisoryCounts_ZeroBeforeAnyPost(t *testing.T) { + s := newAdvisoryTestServer() + findings, overflow := s.AdvisoryCounts() + if findings != 0 || overflow != 0 { + t.Fatalf("expected (0,0) before any post, got (%d,%d)", findings, overflow) + } +} + +// RecordAdvisoryOverflow stores the withheld count alongside the finding count +// recorded by RecordAdvisoryPost, and AdvisoryCounts returns both. +func TestRecordAdvisoryOverflow_ReportedAlongsideFindings(t *testing.T) { + s := newAdvisoryTestServer() + s.RecordAdvisoryPost(5) + s.RecordAdvisoryOverflow(3) + + findings, overflow := s.AdvisoryCounts() + if findings != 5 { + t.Fatalf("expected 5 findings, got %d", findings) + } + if overflow != 3 { + t.Fatalf("expected 3 overflow, got %d", overflow) + } +} + +// RecordAdvisoryOverflow is separate from RecordAdvisoryPost: overwriting the +// overflow must not disturb the recorded finding count, and an uncapped post +// can reset overflow back to 0. +func TestRecordAdvisoryOverflow_IndependentOfPostCount(t *testing.T) { + s := newAdvisoryTestServer() + s.RecordAdvisoryPost(9) + s.RecordAdvisoryOverflow(4) + s.RecordAdvisoryOverflow(0) // next digest was not capped + + findings, overflow := s.AdvisoryCounts() + if findings != 9 { + t.Fatalf("overflow update must not change findings: got %d, want 9", findings) + } + if overflow != 0 { + t.Fatalf("expected overflow reset to 0, got %d", overflow) + } +} diff --git a/src/pkg/dashboard/api.go b/src/pkg/dashboard/api.go index 95965f1e4..746aceb8c 100644 --- a/src/pkg/dashboard/api.go +++ b/src/pkg/dashboard/api.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/json" + "errors" "fmt" "html" "io" @@ -19,6 +20,7 @@ import ( "strings" "sync" "time" + "unicode" "github.com/kubestellar/hive/pkg/agent" "github.com/kubestellar/hive/pkg/beads" @@ -72,6 +74,10 @@ func (s *Server) RegisterAPI(deps *Dependencies) { // Full retained scrollback of an agent's latest run, as plain text (#3693). // Backs the Terminal's "view / download full log" controls. s.mux.HandleFunc("GET /api/agents/{name}/log", s.handleAgentFullLog) + // URLs visible in the agent's pane, joined across terminal wrapping, for + // the dashboard's click-to-copy control (#5188). The terminal itself + // cannot deliver a copy, so the copy is done server-side. + s.mux.HandleFunc("GET /api/agents/{name}/terminal-urls", s.handleAgentTerminalURLs) // Durable per-kick run-log history (#4296, #4295): list archived kick // logs, fetch one, and a minimal HTML index page linked from agent cards. s.mux.HandleFunc("GET /api/agents/{name}/kicks", s.handleAgentKickLogList) @@ -81,6 +87,10 @@ func (s *Server) RegisterAPI(deps *Dependencies) { s.mux.HandleFunc("GET /api/role", s.handleRole) s.mux.HandleFunc("POST /api/kick/{agent}", s.handleKick) + // Outcome of the most recent asynchronous kick (#5325). The POST answers + // 202 as soon as the kick is queued; delivery success or failure is read + // from here, off the request path and therefore never proxy-timed-out. + s.mux.HandleFunc("GET /api/kick/{agent}/status", s.handleKickStatus) s.mux.HandleFunc("POST /api/switch/{agent}/{backend}", s.handleSwitch) s.mux.HandleFunc("POST /api/model/{agent}/{model}", s.handleModelSet) s.mux.HandleFunc("POST /api/pause/{agent}", s.handlePause) @@ -407,6 +417,22 @@ func jsonError(w http.ResponseWriter, msg string, code int) { } } +// jsonStatusResponse writes a JSON body under an explicit status code. +// +// The Content-Type MUST be set before WriteHeader — writing the status first +// freezes the header map, and a JSON body served without its content type is +// exactly what the dashboard's postJSON guard (#5301/#5306) treats as an +// intermediary's HTML error page. Getting this backwards on the kick endpoint +// would turn a healthy 202 into a reported failure, which is the whole class +// of bug #5325 is about. +func jsonStatusResponse(w http.ResponseWriter, code int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + if err := json.NewEncoder(w).Encode(data); err != nil { + slog.Warn("jsonStatusResponse encode failed", "error", err) + } +} + func okResponse(w http.ResponseWriter, extra map[string]string) { result := map[string]interface{}{"ok": true} for k, v := range extra { @@ -507,7 +533,7 @@ func (s *Server) refreshAsync() { const maxDecodeBodyBytes = 1 << 20 // 1 MiB func decodeBody(r *http.Request, v interface{}) error { - defer r.Body.Close() + defer closeHTTPBody(r.Body) r.Body = http.MaxBytesReader(nil, r.Body, maxDecodeBodyBytes) return json.NewDecoder(r.Body).Decode(v) } @@ -575,13 +601,30 @@ func (s *Server) handleRole(w http.ResponseWriter, r *http.Request) { if role == "" { role = "owner" } - jsonResponse(w, map[string]string{ + resp := map[string]string{ "role": role, "user": user, // The queue label is server-configured, so the dashboard must be told // it rather than hard-coding a name that a hive may have changed. "automerge_label": s.autoMergeLabel(), - }) + } + // display_name is the human name for an opaque OIDC identity key + // ("ibmid:5500…"), delivered by the hub heartbeat (AuthorizedUserNames). + // Purely cosmetic — the header chip shows it; every auth decision stays on + // the raw user key. Absent when unknown; the UI falls back to the key. + if dn := s.authorizedDisplayName(user); dn != "" && dn != user { + resp["display_name"] = dn + } + jsonResponse(w, resp) +} + +// authorizedDisplayName looks up the hub-delivered cosmetic display name for +// an identity key. Nil-safe: /api/role is served before deps are required. +func (s *Server) authorizedDisplayName(user string) string { + if s == nil || s.deps == nil || s.deps.Config == nil || user == "" { + return "" + } + return strings.TrimSpace(s.deps.Config.Dashboard.AuthorizedUserNames[user]) } // autoMergeLabel reports the configured queue label. /api/role is served @@ -804,7 +847,7 @@ func ghcrTagExists(tag string) bool { if err != nil { return false } - defer tokenResp.Body.Close() + defer closeHTTPBody(tokenResp.Body) var tok struct { Token string `json:"token"` } @@ -820,7 +863,7 @@ func ghcrTagExists(tag string) bool { if err != nil { return false } - resp.Body.Close() + closeHTTPBody(resp.Body) return resp.StatusCode == http.StatusOK } @@ -900,7 +943,7 @@ func (s *Server) handleConfigDownload(w http.ResponseWriter, r *http.Request) { filename := fmt.Sprintf("hive-%s-%s-%s.yaml", safeOrg, safeRepo, timestamp) w.Header().Set("Content-Type", "application/x-yaml") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) - w.Write(data) + _, _ = w.Write(data) } func (s *Server) handleSelfUpgrade(w http.ResponseWriter, r *http.Request) { @@ -971,7 +1014,7 @@ func (s *Server) handleSelfUpgrade(w http.ResponseWriter, r *http.Request) { jsonError(w, "hub unreachable", http.StatusBadGateway) return } - defer resp.Body.Close() + defer closeHTTPBody(resp.Body) const maxUpgradeResponseBytes = 1 << 16 body, _ := io.ReadAll(io.LimitReader(resp.Body, maxUpgradeResponseBytes)) if resp.StatusCode < 300 { @@ -979,14 +1022,14 @@ func (s *Server) handleSelfUpgrade(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") w.WriteHeader(resp.StatusCode) - w.Write(body) + _, _ = w.Write(body) } func (s *Server) handleSnapshotAPI(w http.ResponseWriter, r *http.Request) { if s.deps == nil || s.deps.Config == nil || !s.deps.Config.Hub.AutoSnapshot { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) - w.Write([]byte(`{"error":"snapshots not enabled"}`)) + _, _ = w.Write([]byte(`{"error":"snapshots not enabled"}`)) return } s.statusMu.RLock() @@ -998,7 +1041,7 @@ func (s *Server) handleSnapshotAPI(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=60") - json.NewEncoder(w).Encode(status) + jsonResponse(w, status) } func (s *Server) handleSnapshotFrameAncestors(w http.ResponseWriter, r *http.Request) { @@ -1018,7 +1061,7 @@ func (s *Server) handleSnapshotPage(w http.ResponseWriter, r *http.Request) { cfg := s.deps.Config if s.deps == nil || cfg == nil || !cfg.Hub.AutoSnapshot { w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, `Hive + _, _ = fmt.Fprintf(w, `Hive
🐝

Hive

AI Agent Orchestration for Open Source

Snapshot is not currently published for this hive.

Redirecting to %s...

`, @@ -1033,7 +1076,8 @@ func (s *Server) handleSnapshotPage(w http.ResponseWriter, r *http.Request) { if mode != "dark" { mode = "light" } - snapshotFile := fmt.Sprintf("/data/snapshots/snapshot-%s.html", mode) + snapDir := s.snapshotDirOrDefault() + snapshotFile := filepath.Join(snapDir, fmt.Sprintf("snapshot-%s.html", mode)) info, err := os.Stat(snapshotFile) intervalMin := cfg.Hub.SnapshotIntervalMin if intervalMin < 5 { @@ -1043,8 +1087,8 @@ func (s *Server) handleSnapshotPage(w http.ResponseWriter, r *http.Request) { needsRebuild := err != nil || time.Since(info.ModTime()) > staleThreshold if needsRebuild { - s.buildSnapshot("/data/snapshots/snapshot-dark.html", "dark") - s.buildSnapshot("/data/snapshots/snapshot-light.html", "light") + s.buildSnapshot(filepath.Join(snapDir, "snapshot-dark.html"), "dark") + s.buildSnapshot(filepath.Join(snapDir, "snapshot-light.html"), "light") } data, err := os.ReadFile(snapshotFile) @@ -1053,15 +1097,15 @@ func (s *Server) handleSnapshotPage(w http.ResponseWriter, r *http.Request) { return } - data = []byte(strings.Replace(string(data), + data = []byte(strings.ReplaceAll(string(data), `href="/live/hive/light"`, - `href="/snapshot?mode=light"`, -1)) - data = []byte(strings.Replace(string(data), + `href="/snapshot?mode=light"`)) + data = []byte(strings.ReplaceAll(string(data), `href="/live/hive/dark"`, - `href="/snapshot?mode=dark"`, -1)) - data = []byte(strings.Replace(string(data), + `href="/snapshot?mode=dark"`)) + data = []byte(strings.ReplaceAll(string(data), `href="/live/hive"`, - `href="/snapshot"`, -1)) + `href="/snapshot"`)) html := string(data) dashURL := "" @@ -1084,11 +1128,39 @@ func (s *Server) handleSnapshotPage(w http.ResponseWriter, r *http.Request) { // the CSP script-src-elem hash allowlist from the exact bytes being served // (#3848 part 1 / #3907, see csp_script_src.go). applyDocumentScriptSrcElem(w, []byte(html)) - w.Write([]byte(html)) + _, _ = w.Write([]byte(html)) +} + +// snapshotDirOrDefault returns the directory handleSnapshotPage/buildSnapshot +// read and write snapshot-{mode}.html under: s.snapshotDir when a test has +// overridden it, otherwise the production default. See the field comment on +// Server.snapshotDir (#5235). +func (s *Server) snapshotDirOrDefault() string { + if s.snapshotDir != "" { + return s.snapshotDir + } + return "/data/snapshots" } func (s *Server) buildSnapshot(outputFile, mode string) { - os.MkdirAll("/data/snapshots", 0o755) + if s.buildSnapshotFn != nil { + s.buildSnapshotFn(s, outputFile, mode) + return + } + buildSnapshotProd(s, outputFile, mode) +} + +// buildSnapshotProd is the real Node-builder invocation buildSnapshot runs in +// production. Split out from buildSnapshot so tests can override the whole +// invocation via Server.buildSnapshotFn (#5235) without ever spawning `node` +// — see the field comment on Server.buildSnapshotFn for the seam convention +// this follows (pkg/hub's afterGenerationsReadAttempt, #5080). +func buildSnapshotProd(s *Server, outputFile, mode string) { + snapDir := s.snapshotDirOrDefault() + if err := os.MkdirAll(snapDir, 0o755); err != nil { + s.logger.Warn("snapshot directory creation failed", "error", err) + return + } dashURL := fmt.Sprintf("http://localhost:%d", s.port) htmlSource := "/opt/hive/proxy/public/index.html" builderScript := "/opt/hive/dashboard/build-snapshot.mjs" @@ -1463,16 +1535,110 @@ func (s *Server) handleKick(w http.ResponseWriter, r *http.Request) { msg = s.deps.Scheduler.BuildAgentMessageFromLastActionable(name) } - if err := s.deps.AgentMgr.SendKick(name, msg); err != nil { + // Queue the kick and answer immediately (#5325). + // + // The old code called the synchronous SendKick inline. Its slow leg waits + // for the CLI's input prompt for up to inputPromptTimeout (120s), which + // exceeds a typical ingress idle timeout (commonly 60s) — so a kick to an + // agent whose CLI was merely slow to present its prompt was answered by the + // proxy with 504 while the wait was still running. The wait then completed, + // the prompt WAS typed, and the agent ran the session; the operator had + // been told it failed, and the natural retry delivered the work twice. + // + // SendKickAsync keeps every fast, deterministic precondition synchronous — + // unknown agent, paused/stopped, missing tmux session, sandbox rejection + // still return 400 here — and moves only the prompt wait and the typing to + // a background goroutine with an exactly-once in-flight guard. The outcome + // is reported by GET /api/kick/{agent}/status, off the request path. + started, err := s.deps.AgentMgr.SendKickAsync(name, msg) + if err != nil { jsonError(w, err.Error(), http.StatusBadRequest) return } + if !started { + // A delivery for this agent is already in flight. Answering 202 with + // status "in-flight" is what makes an operator's retry harmless: the + // prompt is delivered exactly once regardless of how many times Kick + // is clicked. + jsonStatusResponse(w, http.StatusAccepted, map[string]interface{}{ + "ok": true, "status": kickStatusInFlight, "agent": name, + "message": "a kick is already being delivered to " + name + "; not sending it twice", + }) + return + } s.deps.Governor.RecordKick(name) s.deps.Logger.Info("audit: agent kicked", "agent", name, "trigger", "dashboard-api") s.auditFromRequest(r, "kick", "", name) s.refreshAfterMutation() - okResponse(w, map[string]string{"status": "kicked", "agent": name}) + // 202, not 200: the message is queued, not yet proven delivered. + jsonStatusResponse(w, http.StatusAccepted, map[string]interface{}{ + "ok": true, "status": kickStatusQueued, "agent": name, + }) +} + +// Kick dispatch statuses on the wire. "queued"/"in-flight" are the POST's +// answers; the poll adds the terminal "delivered" and "failed". +const ( + kickStatusQueued = "queued" + kickStatusInFlight = "in-flight" + kickStatusUnknown = "unknown" + kickStatusDelivered = "delivered" + kickStatusFailed = "failed" +) + +// handleKickStatus reports the outcome of the most recent asynchronous kick +// for an agent (#5325). +// +// This is where kick success or failure is now decided. The POST only promises +// the kick was queued; a client learns whether the prompt actually reached the +// CLI by polling here. While the phase is "queued"/"in-flight" the outcome is +// INDETERMINATE — pending is not failure, and a UI must not render it as one. +// +// Read-only, so any authenticated role may call it. +func (s *Server) handleKickStatus(w http.ResponseWriter, r *http.Request) { + name := s.resolveAgentParam(r.PathValue("agent")) + if s.deps == nil || s.deps.AgentMgr == nil { + jsonError(w, "agent manager unavailable", http.StatusServiceUnavailable) + return + } + d, ok := s.deps.AgentMgr.KickDispatchState(name) + if !ok { + // No async kick has been dispatched for this agent in this process's + // lifetime. That is not an error — it is simply "nothing to report". + jsonResponse(w, map[string]interface{}{ + "ok": true, "agent": name, "status": kickStatusUnknown, "pending": false, + }) + return + } + resp := map[string]interface{}{ + "ok": true, + "agent": name, + "status": kickPhaseStatus(d.Phase), + "pending": d.Pending(), + "queuedAt": d.QueuedAt.UTC().Format(time.RFC3339), + } + if d.Error != "" { + resp["error"] = d.Error + } + if !d.SettledAt.IsZero() { + resp["settledAt"] = d.SettledAt.UTC().Format(time.RFC3339) + } + jsonResponse(w, resp) +} + +// kickPhaseStatus maps a manager dispatch phase onto the wire status. The +// pending phase is reported as "in-flight" so the poll's vocabulary matches the +// POST's, and so no client can mistake it for a settled outcome. +func kickPhaseStatus(phase string) string { + switch phase { + case agent.KickPhaseDelivered: + return kickStatusDelivered + case agent.KickPhaseFailed: + return kickStatusFailed + default: + return kickStatusInFlight + } } // claimAgentFieldOwnership writes an operator's model and/or backend choice @@ -2408,7 +2574,7 @@ func writeSSOError(w http.ResponseWriter, r *http.Request, status int, code, wha // hive look permanently broken even after access is granted. w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) - fmt.Fprintf(w, ssoErrorPage, html.EscapeString(code), html.EscapeString(what), html.EscapeString(action)) + _, _ = fmt.Fprintf(w, ssoErrorPage, html.EscapeString(code), html.EscapeString(what), html.EscapeString(action)) } // ssoErrorPage is the terminal error shell for a failed SSO handoff. Format @@ -2463,7 +2629,11 @@ func (s *Server) handleGHUserAuthLogout(w http.ResponseWriter, r *http.Request) // logging-out owner's own token stranded on disk. Viewer logouts leave the // hive's user client intact. if !s.directRouteAuthzEnabled() || loggedOutRole == config.RoleOwner { - os.Remove(userTokenPath) + if err := os.Remove(userTokenPath); err != nil && !errors.Is(err, os.ErrNotExist) { + s.deps.Logger.Error("GitHub user token removal failed", "error", err) + jsonError(w, "failed to remove persisted GitHub credentials", http.StatusInternalServerError) + return + } } clearSessionCookie(w) s.auditFromRequest(r, "gh_auth_logout", "", "") @@ -2609,9 +2779,10 @@ func (s *Server) handleAgentConfigGet(w http.ResponseWriter, r *http.Request) { launchCmd := agentCfg.LaunchCmd if launchCmd == "" { launchCmd = fmt.Sprintf("%s --model %s", cli, model) - if cli == "claude" { + switch cli { + case "claude": launchCmd = fmt.Sprintf("claude --model %s --dangerously-skip-permissions", model) - } else if cli == "copilot" { + case "copilot": launchCmd = fmt.Sprintf("/usr/bin/copilot --allow-all --model %s", model) } } @@ -4070,7 +4241,7 @@ func (s *Server) handleAgentExport(w http.ResponseWriter, r *http.Request) { if strings.Contains(accept, "text/yaml") || strings.Contains(accept, "application/yaml") { w.Header().Set("Content-Type", "text/yaml; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.yaml", name)) - w.Write([]byte(yamlContent)) + _, _ = w.Write([]byte(yamlContent)) return } @@ -5250,15 +5421,15 @@ func probeModelsWithHeaders(endpoint, apiKey string, extraHeaders map[string]str if err != nil { return 0, fmt.Errorf("cannot reach gateway: %w", err) } - defer resp.Body.Close() + defer closeHTTPBody(resp.Body) if resp.StatusCode != http.StatusOK { // Error path: only a truncated slice of the body is surfaced to the // dialog (error bodies can be huge and may echo the key). body, _ := io.ReadAll(io.LimitReader(resp.Body, litellmProbeMaxErrBody)) gatewayMsg := redactLiteLLMKeyMaterial(strings.TrimSpace(string(body))) - switch { - case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden: + switch resp.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: // The two auth failures lead users to different fixes. if apiKey == "" { return 0, fmt.Errorf("gateway requires an API key and none is configured (HTTP %d): %s", @@ -6728,14 +6899,14 @@ func (s *Server) handleBackends(w http.ResponseWriter, r *http.Request) { litellmModels := s.queryInferenceModels("litellm") // CLI backends each have a DIFFERENT discovery source (see cli_models.go): - // copilot → per-account Copilot /models, gemini → generativelanguage - // /v1beta/models, claude → maintained static list (no API exists), goose → - // configured provider's static list. Every probe is best-effort and falls - // back to a current static list, so a dropdown is never empty. + // provider HTTP APIs, vendor CLI protocols/subcommands, or a deliberately + // authoritative single option. Every probe is best-effort and falls back to + // a current static list, so a dropdown is never empty. claudeCLI := s.queryCLIModels("claude") copilotCLI := s.queryCLIModels("copilot") geminiCLI := s.queryCLIModels("gemini") gooseCLI := s.queryCLIModels("goose") + agyCLI := s.queryCLIModels(agyBackendID) // bob has no discovery source and no usable --model flag: it selects its // own model. Served explicitly so the client never falls through to the // copilot catalog and offers models bob cannot honor (see bobStaticModels). @@ -6747,6 +6918,7 @@ func (s *Server) handleBackends(w http.ResponseWriter, r *http.Request) { {"id": bobBackendID, "name": "bob (IBM bobshell)", "models": bobCLI.models, "fallback": bobCLI.fallback}, {"id": "gemini", "name": "Gemini", "models": geminiCLI.models, "fallback": geminiCLI.fallback}, {"id": "goose", "name": "Goose", "models": gooseCLI.models, "fallback": gooseCLI.fallback}, + {"id": agyBackendID, "name": "Google Antigravity (agy)", "models": agyCLI.models, "fallback": agyCLI.fallback}, {"id": "vllm", "name": "vLLM (self-hosted)", "models": vllmModels, "inference": true}, {"id": "llm-d", "name": "llm-d (self-hosted)", "models": llmdModels, "inference": true}, {"id": "litellm", "name": "LiteLLM (proxy)", "models": litellmModels, "inference": true}, @@ -7112,7 +7284,7 @@ func fetchModelsWithHeaders(baseURL, apiKey string, extraHeaders map[string]stri if err != nil { return nil, fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() + defer closeHTTPBody(resp.Body) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("upstream returned %d", resp.StatusCode) @@ -7286,10 +7458,28 @@ func (s *Server) handleKnowledgeList(w http.ResponseWriter, r *http.Request) { }) } +// titleCaseWords capitalizes the first letter following each run of +// whitespace, replicating the exact word-boundary rule of the deprecated +// strings.Title (boundary = unicode.IsSpace, not "non-letter" — so +// underscore-joined identifiers like "test_scaffold" are left as +// "Test_scaffold", matching prior output byte-for-byte). It exists only as a +// dependency-free fallback for a fact type absent from typeLabels; +// golang.org/x/text/cases is not a module dependency here. +func titleCaseWords(s string) string { + prevSpace := true + return strings.Map(func(r rune) rune { + if prevSpace && unicode.IsLetter(r) { + r = unicode.ToTitle(r) + } + prevSpace = unicode.IsSpace(r) + return r + }, s) +} + func (s *Server) handleKnowledgeExport(w http.ResponseWriter, r *http.Request) { if !s.ensureKnowledge() { w.Header().Set("Content-Type", "text/markdown; charset=utf-8") - w.Write([]byte("# Agent Knowledge\n\nKnowledge base not available.\n")) + _, _ = w.Write([]byte("# Agent Knowledge\n\nKnowledge base not available.\n")) return } @@ -7343,7 +7533,7 @@ func (s *Server) handleKnowledgeExport(w http.ResponseWriter, r *http.Request) { sortFactsStable(ff) label := typeLabels[t] if label == "" { - label = strings.Title(t) + label = titleCaseWords(t) } sb.WriteString("## " + label + "\n\n") for _, f := range ff { @@ -7364,7 +7554,7 @@ func (s *Server) handleKnowledgeExport(w http.ResponseWriter, r *http.Request) { // tag when it genuinely changes. sum := sha256.Sum256([]byte(body)) w.Header().Set("ETag", fmt.Sprintf(`"%x"`, sum)) - w.Write([]byte(body)) + _, _ = w.Write([]byte(body)) } // sortFactsStable orders facts by their natural identifier so an unchanged @@ -8885,8 +9075,7 @@ func (s *Server) handleBeadsReset(w http.ResponseWriter, r *http.Request) { s.auditFromRequest(r, "beads_reset", "", "") s.refreshAndPersist() - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"status": "reset", "closed": results, "reason": body.Reason}) + jsonResponse(w, map[string]any{"status": "reset", "closed": results, "reason": body.Reason}) } func (s *Server) handleBeadsResetAgent(w http.ResponseWriter, r *http.Request) { @@ -8924,8 +9113,7 @@ func (s *Server) handleBeadsResetAgent(w http.ResponseWriter, r *http.Request) { s.auditFromRequest(r, "beads_reset_agent", "", agentName) s.refreshAndPersist() - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"status": "reset", "agent": agentName, "closed": closed, "reason": body.Reason}) + jsonResponse(w, map[string]any{"status": "reset", "agent": agentName, "closed": closed, "reason": body.Reason}) } func (s *Server) handleBeadsList(w http.ResponseWriter, r *http.Request) { diff --git a/src/pkg/dashboard/api_acmm_recommendation.go b/src/pkg/dashboard/api_acmm_recommendation.go index 0984bd496..5497aadf4 100644 --- a/src/pkg/dashboard/api_acmm_recommendation.go +++ b/src/pkg/dashboard/api_acmm_recommendation.go @@ -49,14 +49,14 @@ func (s *Server) buildACMMStatusInputs() acmmadvisor.StatusInputs { // A hive with no explicit level detects as MinLevel (L1); see // detectACMMLevel. Zero-value fallbacks keep this at a safe default. CurrentLevel: acmmadvisor.MinLevel, - // GreenStreak is intentionally left at zero: the hive does not yet - // track a real green-CI streak as a first-class signal, and the - // advisor must never fabricate one. It therefore reads as "unknown / - // not yet earned", which only ever makes the advisor MORE conservative - // (it will not propose a raise on the strength of a made-up streak). - // When the signal becomes available, populate it here. - // TODO(acmm-signals): thread a real GreenStreak from CI history once - // tracked. + // GreenStreak IS real as of #5226: it is populated below from the + // green-CI streak the status-build path measures against the primary + // repo's default-branch Actions history. Until a measurement has + // actually succeeded it STAYS at zero — "unknown / not yet earned" + // rather than a fabricated number — which only ever makes the advisor + // MORE conservative. A repo with no CI at all reads as unknown, never + // as green. See Client.GreenCIStreak for what it measures and its + // known weaknesses (flake-sensitive, stale on quiet repos, capped). // // MergeSuccessRate IS real as of #3972: it is populated below from the // fleet-stats collector's cached 90-day merged/rejected counts. See @@ -88,6 +88,16 @@ func (s *Server) buildACMMStatusInputs() acmmadvisor.StatusInputs { } } + // Real green-CI streak (#5226), read from the cache the status-build path + // refreshes on the same pass it already fetches workflow health — no fresh + // GitHub call on this advisory request. When no measurement has ever + // succeeded (no GitHub client, API failure, or a repo with no CI history + // on its default branch) the signal STAYS at zero rather than reporting an + // unmeasured zero as a measured one. + if streak, measured := greenCIStreakSnapshot(); measured { + in.GreenStreak = streak + } + // Live queue/coverage signals come from the most recent status snapshot the // eval loop published. Read it under the status lock and tolerate a nil // snapshot (no eval has run yet). diff --git a/src/pkg/dashboard/api_acmm_recommendation_test.go b/src/pkg/dashboard/api_acmm_recommendation_test.go index 229c72759..698b611de 100644 --- a/src/pkg/dashboard/api_acmm_recommendation_test.go +++ b/src/pkg/dashboard/api_acmm_recommendation_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/kubestellar/hive/pkg/acmmadvisor" @@ -99,6 +100,7 @@ func TestHandleACMMRecommendationWithSignals(t *testing.T) { // TestBuildACMMStatusInputs_ReadsLiveSignals unit-tests the signal mapping in // isolation, including the coverage extraction and nil-safety. func TestBuildACMMStatusInputs_ReadsLiveSignals(t *testing.T) { + resetGreenStreakCache(t) s := newTestServer() lvl := 3 s.deps = &Dependencies{ @@ -134,7 +136,9 @@ func TestBuildACMMStatusInputs_ReadsLiveSignals(t *testing.T) { if in.CoveragePct != 88 { t.Fatalf("CoveragePct = %v, want 88", in.CoveragePct) } - // GreenStreak is still untracked and must remain zero (never fabricated). + // GreenStreak must read zero here: no streak has ever been measured in this + // test (the cache is cleared above), so the signal is unknown, not a + // measured zero, and must never be fabricated (#5226). // MergeSuccessRate must also read zero here: no fleet-stats collector is // wired into deps, so the signal is unknown, not measured (#3972). if in.GreenStreak != 0 || in.MergeSuccessRate != 0 { @@ -142,6 +146,121 @@ func TestBuildACMMStatusInputs_ReadsLiveSignals(t *testing.T) { } } +// resetGreenStreakCache clears the package-level green-CI streak cache and +// restores it when the test ends, so streak-sensitive tests do not leak state +// into each other. +func resetGreenStreakCache(t *testing.T) { + t.Helper() + cachedGreenStreakMu.Lock() + prevStreak, prevOK := cachedGreenStreak, cachedGreenStreakOK + cachedGreenStreak, cachedGreenStreakOK = 0, false + cachedGreenStreakMu.Unlock() + t.Cleanup(func() { + cachedGreenStreakMu.Lock() + cachedGreenStreak, cachedGreenStreakOK = prevStreak, prevOK + cachedGreenStreakMu.Unlock() + }) +} + +// TestBuildACMMStatusInputs_GreenStreak verifies the #5226 wiring: when the +// status-build path has measured a real green-CI streak, that REAL VALUE +// reaches the advisor input — it is no longer the hardcoded zero this code +// carried before. The unmeasured case must stay at the conservative zero so an +// absent measurement is never reported as a measured streak. +func TestBuildACMMStatusInputs_GreenStreak(t *testing.T) { + cases := []struct { + name string + streak int + measured bool + want int + }{ + { + // The load-bearing case: a measured streak of 7 must arrive as 7. + // Against the pre-#5226 code this fails (it would read 0), which is + // what makes this an assertion of substance rather than shape. + name: "measured streak flows through", + // A value above greenStreakL4 (5) and below greenStreakL6 (12), so + // it is unmistakably a real reading rather than a boundary artifact. + streak: 7, measured: true, want: 7, + }, + { + // A measured zero is legitimate: CI ran and the latest run is red. + name: "measured zero (latest run red) is honest", + streak: 0, measured: true, want: 0, + }, + { + // Never measured: must NOT be reported as a streak. + name: "unmeasured stays conservative", + streak: 99, measured: false, want: 0, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resetGreenStreakCache(t) + if tc.measured { + cachedGreenStreakMu.Lock() + cachedGreenStreak, cachedGreenStreakOK = tc.streak, true + cachedGreenStreakMu.Unlock() + } + s := newTestServer() + lvl := 3 + s.deps = &Dependencies{Config: &config.Config{ACMMLevel: &lvl}} + s.UpdateStatus(minimalPayload()) + + if got := s.buildACMMStatusInputs().GreenStreak; got != tc.want { + t.Fatalf("GreenStreak = %d, want %d", got, tc.want) + } + }) + } +} + +// TestStatusPayloadCarriesACMMAdvice verifies the #5225 wiring end-to-end: the +// status payload must carry a recommendation whose CONTENT reflects the live +// signals, not merely a non-nil struct. A hive at L3 with a measured green +// streak, quality agent and coverage must produce advice that evaluates the +// L3→L4 criteria and names the real streak value in its checklist. +func TestStatusPayloadCarriesACMMAdvice(t *testing.T) { + resetGreenStreakCache(t) + cachedGreenStreakMu.Lock() + cachedGreenStreak, cachedGreenStreakOK = 7, true + cachedGreenStreakMu.Unlock() + + s := newTestServer() + lvl := 3 + s.deps = &Dependencies{ + Config: &config.Config{ + ACMMLevel: &lvl, + Agents: map[string]config.AgentConfig{qualityAgentName: {}}, + }, + } + s.UpdateStatus(minimalPayload()) + + in := s.buildACMMStatusInputs() + if in.GreenStreak != 7 { + t.Fatalf("advisor input GreenStreak = %d, want the measured 7", in.GreenStreak) + } + rec := acmmadvisor.RecommendFromStatus(in) + if rec.CurrentLevel != 3 { + t.Fatalf("recommendation CurrentLevel = %d, want 3", rec.CurrentLevel) + } + // The streak criterion must appear as MET: 7 clears the L4 floor of 5. + // This asserts the real value drove a real verdict — a payload carrying a + // permanently-zero streak would land this criterion in Unmet instead. + var found bool + for _, c := range rec.Met { + if strings.Contains(c.Name, "Green-CI streak") { + found = true + } + } + if !found { + var unmet []string + for _, c := range rec.Unmet { + unmet = append(unmet, c.Name) + } + t.Fatalf("Green-CI streak should be MET with a measured streak of 7; unmet = %v", unmet) + } +} + // TestBuildACMMStatusInputs_MergeSuccessRate verifies the #3972 wiring: when // the fleet-stats collector holds a completed collect, the advisor input is // the real merged/(merged+rejected) ratio — no longer the hardcoded zero — diff --git a/src/pkg/dashboard/api_agents.go b/src/pkg/dashboard/api_agents.go index 04d5ec031..34c9a3234 100644 --- a/src/pkg/dashboard/api_agents.go +++ b/src/pkg/dashboard/api_agents.go @@ -325,7 +325,7 @@ func (s *Server) handleAgentImport(w http.ResponseWriter, r *http.Request) { jsonError(w, "failed to fetch URL: "+err.Error(), http.StatusBadGateway) return } - defer resp.Body.Close() + defer closeHTTPBody(resp.Body) if resp.StatusCode != http.StatusOK { jsonError(w, fmt.Sprintf("URL returned HTTP %d", resp.StatusCode), http.StatusBadGateway) return diff --git a/src/pkg/dashboard/api_budget_history_activity_test.go b/src/pkg/dashboard/api_budget_history_activity_test.go new file mode 100644 index 000000000..d41aea0ff --- /dev/null +++ b/src/pkg/dashboard/api_budget_history_activity_test.go @@ -0,0 +1,307 @@ +package dashboard + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +// --- handleBudgetHistory --- + +// A hive that has never seen a window roll and has no live status must still +// answer with an empty windows array (never null) and no "current" key. +func TestBudgetHistory_EmptyHistoryNoStatus(t *testing.T) { + s, _ := apiServer(t) + + rec := doGet(s, "/api/budget/history") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := decodeJSON(t, rec) + windows, ok := body["windows"].([]interface{}) + if !ok { + t.Fatalf("windows should be an array, got %T (%v)", body["windows"], body["windows"]) + } + if len(windows) != 0 { + t.Errorf("windows = %v, want empty", windows) + } + if _, ok := body["current"]; ok { + t.Errorf("current should be absent when no status has been published, got %v", body["current"]) + } +} + +// With a live status carrying an open window, the report includes a "current" +// block with the open window's spend and both bounds, alongside the closed rows. +func TestBudgetHistory_CurrentWindowFromStatus(t *testing.T) { + s, _ := apiServer(t) + + s.SeedBudgetWindowHistory([]BudgetWindowEntry{ + {WindowStart: 1000, WindowEnd: 2000, Limit: 500, Used: 500, PctUsed: 100, Exhausted: true}, + }) + s.statusMu.Lock() + s.status = &StatusPayload{Budget: FrontendBudget{ + WeeklyBudget: 1_000_000, + Used: 250_000, + PctUsed: 25, + Exhausted: false, + WindowStartsAt: "2026-08-24T00:00:00Z", + WindowEndsAt: "2026-08-31T00:00:00Z", + }} + s.statusMu.Unlock() + + rec := doGet(s, "/api/budget/history") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := decodeJSON(t, rec) + + windows, ok := body["windows"].([]interface{}) + if !ok || len(windows) != 1 { + t.Fatalf("windows = %v, want 1 closed row", body["windows"]) + } + current, ok := body["current"].(map[string]interface{}) + if !ok { + t.Fatalf("current missing or wrong type: %v", body["current"]) + } + if got := current["limit"].(float64); got != 1_000_000 { + t.Errorf("current.limit = %v, want 1000000", got) + } + if got := current["used"].(float64); got != 250_000 { + t.Errorf("current.used = %v, want 250000", got) + } + if got := current["pctUsed"].(float64); got != 25 { + t.Errorf("current.pctUsed = %v, want 25", got) + } + if got := current["exhausted"].(bool); got { + t.Errorf("current.exhausted = true, want false") + } + if got := current["windowStart"]; got != "2026-08-24T00:00:00Z" { + t.Errorf("current.windowStart = %v", got) + } + if got := current["windowEnd"]; got != "2026-08-31T00:00:00Z" { + t.Errorf("current.windowEnd = %v", got) + } +} + +// When no weekly limit is set the status carries empty window bounds; the +// current block must omit windowStart/windowEnd rather than emit empty strings. +func TestBudgetHistory_CurrentWindowOmitsEmptyBounds(t *testing.T) { + s, _ := apiServer(t) + + s.statusMu.Lock() + s.status = &StatusPayload{Budget: FrontendBudget{Exhausted: true}} + s.statusMu.Unlock() + + rec := doGet(s, "/api/budget/history") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := decodeJSON(t, rec) + current, ok := body["current"].(map[string]interface{}) + if !ok { + t.Fatalf("current missing: %v", body) + } + if _, ok := current["windowStart"]; ok { + t.Errorf("windowStart should be omitted when unset, got %v", current["windowStart"]) + } + if _, ok := current["windowEnd"]; ok { + t.Errorf("windowEnd should be omitted when unset, got %v", current["windowEnd"]) + } + if got := current["exhausted"].(bool); !got { + t.Errorf("current.exhausted = false, want true") + } +} + +// --- document/knowledge handler 503 gates --- + +// Every knowledge-backed endpoint must refuse with 503 when the server has no +// dependencies at all (ensureKnowledge false), instead of dereferencing nil. +func TestKnowledgeHandlers_NilDepsServiceUnavailable(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + s := NewServer(0, logger) // no RegisterAPI: s.deps stays nil + + handlers := map[string]http.HandlerFunc{ + "documents list": s.handleDocumentsList, + "documents import": s.handleDocumentsImport, + "document get": s.handleDocumentGet, + "document delete": s.handleDocumentDelete, + "document reimport": s.handleDocumentReimport, + "cleanup orphans": s.handleCleanupOrphans, + "context7 search": s.handleContext7Search, + } + for name, h := range handlers { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + h(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("%s: status = %d, want 503", name, rec.Code) + } + } +} + +// --- ActivityCollector --- + +// stubAuditReader is a canned auditReader so Start/collect run without a real +// audit log on disk. +type stubAuditReader struct { + entries []AuditEntry + calls atomic.Int32 +} + +func (f *stubAuditReader) OutputActionsSince(time.Time, map[string]bool, string) []AuditEntry { + f.calls.Add(1) + return f.entries +} + +func TestActivityCollector_CollectedAt(t *testing.T) { + var nilAC *ActivityCollector + if !nilAC.CollectedAt().IsZero() { + t.Error("nil collector CollectedAt should be zero") + } + + ac := NewActivityCollector(&stubAuditReader{}, "", nil) + if !ac.CollectedAt().IsZero() { + t.Error("fresh collector CollectedAt should be zero") + } + ac.collect() + if ac.CollectedAt().IsZero() { + t.Error("CollectedAt should be set after a collect") + } +} + +// Start must be a no-op on a nil collector and on one with no audit reader. +func TestActivityCollector_StartInert(t *testing.T) { + done := make(chan struct{}) + go func() { + var nilAC *ActivityCollector + nilAC.Start(context.Background()) + NewActivityCollector(nil, "", nil).Start(context.Background()) + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Start did not return immediately for inert collectors") + } +} + +// Start collects once up front, again on each tick, and exits on ctx cancel. +func TestActivityCollector_StartCollectsAndStops(t *testing.T) { + oldInterval := activityCollectInterval + activityCollectInterval = 5 * time.Millisecond + defer func() { activityCollectInterval = oldInterval }() + + stub := &stubAuditReader{entries: []AuditEntry{ + {Timestamp: rfc3339(time.Now()), Action: "agent_pr_created", Detail: "repo=o/r, agent=quality"}, + }} + ac := NewActivityCollector(stub, "ignored", nil) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { ac.Start(ctx); close(done) }() + + deadline := time.Now().Add(2 * time.Second) + for stub.calls.Load() < 2 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Start did not exit after ctx cancel") + } + if stub.calls.Load() < 2 { + t.Errorf("collect calls = %d, want >=2 (upfront + tick)", stub.calls.Load()) + } + snap, ready := ac.Snapshot() + if !ready { + t.Fatal("snapshot should be ready after collect") + } + if len(snap.Repos) != 1 || snap.Repos[0].Repo != "o/r" { + t.Errorf("snapshot repos = %+v, want one entry for o/r", snap.Repos) + } +} + +// persistLocked failure paths: an unwritable temp path and a rename target that +// is a directory must both be swallowed (logged), never panic or persist junk. +func TestActivityCollector_PersistLockedFailures(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + // No persist path configured → early return. + ac := NewActivityCollector(&stubAuditReader{}, "", logger) + ac.persistLocked() + + // Temp-file write fails: parent directory does not exist. + ac.persistPath = filepath.Join(t.TempDir(), "missing-subdir", "activity.json") + ac.persistLocked() + + // Rename fails: destination is an existing directory. + dir := t.TempDir() + blocked := filepath.Join(dir, "activity.json") + if err := os.Mkdir(blocked, 0o755); err != nil { + t.Fatal(err) + } + ac.persistPath = blocked + ac.persistLocked() + if _, err := os.Stat(filepath.Join(dir, "activity.json.tmp")); err != nil { + t.Fatalf("temp sidecar should exist after failed rename: %v", err) + } +} + +// EnablePersistence restore paths: corrupt JSON and a zero collected_at are +// both "start fresh"; a valid sidecar restores snapshot + timestamp. +func TestActivityCollector_EnablePersistenceRestore(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + dir := t.TempDir() + + // Corrupt sidecar → logged, ignored, not ready. + corrupt := filepath.Join(dir, "corrupt.json") + if err := os.WriteFile(corrupt, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + ac := NewActivityCollector(&stubAuditReader{}, "", logger) + ac.EnablePersistence(corrupt) + if _, ready := ac.Snapshot(); ready { + t.Error("corrupt sidecar must not mark the collector ready") + } + + // Zero collected_at → treated as empty, not ready. + zero := filepath.Join(dir, "zero.json") + if err := os.WriteFile(zero, []byte(`{"snapshot":{},"collected_at":"0001-01-01T00:00:00Z"}`), 0o600); err != nil { + t.Fatal(err) + } + ac2 := NewActivityCollector(&stubAuditReader{}, "", logger) + ac2.EnablePersistence(zero) + if _, ready := ac2.Snapshot(); ready { + t.Error("zero collected_at must not mark the collector ready") + } + + // Round-trip: collect+persist in one collector, restore in a second. + stub := &stubAuditReader{entries: []AuditEntry{ + {Timestamp: rfc3339(time.Now()), Action: "pr_merged", Detail: "repo=o/persisted, agent=quality"}, + }} + sidecar := filepath.Join(dir, "activity.json") + writer := NewActivityCollector(stub, "ignored", logger) + writer.EnablePersistence(sidecar) + writer.collect() + + restored := NewActivityCollector(&stubAuditReader{}, "", logger) + restored.EnablePersistence(sidecar) + snap, ready := restored.Snapshot() + if !ready { + t.Fatal("restored collector should be ready") + } + if len(snap.Repos) != 1 || snap.Repos[0].Repo != "o/persisted" { + t.Errorf("restored repos = %+v, want o/persisted", snap.Repos) + } + if restored.CollectedAt().IsZero() { + t.Error("restored CollectedAt should be non-zero") + } +} diff --git a/src/pkg/dashboard/api_contribute.go b/src/pkg/dashboard/api_contribute.go index a4eb4554d..cdfbd3548 100644 --- a/src/pkg/dashboard/api_contribute.go +++ b/src/pkg/dashboard/api_contribute.go @@ -1998,7 +1998,7 @@ select.admin-act{min-width:0;max-width:100%%} - + @@ -2033,11 +2033,23 @@ select.admin-act{min-width:0;max-width:100%%} Credential note (interim): the generated Secret stores a long-lived personal GH_TOKEN — base64, not encrypted, and readable by anyone with get secrets in that namespace or by cluster-scoped operators/backups. That is materially more exposed than a 0600 file on your laptop. Revoke any time with gh auth logout. Gating the credential on explicit task acceptance is tracked in #2537 and is not solved by this path. + run at all: "other" has no image by definition. (agy used to force Host + here too, on the claim that its image lacked the binary and its Google + sign-in could not be inherited — #5048 found the first half wrong (the + binary just needed adding) and the second half unverified, so agy now + offers Container like every other backend; see #agy-confinement-note for + its own, narrower caveat.) --> + +
Contribute to multiple hives: after registering with each hive, set HIVE_HUB to comma-separated WebSocket URLs and HIVE_REGISTRATION_TOKEN to the matching comma-separated tokens in the same order. One relay shares one CLI/tmux session, works on one task at a time, keeps each hub connected with its own heartbeat, and rotates only when the active hub says no task is available. Added by @hanthor in #2846. @@ -2101,21 +2113,33 @@ var hostTpl='PREREQ\nINSTALL\ngit clone -b {{HIVE_BRANCH}} https://github.com/ku // its own, so you can read it before piping to kubectl. Only the headless- // capable backends run this way (see K8S_HEADLESS_BACKENDS). var k8sTpl='PREREQ\ngit clone -b {{HIVE_BRANCH}} https://github.com/kubestellar/hive && cd hive\nexport HIVE_HUB='+hubURL+'\nROLEHELPjust contribute-setup CLI\n# Review the manifest, then apply into your current kube-context:\njust contribute-k8s hive-contributor | kubectl apply -f -\nkubectl -n hive-contributor rollout status deploy/hive-contributor'; -// Backends with a verified headless (non-interactive) entry point — must match -// HEADLESS_BACKENDS in bin/contributor-relay.sh and the Justfile. A pod has no -// TTY, so only these run in a cluster; anything else refuses work at startup. +// Backends whose credentials and model configuration this generator can stage +// safely. Pi's relay supports headless execution, but this generator does not +// yet stage its provider-specific credentials or canonical model. A pod has no +// TTY, so anything outside this list refuses work at startup. var K8S_HEADLESS_BACKENDS={claude:1,litellm:1,copilot:1,codex:1,watsonx:1,goose:1}; // Backends that can only run on the contributor's own host. "other" has no -// image by definition. agy is host-only for a different reason: it signs in -// through an interactive Google OAuth flow (browser URL + pasted code) with no -// API-key mode, so neither a container nor a pod can inherit a contributor's -// session — verified against agy 1.1.13, where a clean container demands a -// fresh browser login no matter what is mounted, and the contributor image does -// not ship the binary either. Selecting one flips Mode to Host rather than -// generating commands that cannot work. NOTE agy IS headless-capable on a host -// (agy -p, see HEADLESS_BACKENDS in bin/contributor-relay.sh); it is the -// credential, not the capability, that keeps it out of K8S_HEADLESS_BACKENDS. -var HOST_ONLY_BACKENDS=['other','agy']; +// image by definition, so it stays here. Selecting one flips Mode to Host +// rather than generating commands that cannot work. +// +// agy USED TO be in this list too, on the claim that the contributor image +// did not ship the binary and that its Google sign-in could not be inherited +// by a container. #5048 found the first half simply wrong (the binary was +// never added; src/Dockerfile.contributor now installs it) and the second +// half an overclaim: agy does persist OAuth state under ~/.gemini, including +// a refresh_token, and the earlier "verified on 1.1.13" conclusion was +// observing an incomplete staging mount (see the Justfile's agy staging +// case), not an absence of any inheritable credential. agy is therefore no +// longer forced to Host — see #agy-confinement-note for the constraint that +// actually still applies to it: agy has no OS sandbox of its own, so +// Container is its only mode with any host boundary at all, a narrower and +// more accurate statement than "must run on the host." +// +// agy IS headless-capable on a host (agy -p, see HEADLESS_BACKENDS in +// bin/contributor-relay.sh); it stays out of K8S_HEADLESS_BACKENDS +// regardless, because a pod has no way to complete its interactive sign-in +// even once. +var HOST_ONLY_BACKENDS=['other']; function isHostOnly(c){return HOST_ONLY_BACKENDS.indexOf(c)>=0;} var modelRow=document.getElementById('model-row'); var modelInput=document.getElementById('model-input'); @@ -2136,6 +2160,8 @@ var k8sNote=document.getElementById('k8s-note'); if(k8sNote)k8sNote.style.display=(mode==='kubernetes')?'block':'none'; var hostOnlyNote=document.getElementById('hostonly-note'); if(hostOnlyNote)hostOnlyNote.style.display=isHostOnly(cli)?'block':'none'; +var agyNote=document.getElementById('agy-confinement-note'); +if(agyNote)agyNote.style.display=(cli==='agy')?'block':'none'; modelRow.style.display=(modelFlag||cli==='goose')?'flex':'none'; var modelLine=''; if(model){ @@ -2233,7 +2259,7 @@ vllm:{name:'vLLM',tag:'self-hosted'}, 'llm-d':{name:'llm-d',tag:'self-hosted'}, bob:{name:'Bob',tag:'IBM'}, watsonx:{name:'watsonx.ai',tag:'IBM'}, -agy:{name:'Antigravity',tag:'Google (host)'}, +agy:{name:'Antigravity',tag:'Google (unconfined)'}, other:{name:'Other',tag:'host only'} }; var tilesEl=document.getElementById('client-tiles'); @@ -4552,6 +4578,10 @@ function capabilityLine(caps){ if(caps.agent_cli_version)parts.push('cli '+esc(caps.agent_cli_version)); if(caps.relay_protocol_version)parts.push('proto '+esc(caps.relay_protocol_version)); if(caps.credential_type)parts.push('cred:'+esc(caps.credential_type)); + if(caps.pi_binary)parts.push('pi binary:'+esc(caps.pi_binary)); + if(caps.pi_configuration)parts.push('config:'+esc(caps.pi_configuration)); + if(caps.pi_authentication)parts.push('auth:'+esc(caps.pi_authentication)); + if(caps.pi_invocation)parts.push('invoke:'+esc(caps.pi_invocation)); if(!parts.length)return ''; return '
declares: '+parts.join(' · ')+'
'; } @@ -6252,7 +6282,7 @@ fetch('/api/version').then(function(r){return r.json()}).then(function(d){ `, "{{HIVE_BRANCH}}", upstreamBranch()), projectName, michromaFontFaceCSS, customStyleHeadHTML, projectName, len(profiles), tierBoxes.String(), hubURL, hubURLJS, projectNameJS, tierTableRows, customStyleNoticeHTML) applyDocumentScriptSrcElem(w, page.Bytes()) - w.Write(page.Bytes()) + _, _ = w.Write(page.Bytes()) } // ── Registration ─────────────────────────────────────────────────────────── @@ -6465,7 +6495,7 @@ func (s *Server) handleContributeReissueToken(w http.ResponseWriter, r *http.Req if username == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(`{"error":"Invalid or missing GitHub token. Use: Authorization: Bearer "}`)) + _, _ = w.Write([]byte(`{"error":"Invalid or missing GitHub token. Use: Authorization: Bearer "}`)) return } @@ -7705,8 +7735,8 @@ func (s *Server) handleHivesOnboard(w http.ResponseWriter, r *http.Request) { "next_steps": []string{ "1. Install the Hive GitHub App on your org", "2. Note the App ID and Installation ID", - "3. Save the private key as /etc/hive/gh-app-key.pem", - "4. Deploy with: docker compose up -d", + "3. Save the private key in the deployment's secrets directory (for example, /etc/hive/secrets/gh-app-key.pem, or ~/.config/hive/secrets/gh-app-key.pem for rootless Podman)", + "4. Deploy: Docker — docker compose up -d; Podman — install the Quadlet units from src/deploy/quadlet/ per src/docs/podman-standalone-quadlet.md, then start hive-gateway.service with systemctl (systemctl --user for rootless)", "5. Register: POST /api/hives/register", }, }) @@ -7979,7 +8009,7 @@ func isValidUsername(s string) bool { return false } for _, c := range s { - if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') { + if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '-' && c != '_' && c != '.' { return false } } @@ -8100,7 +8130,7 @@ func validateGitHubToken(token, apiURL string) string { if err != nil || resp.StatusCode != 200 { return "" } - defer resp.Body.Close() + defer closeHTTPBody(resp.Body) var user struct { Login string `json:"login"` } @@ -8145,7 +8175,7 @@ func (s *Server) handleAPIv1(w http.ResponseWriter, r *http.Request) { if token == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(`{"error":"Invalid or missing GitHub token. Use: Authorization: Bearer "}`)) + _, _ = w.Write([]byte(`{"error":"Invalid or missing GitHub token. Use: Authorization: Bearer "}`)) return } @@ -8153,7 +8183,7 @@ func (s *Server) handleAPIv1(w http.ResponseWriter, r *http.Request) { if username == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(`{"error":"Invalid or missing GitHub token. Use: Authorization: Bearer "}`)) + _, _ = w.Write([]byte(`{"error":"Invalid or missing GitHub token. Use: Authorization: Bearer "}`)) return } @@ -8198,12 +8228,12 @@ func (s *Server) handleAPIv1(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) - w.Write([]byte(`{"error":"Not registered as a contributor. Run: just contribute-setup"}`)) + _, _ = w.Write([]byte(`{"error":"Not registered as a contributor. Run: just contribute-setup"}`)) default: if !strings.HasPrefix(subpath, "/prs/") || !strings.HasSuffix(subpath, "/queue-automerge") { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) - w.Write([]byte(`{"error":"Unknown endpoint","available":["/api/v1/status","/api/v1/activity","/api/v1/contributors","/api/v1/knowledge","/api/v1/me","/api/v1/prs/{owner}/{repo}/{number}/queue-automerge"]}`)) + _, _ = w.Write([]byte(`{"error":"Unknown endpoint","available":["/api/v1/status","/api/v1/activity","/api/v1/contributors","/api/v1/knowledge","/api/v1/me","/api/v1/prs/{owner}/{repo}/{number}/queue-automerge"]}`)) return } parts := strings.Split(strings.TrimPrefix(subpath, "/prs/"), "/") @@ -8253,7 +8283,7 @@ func (s *Server) handleAPIDocs(w http.ResponseWriter, r *http.Request) { } baseURL := scheme + "://" + host w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, ` + _, _ = fmt.Fprintf(w, ` Hive API