From f09e1d726ca7e224b35ba3027eee1181d2cd21d8 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper Date: Sat, 29 Aug 2026 03:21:02 +0000 Subject: [PATCH 01/26] 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 76e8a7da3878d1f79ea31c1343f9e428cff7e318 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper Date: Sat, 29 Aug 2026 03:29:46 +0000 Subject: [PATCH 02/26] 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 fddb7510d317c53c1cef1404beb220123b5dc50a Mon Sep 17 00:00:00 2001 From: thedancingdeveloper Date: Sat, 29 Aug 2026 03:38:16 +0000 Subject: [PATCH 03/26] 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 34656f84288ff209fbdf772ba2ef78d660e7eb24 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper Date: Sat, 29 Aug 2026 04:01:08 +0000 Subject: [PATCH 04/26] 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 088d0bfc752442fbfa3ebb4c0ac8bb795f6c3caf Mon Sep 17 00:00:00 2001 From: thedancingdeveloper Date: Sat, 29 Aug 2026 04:01:08 +0000 Subject: [PATCH 05/26] 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 990ac47fb7c7a9e4c74997cea208cafa21a8c91a Mon Sep 17 00:00:00 2001 From: thedancingdeveloper Date: Sat, 29 Aug 2026 04:01:08 +0000 Subject: [PATCH 06/26] 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 fcd6fb5d3c05f41260799a308ae4814ba966bc39 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper Date: Sat, 29 Aug 2026 04:19:18 +0000 Subject: [PATCH 07/26] 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 d5e1c5d15ec1029a4152f1cdb0dd729b6aa07871 Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 29 Aug 2026 09:04:17 +0000 Subject: [PATCH 08/26] 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 0263594917da13c8715a81e24a53660bd3528104 Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 29 Aug 2026 09:04:17 +0000 Subject: [PATCH 09/26] 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 4299a7799b0d01d05d2bd90419be440dda668f6f Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 29 Aug 2026 08:42:16 +0000 Subject: [PATCH 10/26] 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 6ff837ca0a88dec0ac8891ba8aa013f701f9adc2 Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 29 Aug 2026 08:42:16 +0000 Subject: [PATCH 11/26] 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 26ff9258804b30456411e8979e8f9744bc657c71 Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 29 Aug 2026 09:17:05 +0000 Subject: [PATCH 12/26] 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 From bf5c0af93baeb5a67060f09a194f86f2de970d81 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:37:07 +0000 Subject: [PATCH 13/26] fix(web): launch session presets without an unwanted name prompt Picking a preset (e.g. Shell) fired promptUser unconditionally even though buildDefaultSessionName had already produced a valid default name, contradicting the documented "no name prompt unless Shift" behaviour in terminalNaming.ts. Create the session immediately with the default name and only prompt when Shift is held on the preset, matching the + Session action. Fixes #472 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BMbeugEzMF6K26Hq3X3dsh --- web/src/App.tsx | 17 ++++++++++++++--- web/src/Sessions.tsx | 6 ++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 572ad7b2..ef588f19 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -817,10 +817,19 @@ const App: Component = () => { return undefined; }; - const launchTemplateDirect = async (template: SessionTemplate) => { + const launchTemplateDirect = async ( + template: SessionTemplate, + // Presets create immediately with their computed default name (e.g. + // `shell-123`), matching the "no name prompt unless Shift" convention of + // the + Session action; hold Shift on the preset to be asked for a name. + promptForName = false, + ) => { const context = await resolveTemplateContext(activeTemplatePath()); const suggested = buildDefaultSessionName(template, context); - const name = await promptUser("New session from preset", suggested, "name"); + let name: string | null = suggested; + if (promptForName) { + name = await promptUser("New session from preset", suggested, "name"); + } if (!name) return; try { const launch = resolveTemplateLaunch(template, context, name); @@ -1743,7 +1752,9 @@ const App: Component = () => { void onCreate(undefined, undefined, promptForName) } sessionTemplates={allTemplates()} - onLaunchTemplate={(template) => void launchTemplateDirect(template)} + onLaunchTemplate={(template, promptForName) => + void launchTemplateDirect(template, promptForName) + } >
{/* The terminal is xterm and the editor is Monaco; both are diff --git a/web/src/Sessions.tsx b/web/src/Sessions.tsx index 8a788183..2878d8c9 100644 --- a/web/src/Sessions.tsx +++ b/web/src/Sessions.tsx @@ -38,7 +38,8 @@ interface Props { /** Presets the overview offers when there are no sessions to list (#233). */ sessionTemplates?: SessionTemplate[]; /** Launch one of those presets straight into its terminal. */ - onLaunchTemplate?: (template: SessionTemplate) => void; + /** Launch a preset. `promptForName` (Shift held) asks for a name first. */ + onLaunchTemplate?: (template: SessionTemplate, promptForName?: boolean) => void; } export const SessionTools: Component = (props) => ( @@ -322,7 +323,8 @@ const Sessions: Component = (props) => {
  • Metadata filters apply to loaded pages. Output search runs server-wide across the full archive. + 0}> + {" "}Live sessions are listed here, but their output is not yet in the search index. +
    @@ -815,11 +868,9 @@ const History: Component = (props) => { when={filteredSessions().length > 0} fallback={
    - {sessions().length === 0 - ? liveSessionNote() - ? `No archived sessions. ${liveSessionNote()}` - : "No archived sessions." - : "No loaded sessions match these filters."} + {mergedSessions().length === 0 + ? "No sessions yet." + : "No sessions match these filters."}
    } > @@ -836,6 +887,17 @@ const History: Component = (props) => {
    {session.name} + + {session.live && session.exit_code === null + ? "Live" + : "Exited"} + Pinned @@ -882,7 +944,7 @@ const History: Component = (props) => {
    Select an archived session to inspect it.
    } + fallback={
    Select a session to inspect it.
    } > {(message) => ( @@ -918,14 +980,19 @@ const History: Component = (props) => { - - + {/* Export and Delete act on the archive record, which a + live session does not have yet — hide them until it + exits and is archived (#477). */} + + + +
    @@ -969,7 +1036,11 @@ const History: Component = (props) => {
    Replay preview -
    Tail view of the archived raw terminal log.
    +
    + {selectedIsLive() + ? "Tail view of the live session's raw terminal log." + : "Tail view of the archived raw terminal log."} +
    - activeSend(d)} - onCopy={() => void activeCopy()} - onPaste={() => void activePaste()} - onSelectAll={() => activeSelectAll()} - onFocusComposer={() => activeFocusComposer()} - /> +
    + activeSend(d)} + onCopy={() => void activeCopy()} + onPaste={() => void activePaste()} + onSelectAll={() => activeSelectAll()} + onFocusComposer={() => activeFocusComposer()} + /> +
    -