Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -378,13 +378,26 @@ jobs:
load: true
tags: ${{ env.STACK_IMAGE }}:smoke-test
labels: ${{ steps.meta.outputs.labels }}
# `#` inside a `|` block scalar is literal content, not a YAML
# comment, and build-push-action parses the input without a comment
# option — so a note in there reaches buildx as a build arg. It goes
# here instead, as it does in `build.yml`.
#
# The generic shape: the agent CLIs the product exists to run, and
# none of the maintainer's estate integrations. A release is the
# public artifact, so it carries what a consumer needs to run a
# session and nothing that only means something inside one estate.
# This is a third shape deliberately chosen, not a drift toward
# `build.yml`'s: those dev/prod branch builds keep the estate rule
# and add cadastre + theclawbay on top of this same base.
build-args: |
POD_BASE_IMAGE=${{ needs.pod-base.outputs.image }}
CORE_IMAGE=${{ env.IMAGE }}@${{ needs.image.outputs.digest }}
VOGT_SOURCE_REF=${{ github.ref_name }}
VOGT_SOURCE_SHA=${{ github.sha }}
VOGT_PRODUCT_VERSION=${{ github.ref_name }}
INSTALL_CADASTRE_MCP=true
INSTALL_AI_CLIENTS=true
INSTALL_CADASTRE_MCP=false
cache-from: type=registry,ref=${{ env.CACHE_IMAGE }}:stack-release
cache-to: type=registry,ref=${{ env.CACHE_IMAGE }}:stack-release,mode=max

Expand All @@ -398,6 +411,30 @@ jobs:
docker run --rm --entrypoint vogt-verify-agent-clis "$CANDIDATE"
echo "both halves are present and start"

# `vogt-verify-agent-clis` above does NOT prove the CLIs are here. It
# is a *shadow* detector: `check_tool` returns 0 when the system path
# does not exist, `check_package` returns 0 unless the binary is
# executable, and `agent-versions.resolved` is written unconditionally
# — so it is green on a CLI-free image. It answers "is a persisted
# $HOME copy shadowing the image's?", not "is there an image copy?".
#
# The release now carries the clients by decision, and a build arg
# that nothing executes is a default waiting to be forgotten — which
# is #23 exactly: INSTALL_AI_CLIENTS defaulted to false, no build
# turned it on, `vogt-dev` ran without claude or codex and nothing was
# red. Running them is the only thing that distinguishes a generic
# release from a silent reversion to the CLI-free one.
#
# Ungated, unlike `build.yml`'s equivalent: every release is the
# generic shape, so there is no ref to condition on. `flutter` and
# `theclawbay` are absent from the list on purpose — a release is the
# `lean` pod base and carries no estate integrations.
for tool in claude codex; do
docker run --rm --entrypoint "$tool" "$CANDIDATE" --version >/dev/null \
|| { echo "the release image is missing $tool" >&2; exit 1; }
done
echo "the release image carries the agent CLIs"

- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
id: push
with:
Expand All @@ -406,13 +443,18 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Identical to the candidate's, so the pushed image is the layers the
# smoke test just ran rather than a rebuild that resembles them. The
# two lists drifting apart is how an image gets smoke-tested in one
# shape and published in another.
build-args: |
POD_BASE_IMAGE=${{ needs.pod-base.outputs.image }}
CORE_IMAGE=${{ env.IMAGE }}@${{ needs.image.outputs.digest }}
VOGT_SOURCE_REF=${{ github.ref_name }}
VOGT_SOURCE_SHA=${{ github.sha }}
VOGT_PRODUCT_VERSION=${{ github.ref_name }}
INSTALL_CADASTRE_MCP=true
INSTALL_AI_CLIENTS=true
INSTALL_CADASTRE_MCP=false
provenance: true
sbom: true
cache-from: type=registry,ref=${{ env.CACHE_IMAGE }}:stack-release
Expand Down
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,23 @@ the write API is isolated, so nothing there persists and no sign-in is needed:

## Run it

