diff --git a/.env.example b/.env.example index 06b6cfc5..da3982c6 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,9 @@ VALKEY_HOST_PORT=6380 GATEWAY_HOST_PORT=14242 # Registry image override (defaults to a local build of docker/gateway.Dockerfile): # GATEWAY_IMAGE=git.mosaicstack.dev/mosaicstack/stack/gateway:sha-acf640d + +# Optional explicit dogfood overlay (docker-compose.dogfood.yml). +# Both paths are required when that overlay is used. Use a dedicated next-based +# worktree and the external home of the unprivileged stack-dogfood seat. +# MOSAIC_DOGFOOD_WORKTREE=/home/example/src/mosaic-stack-worktrees/dogfood-1487 +# MOSAIC_DOGFOOD_SEAT_HOME=/home/example/.mosaic/fleet/agents/stack-dogfood diff --git a/README.md b/README.md index 07acf1be..92ca5187 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,46 @@ mosaic telemetry upload # Dry-run unless opted in Consent state is persisted in config. Remote upload is a no-op until you run `mosaic telemetry opt-in`. +## Standalone container deployment + +The `stack` profile runs PostgreSQL, Valkey, the gateway, and the bundled webUI. Copy +`.env.example` to `.env`, generate `BETTER_AUTH_SECRET`, then start the profile: + +```bash +cp .env.example .env +printf 'BETTER_AUTH_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env +docker compose --profile stack up -d +``` + +The optional dogfood overlay gives one dedicated in-stack agent a writable stack +worktree and its own read-only credential slot. It does not mount the fleet brain or +any other seat. Prepare a `next`-based worktree and an unprivileged `stack-dogfood` +seat outside the container, then set these paths in `.env`: + +```dotenv +MOSAIC_DOGFOOD_WORKTREE=/path/to/mosaic-stack-worktrees/dogfood-1487 +MOSAIC_DOGFOOD_SEAT_HOME=/path/to/.mosaic/fleet/agents/stack-dogfood +``` + +The seat home must contain only that seat's credential at +`secrets/gitea-mosaicstack-stack-dogfood.token`. Never place the token value in +`.env`. Start the overlay with: + +```bash +docker compose \ + -f docker-compose.yml \ + -f docker-compose.dogfood.yml \ + --profile stack up -d +``` + +The overlay scopes regular-agent tools to the mounted checkout. For issue and PR +operations, instruct the agent to use `/opt/mosaic/tools/git/`. The gateway image +configures `git-credential-mosaic` as Git's system credential helper, so pushes and +`pr-create.sh` resolve only the `stack-dogfood` slot and fail if it is absent. + +This deployment route is separate from the local source-development restrictions +below. + ## Development ### Prerequisites diff --git a/docker-compose.dogfood.yml b/docker-compose.dogfood.yml new file mode 100644 index 00000000..103ef444 --- /dev/null +++ b/docker-compose.dogfood.yml @@ -0,0 +1,22 @@ +# Explicit, single-seat dogfood mode for stack-containerization B2. +# Use with docker-compose.yml. The base stack remains credential-free. +services: + gateway: + environment: + # Identity and credential layout match a fleet seat. This fixed name prevents + # an operator from mounting one seat while attributing actions to another. + MOSAIC_AGENT_NAME: stack-dogfood + MOSAIC_GIT_IDENTITY: stack-dogfood + MOSAIC_BRAIN_HOME: /opt/mosaic/brain + AGENT_FILE_SANDBOX_DIR: /workspace/stack + AGENT_USER_TOOLS: fs_read_file,fs_write_file,fs_list_directory,fs_edit_file,git_status,git_log,git_diff,shell_exec + volumes: + # Mount a dedicated worktree, never the canonical clone or divergent local main. + - type: bind + source: ${MOSAIC_DOGFOOD_WORKTREE:?set to a dedicated next-based stack worktree} + target: /workspace/stack + # Only this seat home enters the container. Other fleet credentials stay outside. + - type: bind + source: ${MOSAIC_DOGFOOD_SEAT_HOME:?set to the external stack-dogfood seat directory} + target: /opt/mosaic/brain/fleet/agents/stack-dogfood + read_only: true diff --git a/docker/gateway.Dockerfile b/docker/gateway.Dockerfile index 1dc4feef..a62cb105 100644 --- a/docker/gateway.Dockerfile +++ b/docker/gateway.Dockerfile @@ -29,11 +29,23 @@ ENV NODE_ENV=production # $MOSAIC_ROOT/.workspaces (apps/gateway/src/workspace/workspace.service.ts); # mount a volume over /opt/mosaic to persist workspaces across container restarts. # Intentionally unpinned: Alpine's signed repository is the trust anchor; pinning -# git was declined so routine base-image security updates remain maintainable. -RUN apk add --no-cache git \ +# packages was declined so routine base-image security updates remain maintainable. +# bash/curl/python3 are runtime dependencies of the provider-neutral Mosaic git +# wrappers. jq supports wrapper discovery for non-canonical Gitea hosts. +RUN apk add --no-cache bash curl git jq python3 \ && mkdir -p /opt/mosaic/.workspaces \ && chown -R node:node /opt/mosaic /app ENV MOSAIC_ROOT=/opt/mosaic +# Dogfood agents use the same fail-closed credential helper and PR-create wrapper +# as fleet seats. Copy only that operation and its shared dependencies. Unrelated +# fleet operations, including merge and infrastructure tools, stay out of the image. +COPY --from=builder /app/packages/mosaic/framework/tools/git/pr-create.sh /opt/mosaic/tools/git/pr-create.sh +COPY --from=builder /app/packages/mosaic/framework/tools/git/detect-platform.sh /opt/mosaic/tools/git/detect-platform.sh +COPY --from=builder /app/packages/mosaic/framework/tools/git/repo-decl.sh /opt/mosaic/tools/git/repo-decl.sh +COPY --from=builder /app/packages/mosaic/framework/tools/git/git-credential-mosaic /opt/mosaic/tools/git/git-credential-mosaic +COPY --from=builder /app/packages/mosaic/framework/tools/_lib/credentials.sh /opt/mosaic/tools/_lib/credentials.sh +COPY --from=builder /app/packages/mosaic/framework/tools/structure/validate-repo-json.sh /opt/mosaic/tools/structure/validate-repo-json.sh +RUN git config --system credential.helper /opt/mosaic/tools/git/git-credential-mosaic # Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules COPY --chown=node:node --from=builder /deploy/node_modules ./node_modules COPY --chown=node:node --from=builder /deploy/package.json ./package.json diff --git a/scripts/verify-dogfood-compose.sh b/scripts/verify-dogfood-compose.sh new file mode 100755 index 00000000..5aaffbb8 --- /dev/null +++ b/scripts/verify-dogfood-compose.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Hermetic structural check for the explicit dogfood Compose overlay. +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/worktree" "$tmp/seat/secrets" + +base_config_json=$( + cd "$repo_root" + BETTER_AUTH_SECRET=test-only-not-a-credential \ + docker compose --profile stack config --format json +) + +BASE_CONFIG_JSON="$base_config_json" python3 <<'PY' +import json +import os + +config = json.loads(os.environ["BASE_CONFIG_JSON"]) +gateway = config["services"]["gateway"] +env = gateway["environment"] +for key in ( + "MOSAIC_AGENT_NAME", + "MOSAIC_GIT_IDENTITY", + "MOSAIC_BRAIN_HOME", + "AGENT_FILE_SANDBOX_DIR", + "AGENT_USER_TOOLS", +): + assert key not in env, f"base compose unexpectedly sets dogfood variable {key}" + +targets = {mount["target"] for mount in gateway["volumes"]} +assert "/workspace/stack" not in targets +assert not any(target.startswith("/opt/mosaic/brain/") for target in targets) +PY + +config_json=$( + cd "$repo_root" + BETTER_AUTH_SECRET=test-only-not-a-credential \ + MOSAIC_DOGFOOD_WORKTREE="$tmp/worktree" \ + MOSAIC_DOGFOOD_SEAT_HOME="$tmp/seat" \ + docker compose \ + -f docker-compose.yml \ + -f docker-compose.dogfood.yml \ + --profile stack \ + config --format json +) + +CONFIG_JSON="$config_json" EXPECT_WORKTREE="$tmp/worktree" EXPECT_SEAT="$tmp/seat" python3 <<'PY' +import json +import os + +config = json.loads(os.environ["CONFIG_JSON"]) +gateway = config["services"]["gateway"] +env = gateway["environment"] + +expected_env = { + "MOSAIC_AGENT_NAME": "stack-dogfood", + "MOSAIC_GIT_IDENTITY": "stack-dogfood", + "MOSAIC_BRAIN_HOME": "/opt/mosaic/brain", + "AGENT_FILE_SANDBOX_DIR": "/workspace/stack", +} +for key, value in expected_env.items(): + assert env.get(key) == value, f"{key}: expected {value!r}, got {env.get(key)!r}" + +allowed = set(env["AGENT_USER_TOOLS"].split(",")) +assert allowed == { + "fs_read_file", + "fs_write_file", + "fs_list_directory", + "fs_edit_file", + "git_status", + "git_log", + "git_diff", + "shell_exec", +}, f"unexpected dogfood tool set: {sorted(allowed)}" + +mounts = {mount["target"]: mount for mount in gateway["volumes"]} +worktree = mounts["/workspace/stack"] +assert worktree["type"] == "bind" +assert worktree["source"] == os.environ["EXPECT_WORKTREE"] +assert not worktree.get("read_only", False), "dogfood worktree must be writable" + +seat = mounts["/opt/mosaic/brain/fleet/agents/stack-dogfood"] +assert seat["type"] == "bind" +assert seat["source"] == os.environ["EXPECT_SEAT"] +assert seat.get("read_only") is True, "seat credential slot must be read-only" + +other_seat_mounts = [ + target + for target in mounts + if target.startswith("/opt/mosaic/brain/fleet/agents/") + and target != "/opt/mosaic/brain/fleet/agents/stack-dogfood" +] +assert other_seat_mounts == [], f"other seat mounts leaked: {other_seat_mounts}" +PY + +# Each required path must fail closed rather than falling back to the current checkout. +expect_missing_path() { + local missing=$1 output rc + set +e + case "$missing" in + MOSAIC_DOGFOOD_WORKTREE) + output=$( + cd "$repo_root" + env -u MOSAIC_DOGFOOD_WORKTREE \ + BETTER_AUTH_SECRET=test-only-not-a-credential \ + MOSAIC_DOGFOOD_SEAT_HOME="$tmp/seat" \ + docker compose -f docker-compose.yml -f docker-compose.dogfood.yml \ + --profile stack config 2>&1 + ) + rc=$? + ;; + MOSAIC_DOGFOOD_SEAT_HOME) + output=$( + cd "$repo_root" + env -u MOSAIC_DOGFOOD_SEAT_HOME \ + BETTER_AUTH_SECRET=test-only-not-a-credential \ + MOSAIC_DOGFOOD_WORKTREE="$tmp/worktree" \ + docker compose -f docker-compose.yml -f docker-compose.dogfood.yml \ + --profile stack config 2>&1 + ) + rc=$? + ;; + *) + echo "FAIL: test requested unknown path variable $missing" >&2 + exit 1 + ;; + esac + set -e + if [[ $rc -eq 0 ]]; then + echo "FAIL: dogfood compose accepted missing $missing" >&2 + exit 1 + fi + if [[ "$output" != *"$missing"* ]]; then + echo "FAIL: missing-path failure did not name $missing" >&2 + exit 1 + fi +} + +expect_missing_path MOSAIC_DOGFOOD_WORKTREE +expect_missing_path MOSAIC_DOGFOOD_SEAT_HOME + +printf 'dogfood compose verification passed\n'