From 776b4e3275b16f49a628ea3eae07d61e3b6bd15d Mon Sep 17 00:00:00 2001 From: thedancingdeveloper Date: Sat, 29 Aug 2026 03:21:02 +0000 Subject: [PATCH 01/12] fix(release): bake the AI clients into the release image; ship v0.3.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tagged release builds the merged `vogt-stack` image prod deploys, but it passed `INSTALL_CADASTRE_MCP=true` and never `INSTALL_AI_CLIENTS`, so the Dockerfile default (false) stood: v0.3.0 shipped with no `claude` or `codex`. Production registered the `Claude Code (protected)` / `Codex (protected)` session templates from config.rs yet could not start them — the CLIs were not in the image. `build.yml` learned this for dev/prod (#23); `release.yml` had not. - release.yml: pass INSTALL_AI_CLIENTS=true and INSTALL_THECLAWBAY=true to both the candidate and pushed stack builds; extend the smoke step to run claude, codex and theclawbay so a missing CLI fails the release (NFR-Q7). Flutter stays out by design — a release is the `lean` pod variant (#184). - deploy-production.yml: resolve the stack image by its published tag (`vogt-stack:${TAG#v}`), since docker/metadata-action strips the `v`; the old `:$TAG` lookup would fail the signed-digest resolve for a v-prefixed tag. - test_deploy.py: guard the release build the way the dev build is guarded. - Bump to 0.3.1 across pyproject, package, __init__ and build.yml; changelog. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F4VhQKnLKBA847LSZpPPds --- .github/workflows/build.yml | 8 ++--- .github/workflows/deploy-production.yml | 2 +- .github/workflows/release.yml | 16 +++++++++ CHANGELOG.md | 15 +++++++++ mobile/package.json | 2 +- pyproject.toml | 2 +- src/vogt/__init__.py | 2 +- tests/test_deploy.py | 43 +++++++++++++++++++++++++ web/package.json | 2 +- 9 files changed, 83 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7e0f6c8d..fba8f9b4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -362,7 +362,7 @@ jobs: build-args: | VOGT_SOURCE_REF=dev VOGT_SOURCE_SHA=${{ github.sha }} - VOGT_PRODUCT_VERSION=0.3.0 + VOGT_PRODUCT_VERSION=0.3.1 cache-from: | type=registry,ref=${{ env.CACHE_IMAGE }}:stack-dev type=registry,ref=${{ env.CACHE_IMAGE }}:demo-dev @@ -460,7 +460,7 @@ jobs: build-args: | VOGT_SOURCE_REF=dev VOGT_SOURCE_SHA=${{ github.sha }} - VOGT_PRODUCT_VERSION=0.3.0 + VOGT_PRODUCT_VERSION=0.3.1 provenance: true sbom: true cache-from: | @@ -626,7 +626,7 @@ jobs: CORE_IMAGE=${{ env.IMAGE }}@${{ needs.image.outputs.digest }} VOGT_SOURCE_REF=${{ github.ref_name }} VOGT_SOURCE_SHA=${{ github.sha }} - VOGT_PRODUCT_VERSION=0.3.0 + VOGT_PRODUCT_VERSION=0.3.1 INSTALL_AI_CLIENTS=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/prod' }} INSTALL_CADASTRE_MCP=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/prod' }} INSTALL_THECLAWBAY=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/prod' }} @@ -691,7 +691,7 @@ jobs: CORE_IMAGE=${{ env.IMAGE }}@${{ needs.image.outputs.digest }} VOGT_SOURCE_REF=${{ github.ref_name }} VOGT_SOURCE_SHA=${{ github.sha }} - VOGT_PRODUCT_VERSION=0.3.0 + VOGT_PRODUCT_VERSION=0.3.1 INSTALL_AI_CLIENTS=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/prod' }} INSTALL_CADASTRE_MCP=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/prod' }} INSTALL_THECLAWBAY=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/prod' }} diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 1f4d2c6a..7e43198e 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -73,7 +73,7 @@ jobs: TAG: ${{ inputs.tag }} run: | set -euo pipefail - image="ghcr.io/thedancingdeveloper-org/vogt-stack:$TAG" + image="ghcr.io/thedancingdeveloper-org/vogt-stack:${TAG#v}" digest="$(docker buildx imagetools inspect "$image" --format '{{.Manifest.Digest}}')" case "$digest" in sha256:*) ;; *) echo "invalid digest: $digest" >&2; exit 1;; esac echo "digest=$digest" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b396c11..5f0ad88d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -384,7 +384,9 @@ jobs: VOGT_SOURCE_REF=${{ github.ref_name }} VOGT_SOURCE_SHA=${{ github.sha }} VOGT_PRODUCT_VERSION=${{ github.ref_name }} + INSTALL_AI_CLIENTS=true INSTALL_CADASTRE_MCP=true + INSTALL_THECLAWBAY=true cache-from: type=registry,ref=${{ env.CACHE_IMAGE }}:stack-release cache-to: type=registry,ref=${{ env.CACHE_IMAGE }}:stack-release,mode=max @@ -396,6 +398,18 @@ jobs: docker run --rm --entrypoint vogt "$CANDIDATE" --version docker run --rm --entrypoint vogt-engine "$CANDIDATE" --help >/dev/null docker run --rm --entrypoint vogt-verify-agent-clis "$CANDIDATE" + # A release is always an estate/prod image, so it must carry the AI + # clients and theclawbay. v0.3.0 shipped without claude or codex + # because this build passed INSTALL_CADASTRE_MCP=true but never + # INSTALL_AI_CLIENTS, so the Dockerfile default (false) stood and prod + # registered the "(protected)" templates it could not start. Running + # each CLI here is NFR-Q7: a build arg nothing asserts is a default + # waiting to be forgotten (#23 / WI-17). Flutter is intentionally + # absent — a release is the `lean` pod variant (#184). + for tool in claude codex theclawbay; do + docker run --rm --entrypoint "$tool" "$CANDIDATE" --version >/dev/null \ + || { echo "the release image is missing $tool" >&2; exit 1; } + done echo "both halves are present and start" - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 @@ -412,7 +426,9 @@ jobs: VOGT_SOURCE_REF=${{ github.ref_name }} VOGT_SOURCE_SHA=${{ github.sha }} VOGT_PRODUCT_VERSION=${{ github.ref_name }} + INSTALL_AI_CLIENTS=true INSTALL_CADASTRE_MCP=true + INSTALL_THECLAWBAY=true provenance: true sbom: true cache-from: type=registry,ref=${{ env.CACHE_IMAGE }}:stack-release diff --git a/CHANGELOG.md b/CHANGELOG.md index 400117ad..42d86d6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,21 @@ git log rather than being reconstructed here. Nothing yet. +## [0.3.1] - 2026-08-29 + +### Fixed + +- The tagged release now builds the `vogt-stack` image with the Codex and + Claude Code CLIs (`INSTALL_AI_CLIENTS=true`) and `theclawbay`, matching the + `dev`/`prod` builds. v0.3.0 shipped without them: `release.yml` set + `INSTALL_CADASTRE_MCP=true` but never `INSTALL_AI_CLIENTS`, so production + registered the `Claude Code (protected)` and `Codex (protected)` session + templates yet could not start them. The release smoke test now runs each CLI + so the gap cannot silently reappear (#23). +- Production deployment resolves the signed release stack image by its + published tag (`vogt-stack:`, no `v` prefix), so the digest lookup no + longer fails for `v`-prefixed release tags. + ## [0.3.0] - 2026-08-28 The first release since the merged core+engine stack reached production. No diff --git a/mobile/package.json b/mobile/package.json index 7273f734..9bfbf7f4 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -1,6 +1,6 @@ { "name": "vogt-mobile", - "version": "0.3.0", + "version": "0.3.1", "private": true, "type": "module", "scripts": { diff --git a/pyproject.toml b/pyproject.toml index 8e7379a8..901be297 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vogt" -version = "0.3.0" +version = "0.3.1" description = "A product development environment for the AI era" readme = "README.md" requires-python = ">=3.11" diff --git a/src/vogt/__init__.py b/src/vogt/__init__.py index 0d089ceb..7bfb8868 100644 --- a/src/vogt/__init__.py +++ b/src/vogt/__init__.py @@ -2,6 +2,6 @@ from __future__ import annotations -__version__ = "0.3.0" +__version__ = "0.3.1" __all__ = ["__version__"] diff --git a/tests/test_deploy.py b/tests/test_deploy.py index d3590e12..86dd13ae 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -189,6 +189,49 @@ def test_the_dev_image_build_turns_the_ai_clients_on() -> None: ) +def test_the_release_image_build_turns_the_ai_clients_on() -> None: + """The tagged release *is* the prod image, and prod runs coding sessions. + + v0.3.0 shipped a `vogt-stack` image built with neither `claude` nor `codex`: + `release.yml` passed `INSTALL_CADASTRE_MCP=true` but never + `INSTALL_AI_CLIENTS`, so the Dockerfile default (false) stood and production + came up with the two "(protected)" templates registered but unstartable. + This is #23 one workflow over — the dev build (the sibling test) had learned + it and the release build had not. Unlike `build.yml`, `release.yml` only + ever runs on a tag, so the value is an unconditional `true`, not the ref + rule; both the candidate and the pushed build must carry it, or the image + that is smoke-tested is not the image that is published. + + Flutter stays absent on purpose: a release is the `lean` pod variant (#184). + """ + text = (WORKFLOWS / "release.yml").read_text(encoding="utf-8") + # Only the two args this test is about — the release build already carried + # INSTALL_CADASTRE_MCP (and the core image installs it too, so its count is + # not two). Both the stack candidate and the stack push must set these, or + # the image that is smoke-tested is not the image that is published. + for arg in ("INSTALL_AI_CLIENTS", "INSTALL_THECLAWBAY"): + wired = re.findall(rf"^\s+{arg}=true$", text, re.MULTILINE) + assert len(wired) == 2, ( + f"{arg}=true must reach both the candidate and the pushed build of " + f"the release stack image; found {len(wired)}" + ) + loop = re.search(r"for tool in ([^;]+); do", text) + assert loop, ( + "the release image's smoke test must loop over the tools the image " + "carries and run each one (NFR-Q7)" + ) + probed = set(loop.group(1).split()) + owed = {"claude", "codex", "theclawbay"} + assert owed <= probed, ( + "the release image's smoke test must ask the image for the clients " + f"(NFR-Q7); missing: {sorted(owed - probed)}" + ) + assert "flutter" not in probed, ( + "a release is the `lean` pod variant (#184); flutter is intentionally " + "not in the release image and must not be probed" + ) + + def test_the_image_has_no_default_listen_address() -> None: """NFR-D2: the image must not silently bind anything.""" text = _without_comments(DOCKERFILE.read_text(encoding="utf-8")) diff --git a/web/package.json b/web/package.json index 61ccd2f8..ffeeea4f 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "vogt-web", - "version": "0.3.0", + "version": "0.3.1", "private": true, "type": "module", "scripts": { From 189f1d5b0ce3a923ab1928b83302920b6da5c096 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper Date: Sat, 29 Aug 2026 03:29:46 +0000 Subject: [PATCH 02/12] chore: update uv.lock for the 0.3.1 version bump Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F4VhQKnLKBA847LSZpPPds --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 9887c61d..ed61e164 100644 --- a/uv.lock +++ b/uv.lock @@ -995,7 +995,7 @@ wheels = [ [[package]] name = "vogt" -version = "0.3.0" +version = "0.3.1" source = { editable = "." } dependencies = [ { name = "cryptography" }, From 51d2bfd71b60336715e17ac70b6d4804c62258ec Mon Sep 17 00:00:00 2001 From: thedancingdeveloper Date: Sat, 29 Aug 2026 03:38:16 +0000 Subject: [PATCH 03/12] chore: complete the 0.3.1 bump across ci gate, deploy manifests and docs The version lives in more places than the first commit touched: the ci.yml product-version gate arg, the deploy compose/overlay/.env image tags, the deployment docs, and the product-version test's expected value. Matches the scope of the 0.3.0 bump commit. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F4VhQKnLKBA847LSZpPPds --- .github/workflows/ci.yml | 2 +- deploy/.env.example | 2 +- deploy/engine.overlay.yml | 2 +- deploy/vogt.compose.yml | 2 +- docs/CUSTOMISATION.md | 2 +- docs/DEPLOYMENT.md | 4 ++-- tests/test_product_version.py | 4 ++-- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d008c74f..bf9350fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -219,7 +219,7 @@ jobs: - run: uv sync --locked - name: canonical product version is aligned - run: uv run --no-sync python scripts/check_product_version.py 0.3.0 + run: uv run --no-sync python scripts/check_product_version.py 0.3.1 - name: ruff (lint) run: uv run --no-sync ruff check . diff --git a/deploy/.env.example b/deploy/.env.example index bc709ec6..5813ef64 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -19,7 +19,7 @@ VOGT_BIND_IP=127.0.0.1 # The image to run. Pin a digest in anything you would call a deployment. # Ignored when you add deploy/vogt.build.yml, which builds from the checkout. -# VOGT_IMAGE=ghcr.io/thedancingdeveloper-org/vogt:0.3.0 +# VOGT_IMAGE=ghcr.io/thedancingdeveloper-org/vogt:0.3.1 # The uid the container runs as. The gid is always 0 — that is what makes the # data directory writable at any uid. Set this to whoever owns the files this diff --git a/deploy/engine.overlay.yml b/deploy/engine.overlay.yml index 8d6ecca9..25654a7e 100644 --- a/deploy/engine.overlay.yml +++ b/deploy/engine.overlay.yml @@ -59,7 +59,7 @@ services: # container carries both halves. Defaults to the same public image the # base would run, so the embedded core matches it; pin a digest for a # real deployment. The Dockerfile already carries a public default too. - CORE_IMAGE: "${VOGT_IMAGE:-ghcr.io/thedancingdeveloper-org/vogt:0.3.0}" + CORE_IMAGE: "${VOGT_IMAGE:-ghcr.io/thedancingdeveloper-org/vogt:0.3.1}" # Optional image-managed agent CLIs. When enabled, versions come from # engine/agent-versions.env unless an operator deliberately overrides # one with a reproducible build arg. diff --git a/deploy/vogt.compose.yml b/deploy/vogt.compose.yml index 0d834a69..71c07edb 100644 --- a/deploy/vogt.compose.yml +++ b/deploy/vogt.compose.yml @@ -25,7 +25,7 @@ services: # digest — publishing an image and moving a deployment are separate acts # (NFR-D10), and a digest is the only form of "which image is this" a # rebuild cannot silently change. - image: ${VOGT_IMAGE:-ghcr.io/thedancingdeveloper-org/vogt:0.3.0} + image: ${VOGT_IMAGE:-ghcr.io/thedancingdeveloper-org/vogt:0.3.1} restart: unless-stopped # The image runs as any uid so long as the gid is 0: `/var/lib/vogt` is diff --git a/docs/CUSTOMISATION.md b/docs/CUSTOMISATION.md index 98e4cfd9..d049623f 100644 --- a/docs/CUSTOMISATION.md +++ b/docs/CUSTOMISATION.md @@ -359,7 +359,7 @@ The published image is built to be a base: to it. ```dockerfile -FROM ghcr.io/thedancingdeveloper-org/vogt:0.3.0 +FROM ghcr.io/thedancingdeveloper-org/vogt:0.3.1 USER root RUN apt-get update \ && apt-get install -y --no-install-recommends ripgrep \ diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index e0b74ec2..50bf5d4f 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -91,7 +91,7 @@ What the base does, and why it does it that way: | `VOGT_PUBLIC_URL` | yes | — | The URL clients use to reach this instance. | | `VOGT_PORT` | no | `8080` | Host port the container's 8000 is published on. | | `VOGT_BIND_IP` | no | `127.0.0.1` | Host interface the port is published on. | -| `VOGT_IMAGE` | no | `ghcr.io/thedancingdeveloper-org/vogt:0.3.0` | The image to run. | +| `VOGT_IMAGE` | no | `ghcr.io/thedancingdeveloper-org/vogt:0.3.1` | The image to run. | | `VOGT_UID` | no | `1000` | The uid the container runs as (gid is always 0). | | `VOGT_LOG_LEVEL` | no | `info` | Verbosity of Vogt's own logger. | @@ -118,7 +118,7 @@ cosign verify \ ``` ```console -docker buildx imagetools inspect ghcr.io/thedancingdeveloper-org/vogt:0.3.0 \ +docker buildx imagetools inspect ghcr.io/thedancingdeveloper-org/vogt:0.3.1 \ | grep -m1 Digest # then, in deploy/.env: VOGT_IMAGE=ghcr.io/thedancingdeveloper-org/vogt@sha256: diff --git a/tests/test_product_version.py b/tests/test_product_version.py index fc6c523c..df3df8d7 100644 --- a/tests/test_product_version.py +++ b/tests/test_product_version.py @@ -16,7 +16,7 @@ def test_product_version_contract_is_in_sync() -> None: result = subprocess.run( - [sys.executable, "scripts/check_product_version.py", "0.3.0"], + [sys.executable, "scripts/check_product_version.py", "0.3.1"], cwd=ROOT, capture_output=True, text=True, @@ -28,6 +28,6 @@ def test_product_version_contract_is_in_sync() -> None: def test_android_version_name_is_derived_from_the_canonical_mobile_manifest() -> None: package = (ROOT / "mobile/package.json").read_text(encoding="utf-8") gradle = (ROOT / "mobile/android/app/build.gradle").read_text(encoding="utf-8") - assert '"version": "0.3.0"' in package + assert '"version": "0.3.1"' in package assert "new JsonSlurper().parse(file('../../package.json'))" in gradle assert "versionName androidVersionName" in gradle From 4cffb31cadb964663799c7a0f4bb27beec4d9c75 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper Date: Sat, 29 Aug 2026 04:01:08 +0000 Subject: [PATCH 04/12] Revert "chore: complete the 0.3.1 bump across ci gate, deploy manifests and docs" This reverts commit 1a76f0a65e37f728a14ef19882ce21d134d8c242. --- .github/workflows/ci.yml | 2 +- deploy/.env.example | 2 +- deploy/engine.overlay.yml | 2 +- deploy/vogt.compose.yml | 2 +- docs/CUSTOMISATION.md | 2 +- docs/DEPLOYMENT.md | 4 ++-- tests/test_product_version.py | 4 ++-- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf9350fd..d008c74f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -219,7 +219,7 @@ jobs: - run: uv sync --locked - name: canonical product version is aligned - run: uv run --no-sync python scripts/check_product_version.py 0.3.1 + run: uv run --no-sync python scripts/check_product_version.py 0.3.0 - name: ruff (lint) run: uv run --no-sync ruff check . diff --git a/deploy/.env.example b/deploy/.env.example index 5813ef64..bc709ec6 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -19,7 +19,7 @@ VOGT_BIND_IP=127.0.0.1 # The image to run. Pin a digest in anything you would call a deployment. # Ignored when you add deploy/vogt.build.yml, which builds from the checkout. -# VOGT_IMAGE=ghcr.io/thedancingdeveloper-org/vogt:0.3.1 +# VOGT_IMAGE=ghcr.io/thedancingdeveloper-org/vogt:0.3.0 # The uid the container runs as. The gid is always 0 — that is what makes the # data directory writable at any uid. Set this to whoever owns the files this diff --git a/deploy/engine.overlay.yml b/deploy/engine.overlay.yml index 25654a7e..8d6ecca9 100644 --- a/deploy/engine.overlay.yml +++ b/deploy/engine.overlay.yml @@ -59,7 +59,7 @@ services: # container carries both halves. Defaults to the same public image the # base would run, so the embedded core matches it; pin a digest for a # real deployment. The Dockerfile already carries a public default too. - CORE_IMAGE: "${VOGT_IMAGE:-ghcr.io/thedancingdeveloper-org/vogt:0.3.1}" + CORE_IMAGE: "${VOGT_IMAGE:-ghcr.io/thedancingdeveloper-org/vogt:0.3.0}" # Optional image-managed agent CLIs. When enabled, versions come from # engine/agent-versions.env unless an operator deliberately overrides # one with a reproducible build arg. diff --git a/deploy/vogt.compose.yml b/deploy/vogt.compose.yml index 71c07edb..0d834a69 100644 --- a/deploy/vogt.compose.yml +++ b/deploy/vogt.compose.yml @@ -25,7 +25,7 @@ services: # digest — publishing an image and moving a deployment are separate acts # (NFR-D10), and a digest is the only form of "which image is this" a # rebuild cannot silently change. - image: ${VOGT_IMAGE:-ghcr.io/thedancingdeveloper-org/vogt:0.3.1} + image: ${VOGT_IMAGE:-ghcr.io/thedancingdeveloper-org/vogt:0.3.0} restart: unless-stopped # The image runs as any uid so long as the gid is 0: `/var/lib/vogt` is diff --git a/docs/CUSTOMISATION.md b/docs/CUSTOMISATION.md index d049623f..98e4cfd9 100644 --- a/docs/CUSTOMISATION.md +++ b/docs/CUSTOMISATION.md @@ -359,7 +359,7 @@ The published image is built to be a base: to it. ```dockerfile -FROM ghcr.io/thedancingdeveloper-org/vogt:0.3.1 +FROM ghcr.io/thedancingdeveloper-org/vogt:0.3.0 USER root RUN apt-get update \ && apt-get install -y --no-install-recommends ripgrep \ diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 50bf5d4f..e0b74ec2 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -91,7 +91,7 @@ What the base does, and why it does it that way: | `VOGT_PUBLIC_URL` | yes | — | The URL clients use to reach this instance. | | `VOGT_PORT` | no | `8080` | Host port the container's 8000 is published on. | | `VOGT_BIND_IP` | no | `127.0.0.1` | Host interface the port is published on. | -| `VOGT_IMAGE` | no | `ghcr.io/thedancingdeveloper-org/vogt:0.3.1` | The image to run. | +| `VOGT_IMAGE` | no | `ghcr.io/thedancingdeveloper-org/vogt:0.3.0` | The image to run. | | `VOGT_UID` | no | `1000` | The uid the container runs as (gid is always 0). | | `VOGT_LOG_LEVEL` | no | `info` | Verbosity of Vogt's own logger. | @@ -118,7 +118,7 @@ cosign verify \ ``` ```console -docker buildx imagetools inspect ghcr.io/thedancingdeveloper-org/vogt:0.3.1 \ +docker buildx imagetools inspect ghcr.io/thedancingdeveloper-org/vogt:0.3.0 \ | grep -m1 Digest # then, in deploy/.env: VOGT_IMAGE=ghcr.io/thedancingdeveloper-org/vogt@sha256: diff --git a/tests/test_product_version.py b/tests/test_product_version.py index df3df8d7..fc6c523c 100644 --- a/tests/test_product_version.py +++ b/tests/test_product_version.py @@ -16,7 +16,7 @@ def test_product_version_contract_is_in_sync() -> None: result = subprocess.run( - [sys.executable, "scripts/check_product_version.py", "0.3.1"], + [sys.executable, "scripts/check_product_version.py", "0.3.0"], cwd=ROOT, capture_output=True, text=True, @@ -28,6 +28,6 @@ def test_product_version_contract_is_in_sync() -> None: def test_android_version_name_is_derived_from_the_canonical_mobile_manifest() -> None: package = (ROOT / "mobile/package.json").read_text(encoding="utf-8") gradle = (ROOT / "mobile/android/app/build.gradle").read_text(encoding="utf-8") - assert '"version": "0.3.1"' in package + assert '"version": "0.3.0"' in package assert "new JsonSlurper().parse(file('../../package.json'))" in gradle assert "versionName androidVersionName" in gradle From 38f66e0e3fec33bd6a9bc7948dc35be6b5e2da34 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper Date: Sat, 29 Aug 2026 04:01:08 +0000 Subject: [PATCH 05/12] Revert "chore: update uv.lock for the 0.3.1 version bump" This reverts commit 3cf031de267fe8e63c1d87934e6cbc8ccd9adf65. --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index ed61e164..9887c61d 100644 --- a/uv.lock +++ b/uv.lock @@ -995,7 +995,7 @@ wheels = [ [[package]] name = "vogt" -version = "0.3.1" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, From a77e8a52f909cf47f186636433923d246f0dab9d Mon Sep 17 00:00:00 2001 From: thedancingdeveloper Date: Sat, 29 Aug 2026 04:01:08 +0000 Subject: [PATCH 06/12] Revert "fix(release): bake the AI clients into the release image; ship v0.3.1" This reverts commit 34665f571a743ec9c48eb53bc30dfb41a15fbc0c. --- .github/workflows/build.yml | 8 ++--- .github/workflows/deploy-production.yml | 2 +- .github/workflows/release.yml | 16 --------- CHANGELOG.md | 15 --------- mobile/package.json | 2 +- pyproject.toml | 2 +- src/vogt/__init__.py | 2 +- tests/test_deploy.py | 43 ------------------------- web/package.json | 2 +- 9 files changed, 9 insertions(+), 83 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fba8f9b4..7e0f6c8d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -362,7 +362,7 @@ jobs: build-args: | VOGT_SOURCE_REF=dev VOGT_SOURCE_SHA=${{ github.sha }} - VOGT_PRODUCT_VERSION=0.3.1 + VOGT_PRODUCT_VERSION=0.3.0 cache-from: | type=registry,ref=${{ env.CACHE_IMAGE }}:stack-dev type=registry,ref=${{ env.CACHE_IMAGE }}:demo-dev @@ -460,7 +460,7 @@ jobs: build-args: | VOGT_SOURCE_REF=dev VOGT_SOURCE_SHA=${{ github.sha }} - VOGT_PRODUCT_VERSION=0.3.1 + VOGT_PRODUCT_VERSION=0.3.0 provenance: true sbom: true cache-from: | @@ -626,7 +626,7 @@ jobs: CORE_IMAGE=${{ env.IMAGE }}@${{ needs.image.outputs.digest }} VOGT_SOURCE_REF=${{ github.ref_name }} VOGT_SOURCE_SHA=${{ github.sha }} - VOGT_PRODUCT_VERSION=0.3.1 + VOGT_PRODUCT_VERSION=0.3.0 INSTALL_AI_CLIENTS=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/prod' }} INSTALL_CADASTRE_MCP=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/prod' }} INSTALL_THECLAWBAY=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/prod' }} @@ -691,7 +691,7 @@ jobs: CORE_IMAGE=${{ env.IMAGE }}@${{ needs.image.outputs.digest }} VOGT_SOURCE_REF=${{ github.ref_name }} VOGT_SOURCE_SHA=${{ github.sha }} - VOGT_PRODUCT_VERSION=0.3.1 + VOGT_PRODUCT_VERSION=0.3.0 INSTALL_AI_CLIENTS=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/prod' }} INSTALL_CADASTRE_MCP=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/prod' }} INSTALL_THECLAWBAY=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/prod' }} diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 7e43198e..1f4d2c6a 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -73,7 +73,7 @@ jobs: TAG: ${{ inputs.tag }} run: | set -euo pipefail - image="ghcr.io/thedancingdeveloper-org/vogt-stack:${TAG#v}" + image="ghcr.io/thedancingdeveloper-org/vogt-stack:$TAG" digest="$(docker buildx imagetools inspect "$image" --format '{{.Manifest.Digest}}')" case "$digest" in sha256:*) ;; *) echo "invalid digest: $digest" >&2; exit 1;; esac echo "digest=$digest" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f0ad88d..0b396c11 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -384,9 +384,7 @@ jobs: VOGT_SOURCE_REF=${{ github.ref_name }} VOGT_SOURCE_SHA=${{ github.sha }} VOGT_PRODUCT_VERSION=${{ github.ref_name }} - INSTALL_AI_CLIENTS=true INSTALL_CADASTRE_MCP=true - INSTALL_THECLAWBAY=true cache-from: type=registry,ref=${{ env.CACHE_IMAGE }}:stack-release cache-to: type=registry,ref=${{ env.CACHE_IMAGE }}:stack-release,mode=max @@ -398,18 +396,6 @@ jobs: docker run --rm --entrypoint vogt "$CANDIDATE" --version docker run --rm --entrypoint vogt-engine "$CANDIDATE" --help >/dev/null docker run --rm --entrypoint vogt-verify-agent-clis "$CANDIDATE" - # A release is always an estate/prod image, so it must carry the AI - # clients and theclawbay. v0.3.0 shipped without claude or codex - # because this build passed INSTALL_CADASTRE_MCP=true but never - # INSTALL_AI_CLIENTS, so the Dockerfile default (false) stood and prod - # registered the "(protected)" templates it could not start. Running - # each CLI here is NFR-Q7: a build arg nothing asserts is a default - # waiting to be forgotten (#23 / WI-17). Flutter is intentionally - # absent — a release is the `lean` pod variant (#184). - for tool in claude codex theclawbay; do - docker run --rm --entrypoint "$tool" "$CANDIDATE" --version >/dev/null \ - || { echo "the release image is missing $tool" >&2; exit 1; } - done echo "both halves are present and start" - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 @@ -426,9 +412,7 @@ jobs: VOGT_SOURCE_REF=${{ github.ref_name }} VOGT_SOURCE_SHA=${{ github.sha }} VOGT_PRODUCT_VERSION=${{ github.ref_name }} - INSTALL_AI_CLIENTS=true INSTALL_CADASTRE_MCP=true - INSTALL_THECLAWBAY=true provenance: true sbom: true cache-from: type=registry,ref=${{ env.CACHE_IMAGE }}:stack-release diff --git a/CHANGELOG.md b/CHANGELOG.md index 42d86d6f..400117ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,21 +14,6 @@ git log rather than being reconstructed here. Nothing yet. -## [0.3.1] - 2026-08-29 - -### Fixed - -- The tagged release now builds the `vogt-stack` image with the Codex and - Claude Code CLIs (`INSTALL_AI_CLIENTS=true`) and `theclawbay`, matching the - `dev`/`prod` builds. v0.3.0 shipped without them: `release.yml` set - `INSTALL_CADASTRE_MCP=true` but never `INSTALL_AI_CLIENTS`, so production - registered the `Claude Code (protected)` and `Codex (protected)` session - templates yet could not start them. The release smoke test now runs each CLI - so the gap cannot silently reappear (#23). -- Production deployment resolves the signed release stack image by its - published tag (`vogt-stack:`, no `v` prefix), so the digest lookup no - longer fails for `v`-prefixed release tags. - ## [0.3.0] - 2026-08-28 The first release since the merged core+engine stack reached production. No diff --git a/mobile/package.json b/mobile/package.json index 9bfbf7f4..7273f734 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -1,6 +1,6 @@ { "name": "vogt-mobile", - "version": "0.3.1", + "version": "0.3.0", "private": true, "type": "module", "scripts": { diff --git a/pyproject.toml b/pyproject.toml index 901be297..8e7379a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vogt" -version = "0.3.1" +version = "0.3.0" description = "A product development environment for the AI era" readme = "README.md" requires-python = ">=3.11" diff --git a/src/vogt/__init__.py b/src/vogt/__init__.py index 7bfb8868..0d089ceb 100644 --- a/src/vogt/__init__.py +++ b/src/vogt/__init__.py @@ -2,6 +2,6 @@ from __future__ import annotations -__version__ = "0.3.1" +__version__ = "0.3.0" __all__ = ["__version__"] diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 86dd13ae..d3590e12 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -189,49 +189,6 @@ def test_the_dev_image_build_turns_the_ai_clients_on() -> None: ) -def test_the_release_image_build_turns_the_ai_clients_on() -> None: - """The tagged release *is* the prod image, and prod runs coding sessions. - - v0.3.0 shipped a `vogt-stack` image built with neither `claude` nor `codex`: - `release.yml` passed `INSTALL_CADASTRE_MCP=true` but never - `INSTALL_AI_CLIENTS`, so the Dockerfile default (false) stood and production - came up with the two "(protected)" templates registered but unstartable. - This is #23 one workflow over — the dev build (the sibling test) had learned - it and the release build had not. Unlike `build.yml`, `release.yml` only - ever runs on a tag, so the value is an unconditional `true`, not the ref - rule; both the candidate and the pushed build must carry it, or the image - that is smoke-tested is not the image that is published. - - Flutter stays absent on purpose: a release is the `lean` pod variant (#184). - """ - text = (WORKFLOWS / "release.yml").read_text(encoding="utf-8") - # Only the two args this test is about — the release build already carried - # INSTALL_CADASTRE_MCP (and the core image installs it too, so its count is - # not two). Both the stack candidate and the stack push must set these, or - # the image that is smoke-tested is not the image that is published. - for arg in ("INSTALL_AI_CLIENTS", "INSTALL_THECLAWBAY"): - wired = re.findall(rf"^\s+{arg}=true$", text, re.MULTILINE) - assert len(wired) == 2, ( - f"{arg}=true must reach both the candidate and the pushed build of " - f"the release stack image; found {len(wired)}" - ) - loop = re.search(r"for tool in ([^;]+); do", text) - assert loop, ( - "the release image's smoke test must loop over the tools the image " - "carries and run each one (NFR-Q7)" - ) - probed = set(loop.group(1).split()) - owed = {"claude", "codex", "theclawbay"} - assert owed <= probed, ( - "the release image's smoke test must ask the image for the clients " - f"(NFR-Q7); missing: {sorted(owed - probed)}" - ) - assert "flutter" not in probed, ( - "a release is the `lean` pod variant (#184); flutter is intentionally " - "not in the release image and must not be probed" - ) - - def test_the_image_has_no_default_listen_address() -> None: """NFR-D2: the image must not silently bind anything.""" text = _without_comments(DOCKERFILE.read_text(encoding="utf-8")) diff --git a/web/package.json b/web/package.json index ffeeea4f..61ccd2f8 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "vogt-web", - "version": "0.3.1", + "version": "0.3.0", "private": true, "type": "module", "scripts": { From ca81a9876142d61f73f713deb3ff2022d7480c7d Mon Sep 17 00:00:00 2001 From: thedancingdeveloper Date: Sat, 29 Aug 2026 04:19:18 +0000 Subject: [PATCH 07/12] docs(deploy): state that release images are CLI-free and the estate is operator-private MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reader following DEPLOYMENT.md could pin a plain release image for production and get a pod whose Claude Code / Codex session templates register but cannot start — the binaries are only ever added by the §3.2 VOGT_INSTALL_AI_CLIENTS build flag, which no published image sets. §3.2 now says so directly, and §7 states the boundary: the production desired-state and overlays live in the operator's own repository (#204), the signed release digests are CLI-free, and the maintainer's estate is a private deployment, not a turnkey scenario reproducible from this tree. --- docs/DEPLOYMENT.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index e0b74ec2..10a7e0cc 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -229,6 +229,15 @@ overrides. The image records the resolved versions and refuses to start when a persisted home volume would shadow an image-managed CLI (set `VOGT_AGENT_SHADOW_POLICY=warn` only for a deliberate user-local override). +This build-time flag is the only way the CLIs enter a deployment, and it +defaults to `false`. No published image carries them: the release `vogt` core +image runs the register alone, and the signed release digests promoted to +production (§7) are CLI-free by design. So a deployment that runs agent +sessions is one whose engine was built with `VOGT_INSTALL_AI_CLIENTS=true` — +pinning a plain release image instead leaves the `Claude Code (protected)` and +`Codex (protected)` session templates registered but unable to start, because +the `claude` and `codex` binaries are simply not in the image. + Be aware before you run it: the engine image is a **development pod**, not a hardened service image — it carries a writable home, `sudo`, optional agent CLIs, and an entrypoint that supports integrations this repository's @@ -367,6 +376,18 @@ a version tag creates signed, immutable artifacts; deployment selects the digest a production instance runs. A successful build or published image does not change production by itself. +The desired state a production instance runs — which digests, which overlays, +which host specifics — is owned by the operator's own deployment repository, +not this one (#204); this tree ships only the estate-neutral base and overlays, +never a turnkey production estate. Two consequences are worth stating plainly. +The signed release digests are CLI-free (§3.2), so a production engine that +runs agent sessions is one the operator built with +`VOGT_INSTALL_AI_CLIENTS=true` and published to its own registry — the release +`vogt`/`vogt-stack` digests are not that image. And the maintainer's own +production is one such private deployment, layering a private overlay on the +public base; it is not a supported drop-in scenario reproducible from this +repository alone. + ### 7.1 Promote `dev` to production Promotion is two explicit, fast-forward-only pull requests. First deploy the From 0324f79ae79e9d6bace10913a3bda2ff69665e3f Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 29 Aug 2026 09:04:17 +0000 Subject: [PATCH 08/12] fix(web): stop terminals doubling/tripling output from leaked sockets (#466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `connect()` reassigned `ws` without closing the previous socket, and several paths reach it while a socket is still open or connecting: the reactive park/resume effect, `onWake`, the cache-replay `.then()`, and — since the recent silent-socket work — `recycleSocket()`, which calls `ws.close()` *and* `scheduleReconnect(100)` so the socket's own delayed `close` event can spawn a second `connect()`. Every leftover socket keeps its `message` listener attached and writes the same PTY output into the one xterm instance, so lines render two or three times. Seen live on dev and prod. Make `connect()` the single owner of the socket: drop any existing socket first, then capture the new socket in a local `socket` and guard every handler with `if (ws !== socket) return;` so a replaced or parked socket's late open/message/close/error events are no-ops. One terminal now has exactly one live socket at a time. Only the racy leftover path closes a socket here; healthy sockets are untouched (all `connect()` callers already gate on a closed/absent socket or a prior park). PWA typecheck and the 817 web tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F4VhQKnLKBA847LSZpPPds --- web/src/Terminal.tsx | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/web/src/Terminal.tsx b/web/src/Terminal.tsx index 2c44c2d5..5cbef986 100644 --- a/web/src/Terminal.tsx +++ b/web/src/Terminal.tsx @@ -860,8 +860,26 @@ const TerminalView: Component = (props) => { clearCountdown(); setReconnectView(null); setStatusText("Loading terminal..."); - ws = openAttach(props.sessionId, outputPosition); - ws.addEventListener("open", () => { + // Never leave a second socket attached to this terminal. Several paths can + // reach connect() while a socket is still open or connecting (pane resume, + // wake, watchdog recycle, a delayed close that reschedules). A leftover + // socket keeps delivering the same PTY output, so its bytes get written on + // top of the live socket's and every line doubles or triples (#466). Drop + // any existing socket first; the `socket`-identity guards on the handlers + // below turn its late events into no-ops. + if (ws) { + const stale = ws; + ws = null; + try { + stale.close(); + } catch { + /* already closing */ + } + } + const socket = openAttach(props.sessionId, outputPosition); + ws = socket; + socket.addEventListener("open", () => { + if (ws !== socket) return; reconnect.recover(); watchdog.reset(); startWatchdog(); @@ -869,7 +887,8 @@ const TerminalView: Component = (props) => { flushPendingInput(); checkWatchdog(true); }); - ws.addEventListener("message", (ev) => { + socket.addEventListener("message", (ev) => { + if (ws !== socket) return; if (typeof ev.data === "string") { try { const ctrl = JSON.parse(ev.data) as @@ -952,7 +971,8 @@ const TerminalView: Component = (props) => { term?.write(buf); } }); - ws.addEventListener("close", () => { + socket.addEventListener("close", () => { + if (ws !== socket) return; stopWatchdog(); if (socketParked || isParked()) return; // Write the [disconnected] marker once at the start of the outage, not on @@ -966,7 +986,8 @@ const TerminalView: Component = (props) => { } scheduleReconnect(); }); - ws.addEventListener("error", () => { + socket.addEventListener("error", () => { + if (ws !== socket) return; // Browser fires both error + close; close handler is enough. }); } From a86b75a9d1d769d9cf9066f1d4fb8be47764cc26 Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 29 Aug 2026 09:04:17 +0000 Subject: [PATCH 09/12] fix(engine): recover lagging terminal clients in-band; coalesce bursts (#466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-selected terminal sessions were force-dropped and then flooded with a replay on switch. When a subscriber fell >1024 broadcast chunks behind, the outbound task sent a `Lag` frame and closed the socket; the client tore down and reattached, and the server replayed scrollback — a large/full replay that is heavy main-thread work, which stalled the neighbouring panes' sockets and made them lag too. The loop fed itself. Two changes to the outbound task, server-side only (the client already handles mid-stream snapshots and tracks its resume cursor): - Coalesce: drain the broadcast chunks already queued (`try_recv`) into one WebSocket frame, bounded at 256 KiB. A chatty session now wakes the client's single message-draining thread far fewer times, which is what was starving the other panes. - In-band lag recovery: on `Lagged`, re-snapshot from the client's exact last-delivered position and stream it as the normal SnapshotStart → payload → SnapshotDone sequence on the *same* socket — a `reset:false` delta when the cursor is still in the scrollback window, a `reset:true` reload when it has aged out. The socket never closes, so there is no reconnect/replay cascade. A circuit breaker (5 consecutive resyncs with no live send) falls back to the old Lag-and-close so a hopelessly-behind client can't drive unbounded resnapshotting. Pure `coalesce` helper is unit-tested; fmt, clippy (-D warnings) and the ws tests pass. Deliberately out of scope (follow-ups): decoupling client socket draining from the render thread (Web Worker) and pausing hidden panes' subscriptions. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F4VhQKnLKBA847LSZpPPds --- engine/server/src/ws.rs | 286 +++++++++++++++++++++++++++++++++++----- 1 file changed, 254 insertions(+), 32 deletions(-) diff --git a/engine/server/src/ws.rs b/engine/server/src/ws.rs index 0bbba2b7..3d3b1fa0 100644 --- a/engine/server/src/ws.rs +++ b/engine/server/src/ws.rs @@ -9,11 +9,14 @@ use axum::{ }; use futures_util::{SinkExt, StreamExt}; use serde::Deserialize; -use tokio::sync::{broadcast::error::RecvError, mpsc}; +use tokio::sync::{ + broadcast::error::{RecvError, TryRecvError}, + mpsc, +}; use uuid::Uuid; use vogt_engine_contract::{ClientControl, ServerControl}; -use crate::{app::AppState, auth}; +use crate::{app::AppState, auth, pty::OutputChunk, pty::Session}; #[derive(Debug, Deserialize)] pub struct AttachQuery { @@ -33,6 +36,21 @@ const SNAPSHOT_CHUNK: usize = 64 * 1024; /// dump from becoming a multi-megabyte editable command line inside the PTY. const MAX_INPUT_BYTES: usize = 64 * 1024; +/// Upper bound on the bytes coalesced into a single outbound frame. Bursty +/// output (agent token streams, TUI redraws, a build) arrives as many small +/// broadcast chunks; batching the ones already queued into one WebSocket frame +/// means a chatty session wakes the client's single message-draining thread far +/// fewer times, which is what starves the other panes' sockets under load +/// (#466). Bounded so one burst cannot build an unbounded frame. +const OUTBOUND_COALESCE_CAP: usize = 256 * 1024; + +/// How many in-band lag resyncs to attempt, with no normal live send in +/// between, before giving up and asking the client for a clean reattach. A +/// client that keeps falling behind every resync would otherwise drive +/// unbounded resnapshotting; the counter resets whenever live output flows +/// again (#466). +const MAX_CONSECUTIVE_RESYNCS: u32 = 5; + /// How long a freshly-upgraded socket has to send `{"type":"auth",...}` before /// we drop it. Keeps unauth clients from hanging on to a socket indefinitely. const AUTH_DEADLINE: Duration = Duration::from_secs(5); @@ -61,6 +79,100 @@ fn live_skip(chunk_pos: u64, chunk_len: usize, snap_pos: u64) -> Option { } } +/// Coalesce a run of consecutive broadcast chunks into the bytes to forward. +/// +/// `snap_pos` is the current dedup boundary: any part of a chunk at or before +/// it was already delivered (in the initial snapshot or a resync) and is +/// dropped via [`live_skip`]. Returns the concatenated not-yet-seen bytes and +/// the absolute position after the last chunk that contributed output — the +/// caller's new "sent up to here" cursor. When nothing new is forwarded the +/// returned position is `snap_pos`, so the caller can keep its existing cursor. +fn coalesce(snap_pos: u64, chunks: &[OutputChunk]) -> (Vec, u64) { + let mut out = Vec::new(); + let mut end = snap_pos; + for chunk in chunks { + if let Some(skip) = live_skip(chunk.pos, chunk.data.len(), snap_pos) { + out.extend_from_slice(&chunk.data[skip..]); + end = chunk.pos + chunk.data.len() as u64; + } + } + (out, end) +} + +/// Re-synchronise a lagging client in-band, on the same socket, instead of +/// dropping it and forcing a reconnect + replay (the cascade in #466). +/// +/// Snapshots from `from_pos` (the client's last delivered position) and streams +/// it as the same `SnapshotStart` → payload → `SnapshotDone` sequence a fresh +/// attach uses. When the cursor is still inside the scrollback window the server +/// sends a `reset: false` delta (the client appends it); when it has aged out it +/// sends a `reset: true` full snapshot (the client clears and reloads). Either +/// way the socket stays open. Returns the new absolute position the client is +/// synchronised to, or `Err(())` if the socket died mid-send. +async fn send_resync(sink: &mut S, session: &Session, from_pos: u64) -> Result +where + S: SinkExt + Unpin, +{ + let (payload, pos, reset) = session.snapshot_for_attach(Some(from_pos)); + let meta = ServerControl::SnapshotStart { + session_id: Some(session.id), + scrollback_bytes: payload.len() as u64, + scrollback_pos: pos, + reset, + }; + sink.send(Message::Text(serde_json::to_string(&meta).unwrap().into())) + .await + .map_err(|_| ())?; + for chunk in payload.chunks(SNAPSHOT_CHUNK) { + sink.send(Message::Binary(chunk.to_vec().into())) + .await + .map_err(|_| ())?; + } + sink.send(Message::Text( + serde_json::to_string(&ServerControl::SnapshotDone) + .unwrap() + .into(), + )) + .await + .map_err(|_| ())?; + Ok(pos) +} + +/// Outcome of an in-band lag recovery attempt. +enum Recovery { + /// Client re-synchronised to this absolute position on the same socket. + Resynced(u64), + /// Too many resyncs without progress — fall back to a clean reattach. + GiveUp, +} + +/// Attempt an in-band resync, tripping the circuit breaker after too many in a +/// row. Increments `resyncs`; the caller resets it whenever live output flows. +async fn recover_from_lag( + sink: &mut S, + session: &Session, + from_pos: u64, + resyncs: &mut u32, +) -> Recovery +where + S: SinkExt + Unpin, +{ + *resyncs += 1; + if *resyncs > MAX_CONSECUTIVE_RESYNCS { + let lag = ServerControl::Lag { + note: "client too slow; reattach".into(), + }; + let _ = sink + .send(Message::Text(serde_json::to_string(&lag).unwrap().into())) + .await; + return Recovery::GiveUp; + } + match send_resync(sink, session, from_pos).await { + Ok(pos) => Recovery::Resynced(pos), + Err(()) => Recovery::GiveUp, + } +} + pub async fn attach( ws: WebSocketUpgrade, State(state): State>, @@ -242,8 +354,21 @@ async fn handle_socket( } }); - // Outbound: broadcast chunks → client, skipping anything already in the snapshot. + // Outbound: broadcast chunks → client. Bursts are coalesced into fewer + // frames, and a lagging client is recovered in-band rather than dropped + // (#466). + let outbound_session = Arc::clone(&session); let outbound = tokio::spawn(async move { + // `snap_pos` is the dedup boundary handed to `live_skip`; `sent_pos` is + // the absolute offset the client has been streamed up to. Both jump + // forward after an in-band resync. + let mut snap_pos = snap_pos; + let mut sent_pos = snap_pos; + // Consecutive resyncs with no normal live send in between. Reset on any + // live output; a client that trips the ceiling is handed back to a + // clean reattach instead of driving unbounded resnapshotting. + let mut resyncs: u32 = 0; + loop { tokio::select! { Some(control) = control_rx.recv() => { @@ -255,38 +380,87 @@ async fn handle_socket( break; } } - result = rx.recv() => match result { - Ok(chunk) => { - // Skip anything already delivered in the replayed snapshot; - // for a chunk straddling the snapshot boundary, send only - // the not-yet-seen tail. - let Some(skip) = live_skip(chunk.pos, chunk.data.len(), snap_pos) else { - continue; - }; - let send_buf = if skip == 0 { - chunk.data - } else { - chunk.data.slice(skip..) + result = rx.recv() => { + let first = match result { + Ok(chunk) => chunk, + // Lagged straight from the blocking recv: recover in-band. + Err(RecvError::Lagged(_)) => { + match recover_from_lag( + &mut sink, + &outbound_session, + sent_pos, + &mut resyncs, + ) + .await + { + Recovery::Resynced(pos) => { + snap_pos = pos; + sent_pos = pos; + continue; + } + Recovery::GiveUp => break, + } + } + Err(RecvError::Closed) => break, }; - if sink - .send(Message::Binary(send_buf.to_vec().into())) - .await - .is_err() - { + + // Drain everything already queued so a burst becomes one + // frame, not dozens — each frame is a client main-thread + // wakeup. Bounded so one session can't build a huge frame. + let mut drained = vec![first]; + let mut queued = drained[0].data.len(); + let mut lagged = false; + let mut closed = false; + while queued < OUTBOUND_COALESCE_CAP { + match rx.try_recv() { + Ok(next) => { + queued += next.data.len(); + drained.push(next); + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Lagged(_)) => { + lagged = true; + break; + } + Err(TryRecvError::Closed) => { + closed = true; + break; + } + } + } + + let (frame, end) = coalesce(snap_pos, &drained); + if end > sent_pos { + sent_pos = end; + } + if !frame.is_empty() { + if sink.send(Message::Binary(frame.into())).await.is_err() { + break; + } + // Live output flowed: the client is keeping up again. + resyncs = 0; + } + + if closed { break; } + if lagged { + match recover_from_lag( + &mut sink, + &outbound_session, + sent_pos, + &mut resyncs, + ) + .await + { + Recovery::Resynced(pos) => { + snap_pos = pos; + sent_pos = pos; + } + Recovery::GiveUp => break, + } + } } - Err(RecvError::Lagged(_n)) => { - let lag = ServerControl::Lag { - note: "client too slow; reattach".into(), - }; - let _ = sink - .send(Message::Text(serde_json::to_string(&lag).unwrap().into())) - .await; - break; - } - Err(RecvError::Closed) => break, - }, } } }); @@ -300,7 +474,55 @@ async fn handle_socket( #[cfg(test)] mod tests { - use super::live_skip; + use super::{coalesce, live_skip}; + use crate::pty::OutputChunk; + use bytes::Bytes; + + fn chunk(pos: u64, data: &'static [u8]) -> OutputChunk { + OutputChunk { + pos, + data: Bytes::from_static(data), + } + } + + #[test] + fn coalesce_concatenates_consecutive_new_chunks() { + // A burst of small chunks all past the boundary becomes one buffer, and + // the returned cursor is the end of the last chunk. + let chunks = [chunk(10, b"abc"), chunk(13, b"de"), chunk(15, b"f")]; + let (out, end) = coalesce(10, &chunks); + assert_eq!(&out, b"abcdef"); + assert_eq!(end, 16); + } + + #[test] + fn coalesce_drops_chunks_already_below_the_boundary() { + // The first chunk is fully within an earlier snapshot (pos+len <= 10); + // only the not-yet-seen chunks survive, and the cursor tracks them. + let chunks = [chunk(4, b"OLD"), chunk(10, b"new")]; + let (out, end) = coalesce(10, &chunks); + assert_eq!(&out, b"new"); + assert_eq!(end, 13); + } + + #[test] + fn coalesce_trims_a_chunk_straddling_the_boundary() { + // Chunk [8,13) with boundary 10: only bytes [10,13) are new. + let chunks = [chunk(8, b"XXabc")]; + let (out, end) = coalesce(10, &chunks); + assert_eq!(&out, b"abc"); + assert_eq!(end, 13); + } + + #[test] + fn coalesce_forwards_nothing_and_keeps_the_cursor_when_all_seen() { + // Everything is at or before the boundary: no bytes, cursor unchanged so + // the caller keeps its existing sent position. + let chunks = [chunk(0, b"seen"), chunk(4, b"more")]; + let (out, end) = coalesce(8, &chunks); + assert!(out.is_empty()); + assert_eq!(end, 8); + } #[test] fn chunk_fully_in_snapshot_is_dropped() { From b02e87ae672f93a8c257040a833b5b86b512f411 Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 29 Aug 2026 08:42:16 +0000 Subject: [PATCH 10/12] security(voice): bump ureq/url to clear rustls-webpki + idna advisories (#459) Relax the ureq/url version floors in voice/tts/Cargo.toml and update the shared voice/Cargo.lock so the sidecar no longer resolves the vulnerable transitive crates: - rustls-webpki 0.102.8 -> 0.103.15 (clears RUSTSEC-2026-0104 high panic-on-CRL, RUSTSEC-2026-0049, RUSTSEC-2026-0098, RUSTSEC-2026-0099) - idna 0.4.0 -> 1.1.0 (clears RUSTSEC-2024-0421) - ureq 2.9.7 -> 2.12.1 (kept on the 2.x line; no ureq-3 API use) - url 2.4.1 -> 2.5.8 ureq/url are not referenced from tts/src; they exist only to steer the shared lock. cargo-audit against voice/Cargo.lock goes from 5 vulnerabilities to 0 (only the pre-existing, unrelated audiopus_sys "unmaintained" notice remains). Voice CI (fmt, clippy -D warnings, test) passes locally with the updated lock. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F4VhQKnLKBA847LSZpPPds --- voice/Cargo.lock | 283 +++++++++++++++++++++++++++++++++++++------ voice/tts/Cargo.toml | 9 +- 2 files changed, 252 insertions(+), 40 deletions(-) diff --git a/voice/Cargo.lock b/voice/Cargo.lock index 0799d90b..9ac5d195 100644 --- a/voice/Cargo.lock +++ b/voice/Cargo.lock @@ -413,6 +413,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "either" version = "1.18.0" @@ -724,14 +735,108 @@ dependencies = [ "tower-service", ] +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" -version = "0.4.0" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "icu_normalizer", + "icu_properties", ] [[package]] @@ -795,6 +900,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "lock_api" version = "0.4.14" @@ -1056,6 +1167,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1202,11 +1322,12 @@ dependencies = [ [[package]] name = "rustls" -version = "0.22.4" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "log", + "once_cell", "ring", "rustls-pki-types", "rustls-webpki", @@ -1225,9 +1346,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.102.8" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -1412,6 +1533,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1561,6 +1688,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tar" version = "0.4.46" @@ -1615,20 +1753,15 @@ dependencies = [ ] [[package]] -name = "tinyvec" -version = "1.12.0" +name = "tinystr" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ - "tinyvec_macros", + "displaydoc", + "zerovec", ] -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.53.1" @@ -1752,27 +1885,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - [[package]] name = "untrusted" version = "0.9.0" @@ -1781,9 +1899,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "2.9.7" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d11a831e3c0b56e438a28308e7c810799e3c118417f342d30ecec080105395cd" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ "base64", "flate2", @@ -1791,7 +1909,6 @@ dependencies = [ "once_cell", "rustls", "rustls-pki-types", - "rustls-webpki", "socks", "url", "webpki-roots 0.26.11", @@ -1799,15 +1916,22 @@ dependencies = [ [[package]] name = "url" -version = "2.4.1" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2079,6 +2203,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "xattr" version = "1.6.1" @@ -2089,12 +2219,89 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/voice/tts/Cargo.toml b/voice/tts/Cargo.toml index 1213913e..f01b353f 100644 --- a/voice/tts/Cargo.toml +++ b/voice/tts/Cargo.toml @@ -23,8 +23,13 @@ home = "=0.5.9" half = "=2.4.1" rustls-pki-types = "=1.12.0" zeroize = "=1.8.1" -ureq = "=2.9.7" -url = "=2.4.1" +# Version floors that steer the shared voice/Cargo.lock past the vulnerable +# transitive crates (issue #459): ureq >=2.12 pulls rustls-webpki >=0.103.13 +# (clears GHSA-82j2-j2ch-gfr8 et al.) and url >=2.5 pulls idna >=1.0 +# (clears GHSA-h97m-ww89-6jmq). Kept on the ureq 2.x line so no source uses +# the ureq 3.x API. Neither crate is referenced directly from tts/src. +ureq = "2.12" +url = "2.5" tempfile = "=3.23.0" tokio = { version = "1", features = ["io-util", "macros", "process", "rt", "time"] } thiserror = "2" From fa35c346f736dbc0623d7f5a3601e7d368fb28d3 Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 29 Aug 2026 08:42:16 +0000 Subject: [PATCH 11/12] ci(promote): use the GitHub REST API instead of the gh CLI (#460) promote.yml has never succeeded: the self-hosted [self-hosted] runner has no gh CLI, so `gh api` / `gh run list` / `gh pr list` / `gh pr create` all die with `gh: command not found` (exit 127) before any gate runs. Replace every gh call with a small curl+jq `api()` helper hitting $GITHUB_API_URL (curl and jq are both present on the runner and already used by deploy-production.yml). PR creation still authenticates as VOGT_PROMOTION_TOKEN so the opened PR receives its normal checks. Also add `actions: read` to the job permissions: the dev deployment-receipt gate reads actions/workflows runs and run artifacts, which the previous permission block (contents/checks/pull-requests only) did not grant. That gap was never reached before because the run failed earlier on the missing gh. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F4VhQKnLKBA847LSZpPPds --- .github/workflows/promote.yml | 56 +++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 74a9ede3..bf5b6ea4 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -23,6 +23,7 @@ on: permissions: contents: read checks: read + actions: read pull-requests: write jobs: @@ -71,15 +72,25 @@ jobs: exit 1 } + # The self-hosted runner has curl and jq but not the gh CLI + # (issue #460), so promotion queries the GitHub REST API directly. + api() { + curl -fsS \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + } + # A green source branch is a prerequisite, not a substitute for the # target PR checks. Select the newest completed run for each check so # an old green run cannot satisfy this gate. Documentation-only # commits intentionally have no image build; a release tag builds # the deployable artifact later. for check in ci runner-policy; do - conclusion="$(gh api \ - "repos/$GITHUB_REPOSITORY/commits/$source_sha/check-runs" \ - --jq "[.check_runs[] | select(.name == \"$check\" and .status == \"completed\")] | sort_by(.completed_at) | last | .conclusion")" + conclusion="$(api \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/commits/$source_sha/check-runs?per_page=100" \ + | jq -r "[.check_runs[] | select(.name == \"$check\" and .status == \"completed\")] | sort_by(.completed_at) | last | .conclusion")" [ "$conclusion" = success ] || { echo "::error::$SOURCE@$source_sha does not have a successful $check check (got ${conclusion:-missing})." exit 1 @@ -88,13 +99,16 @@ jobs: if [ "$SOURCE" = dev ]; then receipt_name="vogt-dev-deployment-receipt-${source_sha}" - receipt_run="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow deploy-dev.yml --json databaseId,headSha,status,conclusion --limit 50 \ - --jq ".[] | select(.headSha == \"$source_sha\" and .status == \"completed\" and .conclusion == \"success\") | .databaseId" | head -n 1)" + receipt_run="$(api \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/deploy-dev.yml/runs?per_page=50" \ + | jq -r ".workflow_runs[] | select(.head_sha == \"$source_sha\" and .status == \"completed\" and .conclusion == \"success\") | .id" | head -n 1)" [ -n "$receipt_run" ] || { echo "::error::dev@$source_sha has no successful verified dev deployment receipt ($receipt_name)." exit 1 } - artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$receipt_run/artifacts" --jq ".artifacts[] | select(.name == \"$receipt_name\") | .id" | head -n 1)" + artifact_id="$(api \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$receipt_run/artifacts" \ + | jq -r ".artifacts[] | select(.name == \"$receipt_name\") | .id" | head -n 1)" [ -n "$artifact_id" ] || { echo "::error::successful dev deployment has no matching receipt artifact." exit 1 @@ -122,9 +136,22 @@ jobs: run: | set -euo pipefail : "${GH_TOKEN:?Configure VOGT_PROMOTION_TOKEN with pull-request write access}" - existing="$(gh pr list --repo "$GITHUB_REPOSITORY" \ - --head "$SOURCE" --base "$TARGET" --state open \ - --json number,url --jq '.[0] // empty')" + + # Talk to the REST API directly; the gh CLI is not on the runner + # (issue #460). This step authenticates as VOGT_PROMOTION_TOKEN so the + # created PR receives its normal checks. + api() { + curl -fsS \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + } + + owner="${GITHUB_REPOSITORY%%/*}" + existing="$(api \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls?head=$owner:$SOURCE&base=$TARGET&state=open" \ + | jq -r '.[0].html_url // empty')" if [ -n "$existing" ]; then echo "An open promotion PR already exists: $existing" exit 0 @@ -148,6 +175,11 @@ jobs: in this repository pushes a protected branch or deploys production as a side effect of this PR. EOF - gh pr create --repo "$GITHUB_REPOSITORY" --head "$SOURCE" --base "$TARGET" \ - --title "Promote $SOURCE → $TARGET (${SOURCE_SHA:0:7})" \ - --body-file "$body" + jq -n \ + --arg title "Promote $SOURCE → $TARGET (${SOURCE_SHA:0:7})" \ + --arg head "$SOURCE" \ + --arg base "$TARGET" \ + --rawfile body "$body" \ + '{title: $title, head: $head, base: $base, body: $body}' \ + | api -X POST "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls" --data @- \ + | jq -r '"Opened promotion PR: " + .html_url' From 0a010ca8985d7fbd0dff6491a448c1f3a3ad3488 Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 29 Aug 2026 09:17:05 +0000 Subject: [PATCH 12/12] test(promote): assert the REST promotion path, not the removed gh calls (#460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_promotion_is_fast_forward_only_and_never_pushes_a_branch pinned the old `gh pr create` string; #460 replaced the gh CLI with curl+REST. Update the assertions to the new contract — the PR is opened via POST .../pulls with the promotion token, `gh pr create`/`gh api`/`gh run list` are gone, and `actions: read` is granted for the receipt lookups — while keeping the never-pushes-a-branch and fast-forward-only invariants. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F4VhQKnLKBA847LSZpPPds --- tests/test_deploy.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_deploy.py b/tests/test_deploy.py index d3590e12..954c265e 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -1508,7 +1508,15 @@ def test_promotion_is_fast_forward_only_and_never_pushes_a_branch() -> None: ) assert environment in workflow assert "git merge-base --is-ancestor" in workflow - assert "gh pr create" in workflow + # #460: the self-hosted runner has no gh CLI, so the promotion PR is opened + # through the GitHub REST API (POST .../pulls, authenticated with the + # promotion token), never with `gh` and never by pushing a branch. + assert "gh pr create" not in workflow + assert "gh api" not in workflow + assert "gh run list" not in workflow + assert "/repos/$GITHUB_REPOSITORY/pulls" in workflow + assert "-X POST" in workflow + assert "actions: read" in workflow assert "VOGT_PROMOTION_TOKEN" in workflow assert "GITHUB_TOKEN" in workflow assert "contents: write" not in workflow