The stack is two Compose files layered together: the base
(`deploy/vogt.compose.yml`) defines the core service, and the engine overlay
(`deploy/engine.overlay.yml`) adds the Rust engine — the PWA front end — in
front of it. No engine image is published, so that overlay always builds one
from this checkout.
There are three shapes, and they trade containment against convenience:

| | What it is | Web UI | Build |
|---|---|---|---|
| **Core only** | `deploy/vogt.compose.yml` — the API, hardened | no | no |
| **Core + engine** | the above + `deploy/engine.overlay.yml` | yes | the engine |
| **All-in-one** | `deploy/stack.compose.yml` — one published image | yes | no |

The first two keep the core in a hardened container: slim base, `read_only`,
all capabilities dropped. The all-in-one is a **development pod** — writable
home, `sudo`, sshd, and the `claude` and `codex` CLIs — because it exists to
run coding agents, and an agent session needs a machine. Pick it where you
would run a dev box, not where you would run a hardened service.
[`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) §1.1 lays the three out in full.

No image of the engine alone is published, so the overlay path always builds
one from this checkout; the all-in-one image carries it with a core inside.

The quickstart below **builds the core from this checkout** with the
one-service build overlay `deploy/vogt.build.yml`, a path that works without
Expand Down
168 changes: 168 additions & 0 deletions deploy/stack.compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# The all-in-one Vogt stack: core, engine and PWA in one container.
#
# cp deploy/stack.env.example deploy/.env # then fill in ENGINE_TOKEN
# openssl rand -hex 32 > deploy/vogt-core-token
# docker compose -f deploy/stack.compose.yml up -d --wait
#
# There is no `--build`: the published image already carries all three.
#
# This is a *base*, not an overlay. It stands alone and does not layer onto
# `vogt.compose.yml` — that file runs a core, and so does this image, so
# combining them would run two.
#
# ── Which shape is this, and do you want it? ────────────────────────────────
#
# Three supported shapes, and this is the least contained of them:
#
# core only (`vogt.compose.yml`) hardened; no UI, no sessions
# core + engine (`engine.overlay.yml`) core stays hardened; engine builds
# from the checkout
# the AIO (this file) one container, no build, full pod
#
# The AIO is a **development pod**, and that is a deliberate posture rather
# than an oversight. It carries a writable home, passwordless `sudo`, an SSH
# server, a Docker CLI that talks to a socket you may mount, and the `claude`
# and `codex` agent CLIs. It cannot run `read_only`, and it does not drop
# capabilities. The core-only image is hardened by construction — slim base,
# `nologin`, `read_only`, `cap_drop: [ALL]`, one writable volume — and none of
# that survives being lifted into a pod that exists to run arbitrary agent
# sessions. `engine/Dockerfile`'s header says why the two images are kept
# separate: collapsing them would quietly delete Vogt's containment story
# under an unchanged image name.
#
# So: run this where you would run a dev box, not where you would run a
# hardened service. If you want the containment, run the core alone and put
# the engine somewhere you trust it.
#
# Nothing here names a host, a path, or a tailnet. Every value an operator must
# choose is a `${VAR}`; where one is genuinely required the reference fails the
# deploy with a message rather than guessing.

services:
vogt:
# Digest-pin this for a real deployment (NFR-D10) — a tag can move, and
# the whole point of a signed release is that the bytes cannot. The default
# is a tag so the file is runnable as-is.
#
# Pin the *release* family (`X.Y.Z`, `latest`). The `dev-<sha>` and
# `prod-<sha>` tags in the same repository are the maintainer's own pods:
# same CLIs, plus estate integrations that address one estate's
# infrastructure and mean nothing outside it. See DEPLOYMENT.md §7.
image: ${VOGT_STACK_IMAGE:-ghcr.io/thedancingdeveloper-org/vogt-stack:0.4.0}
restart: unless-stopped

# tini as PID 1 reaps the processes agent sessions leave behind. The
# entrypoint deliberately does *not* adopt a supervision framework, and
# this is the half of that decision the compose owns.
init: true

# The engine is the only way in (NFR-D11). The core listens on loopback
# *inside* the container and is never published — the entrypoint enforces
# that rather than trusting this comment, refusing to start if
# `VOGT_CORE_URL` names anything but a loopback address.
#
# Loopback on the host too, unless told otherwise: this file will not put a
# pod carrying sudo and agent CLIs on a network interface because nobody
# said to. Set `ENGINE_BIND` to a real address to expose it, and put
# something that terminates TLS in front.
ports:
- "${ENGINE_BIND:-127.0.0.1}:${ENGINE_PORT:-8910}:8910"

environment:
# The engine's own listen socket, inside the container's namespace — all
# interfaces so the published port above can reach it. Fixed, not an
# exposure decision: `ENGINE_BIND` in `.env` decides exposure, on the
# host side of the port mapping.
ENGINE_BIND: "0.0.0.0:8910"
# The engine's bearer token (at least 16 characters), required for every
# API and WebSocket call. Keep it in `deploy/.env` (git-ignored) rather
# than your shell.
ENGINE_TOKEN: "${ENGINE_TOKEN:?Set ENGINE_TOKEN in deploy/.env — the engine bearer token, at least 16 characters}"
# The address clients reach the front door at. The engine cannot
# advertise an address it is not told, so set it once the stack is
# reachable at a stable URL.
ENGINE_PUBLIC_URL: "${ENGINE_PUBLIC_URL:-}"

# One value both starts the core and tells the engine where to proxy.
# Loopback means "the core is ours to run", so the entrypoint supervises
# `vogt init && vogt serve` here; the engine proxies `/api/vogt`, `/mcp`
# and `/ui-legacy` to the same address. Deriving one from the other is
# what stops a front door confidently pointed at a port nothing serves.
VOGT_CORE_URL: "http://127.0.0.1:8000"
# The token the engine presents on `/api/vogt`; the core adopts the same
# file at `init` if it does not already hold one. Both halves reading one
# file is the whole bootstrap mechanism — no second deploy. Brokered as a
# file, never a bare value, so it stays out of every `docker inspect`.
VOGT_CORE_TOKEN_FILE: /run/secrets/vogt_core_token
VOGT_BOOTSTRAP_CORE_TOKEN_FILE: /run/secrets/vogt_core_token
# Who that adopted token acts as, and how much it may do. Everything that
# can read the file runs as this identity, so this scope is the blast
# radius; narrow it if you do not need project writes.
VOGT_BOOTSTRAP_CORE_TOKEN_ACTOR: "${VOGT_BOOTSTRAP_CORE_TOKEN_ACTOR:-agent:engine}"
VOGT_BOOTSTRAP_CORE_TOKEN_SCOPES: "${VOGT_BOOTSTRAP_CORE_TOKEN_SCOPES:-read,work.write,project.write}"
# The engine is the front door, so `connect` and `/connection-info`
# render against the identity the engine states per request (its
# `X-Vogt-*` headers) rather than the core's own loopback address.
# `VOGT_PUBLIC_URL` is deliberately absent: when fronted, the door is the
# only thing that knows where clients arrive, and it says so per request.
VOGT_FRONTED: "true"

# Both halves must agree about where the estate is: the core's import
# root and the engine's workspace root are the same tree, or a session
# opened "for" a project opens somewhere the registry never recorded.
# The entrypoint warns at boot if these drift apart.
VOGT_DATA_DIR: /var/lib/vogt
VOGT_IMPORT_ROOT: /home/sprooty/Working

VOGT_LIFECYCLE_STATE_DIR: /home/sprooty/.vogt-lifecycle
VOGT_LIFECYCLE_WORKDIR: /home/sprooty/Working
VOGT_LIFECYCLE_HEALTHCHECK_URL: http://127.0.0.1:8910/readyz
VOGT_HOOK_DIR: /run/vogt/hooks
VOGT_HOOKS_REQUIRED: "${VOGT_HOOKS_REQUIRED:-false}"

# Named volumes, not host binds. Docker seeds a fresh named volume from the
# image, so the `sprooty` ownership the image built is preserved — a host
# directory would arrive root-owned and break the pod. No host paths appear
# in this file by design.
volumes:
# The pod's writable home: agent state, caches, session scratch, and the
# `Working` tree sessions run in.
- engine-home:/home/sprooty
# The core's SQLite databases and backups. Separate from the home volume
# so the data outlives a pod you decide to reset.
- vogt-data:/var/lib/vogt

# `/readyz` is the aggregate. It reports the core's state but deliberately
# stays ready when the core is absent — restarting the container would not
# revive a core and would kill every live terminal (FR-E9).
healthcheck:
test: ["CMD", "/usr/local/bin/vogt-lifecycle", "health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s

logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

secrets:
- vogt_core_token

volumes:
engine-home:
vogt-data:

secrets:
# The core token, shared by both halves inside the container. Create it
# beside this file before the first deploy:
#
# openssl rand -hex 32 > deploy/vogt-core-token
#
# A relative path, so nothing here is tied to one host. Legitimately empty
# until you mint one — an empty file just leaves `/api/vogt` answering 401,
# which is the pre-bootstrap behaviour rather than a broken one.
vogt_core_token:
file: ./vogt-core-token
66 changes: 66 additions & 0 deletions deploy/stack.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Settings for the all-in-one stack. Copy to deploy/.env before starting it:
#
# cp deploy/stack.env.example deploy/.env
# docker compose -f deploy/stack.compose.yml up -d --wait
#
# Separate from `.env.example` on purpose: that file configures the core-only
# base and its optional engine overlay, and half of it (`VOGT_PORT`,
# `VOGT_UID`, `VOGT_IMAGE`) means nothing to the AIO, which publishes one port
# and runs its core on loopback inside the container. Use one or the other,
# not both.

# ── Required ────────────────────────────────────────────────────────────────

# The engine's bearer token, required for every API and WebSocket call. At
# least 16 characters. The stack refuses to start without it rather than
# generating one, because a token this file invented would be a token nobody
# knows they are trusting. Mint one with:
#
# ENGINE_TOKEN=$(openssl rand -hex 24)
ENGINE_TOKEN=

# The core token is *not* an env var — it is a file both halves read, so it
# stays out of `docker inspect`. Create it beside this file before the first
# deploy:
#
# openssl rand -hex 32 > deploy/vogt-core-token
#
# Leaving it empty is legitimate: `/api/vogt` then answers 401 until a token
# is minted by hand, which is the pre-bootstrap behaviour rather than a break.

# ── Exposure ────────────────────────────────────────────────────────────────

# Which host interface the engine is published on. Loopback by default — this
# is a development pod carrying sudo, sshd and agent CLIs, so it does not go
# onto a network interface because nobody said to. Set a real address only
# when you mean to expose it, and put something that terminates TLS in front.
ENGINE_BIND=127.0.0.1

# Host port. Change it if 8910 is taken.
ENGINE_PORT=8910

# The URL clients actually reach the stack at. Optional to start with, but the
# engine cannot advertise an address it has not been told, so set it once the
# stack has a stable one — `connect` and `/connection-info` render against it.
ENGINE_PUBLIC_URL=

# ── The image ───────────────────────────────────────────────────────────────

# Pin a digest in anything you would call a deployment: a tag can move, and a
# signed release exists so the bytes cannot. Pin the release family
# (`X.Y.Z`, `latest`) — the `dev-<sha>` / `prod-<sha>` tags in the same
# repository are the maintainer's own pods, carrying estate integrations that
# address one estate's infrastructure. See docs/DEPLOYMENT.md §7.
# VOGT_STACK_IMAGE=ghcr.io/thedancingdeveloper-org/vogt-stack@sha256:...

# ── Optional ────────────────────────────────────────────────────────────────

# Who the bootstrapped core token acts as, and how much it may do. Everything
# in the container that can read the token file runs as this identity, so this
# scope is the blast radius; narrow it if you do not need project writes.
VOGT_BOOTSTRAP_CORE_TOKEN_ACTOR=agent:engine
VOGT_BOOTSTRAP_CORE_TOKEN_SCOPES=read,work.write,project.write

# Whether a missing lifecycle hook is fatal. Off by default: hooks are a
# deployment's extension point, and a deployment that installs none is normal.
VOGT_HOOKS_REQUIRED=false
Loading
Loading