Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b85978793 | ||
|
|
e18d13d36f | ||
|
|
60bc5d2022 | ||
|
|
ea91cfc421 |
No files matched your search
+13
-155
@@ -1,156 +1,14 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Mosaic — Environment Variables Reference
|
||||
# Copy this file to .env and fill in the values for your deployment.
|
||||
# Lines beginning with # are comments; optional vars are commented out.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Mosaic Stack standalone deployment (compose `stack` profile)
|
||||
# Copy to .env and adjust. Port overrides exist because the defaults
|
||||
# collide with common host services (and with the dev compose itself).
|
||||
PG_HOST_PORT=5433
|
||||
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
|
||||
|
||||
|
||||
# ─── Database (PostgreSQL 17 + pgvector) ─────────────────────────────────────
|
||||
# Full connection string used by the gateway, ORM, and migration runner.
|
||||
# Port 5433 avoids conflict with a host-side PostgreSQL instance.
|
||||
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
|
||||
|
||||
# Docker Compose host-port override for the PostgreSQL container (default: 5433)
|
||||
# PG_HOST_PORT=5433
|
||||
|
||||
|
||||
# ─── Queue (Valkey 8 / Redis-compatible) ─────────────────────────────────────
|
||||
# Port 6380 avoids conflict with a host-side Redis/Valkey instance.
|
||||
VALKEY_URL=redis://localhost:6380
|
||||
|
||||
# Docker Compose host-port override for the Valkey container (default: 6380)
|
||||
# VALKEY_HOST_PORT=6380
|
||||
|
||||
|
||||
# ─── Gateway ─────────────────────────────────────────────────────────────────
|
||||
# TCP port the NestJS/Fastify gateway listens on (default: 14242)
|
||||
GATEWAY_PORT=14242
|
||||
|
||||
# Comma-separated list of allowed CORS origins.
|
||||
# Must include the web app origin in production.
|
||||
GATEWAY_CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
|
||||
# ─── Auth (BetterAuth) ───────────────────────────────────────────────────────
|
||||
# REQUIRED — random secret used to sign sessions and tokens.
|
||||
# Generate with: openssl rand -base64 32
|
||||
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
||||
|
||||
# Public base URL of the gateway (used by BetterAuth for callback URLs)
|
||||
BETTER_AUTH_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── Web App (SPA) ───────────────────────────────────────────────────────────
|
||||
# Directory holding the built SPA bundle (vite build output). When set, the
|
||||
# gateway serves the SPA same-origin; when unset (dev), run the Vite dev
|
||||
# server (pnpm --filter @mosaicstack/web dev), which proxies to the gateway.
|
||||
# safe-default: unset in dev — SPA serving is an opt-in production concern
|
||||
#WEB_DIST_DIR=apps/web/dist
|
||||
|
||||
|
||||
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
|
||||
# OTLP HTTP endpoint (otel-collector or any OpenTelemetry-compatible backend)
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
|
||||
# Service name shown in traces
|
||||
OTEL_SERVICE_NAME=mosaic-gateway
|
||||
|
||||
|
||||
# ─── AI Providers ────────────────────────────────────────────────────────────
|
||||
|
||||
# Ollama (local models — set OLLAMA_BASE_URL to enable)
|
||||
# OLLAMA_BASE_URL=http://localhost:11434
|
||||
# OLLAMA_HOST is a legacy alias for OLLAMA_BASE_URL
|
||||
# OLLAMA_HOST=http://localhost:11434
|
||||
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
|
||||
# OLLAMA_MODELS=llama3.2,codellama,mistral
|
||||
|
||||
# Anthropic (claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5)
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# OpenAI (gpt-4o, gpt-4o-mini, o3-mini)
|
||||
# OPENAI_API_KEY=sk-...
|
||||
|
||||
# Z.ai / GLM (glm-4.5, glm-4.5-air, glm-4.5-flash)
|
||||
# ZAI_API_KEY=...
|
||||
|
||||
# Custom providers — JSON array of provider configs
|
||||
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
|
||||
# MOSAIC_CUSTOM_PROVIDERS=
|
||||
|
||||
|
||||
# ─── Embedding Service ───────────────────────────────────────────────────────
|
||||
# OpenAI-compatible embeddings endpoint (default: OpenAI)
|
||||
# EMBEDDING_API_URL=https://api.openai.com/v1
|
||||
# EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
|
||||
# ─── Log Summarization Service ───────────────────────────────────────────────
|
||||
# OpenAI-compatible chat completions endpoint for log summarization (default: OpenAI)
|
||||
# SUMMARIZATION_API_URL=https://api.openai.com/v1
|
||||
# SUMMARIZATION_MODEL=gpt-4o-mini
|
||||
|
||||
# Cron schedule for summarization job (default: every 6 hours)
|
||||
# SUMMARIZATION_CRON=0 */6 * * *
|
||||
|
||||
# Cron schedule for log tier management (default: daily at 03:00)
|
||||
# TIER_MANAGEMENT_CRON=0 3 * * *
|
||||
|
||||
|
||||
# ─── Agent ───────────────────────────────────────────────────────────────────
|
||||
# Filesystem sandbox root for agent file tools (default: process.cwd())
|
||||
# AGENT_FILE_SANDBOX_DIR=/var/lib/mosaic/sandbox
|
||||
|
||||
# Comma-separated list of tool names available to non-admin users.
|
||||
# Leave unset to allow all tools for all authenticated users.
|
||||
# AGENT_USER_TOOLS=read_file,list_directory,search_files
|
||||
|
||||
# System prompt injected into every agent session (optional)
|
||||
# AGENT_SYSTEM_PROMPT=You are a helpful assistant.
|
||||
|
||||
|
||||
# ─── MCP Servers ─────────────────────────────────────────────────────────────
|
||||
# JSON array of MCP server configs — set to enable MCP tool integration.
|
||||
# Each entry: {"name":"<id>","url":"<http-or-sse-url>"}
|
||||
# MCP_SERVERS=[{"name":"my-mcp","url":"http://localhost:3100/sse"}]
|
||||
|
||||
|
||||
# ─── Coordinator ─────────────────────────────────────────────────────────────
|
||||
# Root directory used to scope coordinator (worktree/repo) operations.
|
||||
# Defaults to the monorepo root auto-detected from process.cwd().
|
||||
# MOSAIC_WORKSPACE_ROOT=/home/user/projects/mosaic
|
||||
|
||||
|
||||
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
|
||||
# DISCORD_BOT_TOKEN=
|
||||
# DISCORD_GUILD_ID=
|
||||
# DISCORD_GATEWAY_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
|
||||
# TELEGRAM_BOT_TOKEN=
|
||||
# TELEGRAM_GATEWAY_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
|
||||
|
||||
# --- Authentik (optional — set AUTHENTIK_CLIENT_ID to enable) ---
|
||||
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
|
||||
# AUTHENTIK_CLIENT_ID=
|
||||
# AUTHENTIK_CLIENT_SECRET=
|
||||
|
||||
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
|
||||
# WORKOS_ISSUER=https://your-company.authkit.app
|
||||
# WORKOS_CLIENT_ID=client_...
|
||||
# WORKOS_CLIENT_SECRET=sk_live_...
|
||||
|
||||
# --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) ---
|
||||
# KEYCLOAK_ISSUER=https://auth.example.com/realms/master
|
||||
# Legacy alternative if you prefer to compose the issuer from separate vars:
|
||||
# KEYCLOAK_URL=https://auth.example.com
|
||||
# KEYCLOAK_REALM=master
|
||||
# KEYCLOAK_CLIENT_ID=mosaic
|
||||
# KEYCLOAK_CLIENT_SECRET=
|
||||
|
||||
# The web login page discovers configured providers dynamically from
|
||||
# GET /api/sso/providers. No NEXT_PUBLIC_* provider feature flag is required.
|
||||
# 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -47,6 +47,44 @@ services:
|
||||
environment:
|
||||
COLLECTOR_OTLP_ENABLED: 'true'
|
||||
|
||||
|
||||
gateway:
|
||||
# Standalone-tier application service (compose `stack` profile).
|
||||
# Default image = local build of docker/gateway.Dockerfile (works with
|
||||
# no registry auth); override GATEWAY_IMAGE to a CI-published sha tag
|
||||
# for registry deploys (git.mosaicstack.dev/mosaicstack/stack/gateway:sha-XXXXXXX).
|
||||
profiles: [stack]
|
||||
image: ${GATEWAY_IMAGE:-mosaic-gateway:dev}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/gateway.Dockerfile
|
||||
ports:
|
||||
- '${GATEWAY_HOST_PORT:-14242}:14242'
|
||||
environment:
|
||||
GATEWAY_PORT: '14242'
|
||||
DATABASE_URL: postgresql://mosaic:mosaic@postgres:5432/mosaic
|
||||
VALKEY_URL: valkey://valkey:6379
|
||||
# The compose IS the standalone tier by declaration (mode contract:
|
||||
# mode chosen at install); pinning skips cross-container probe races.
|
||||
MOSAIC_STORAGE_TIER: standalone
|
||||
# Standalone-tier secrets: generated at install (see .env.example).
|
||||
# Enterprise tier replaces these with Vault/Openbao plumbing.
|
||||
BETTER_AUTH_SECRET: '${BETTER_AUTH_SECRET:?set in .env — openssl rand -hex 32}'
|
||||
volumes:
|
||||
- gateway_workspaces:/opt/mosaic/.workspaces
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget -qO- http://127.0.0.1:14242/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
gateway_workspaces:
|
||||
pg_data:
|
||||
valkey_data:
|
||||
@@ -29,11 +29,19 @@ 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 wrappers as
|
||||
# fleet seats. The complete tools directory is copied because pr-create.sh shares
|
||||
# provider detection, declaration validation, and credential libraries with the
|
||||
# rest of the wrapper suite.
|
||||
COPY --from=builder /app/packages/mosaic/framework/tools /opt/mosaic/tools
|
||||
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
|
||||
|
||||
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# mosaic — fleet launcher (shipped-first, split-home safe).
|
||||
#
|
||||
# T110 / P5-RM-009 stack side. Carries the T106 brain launcher contract
|
||||
# (shipped-first, worktree dev opt-in, OFF pass-through, typed failure) with
|
||||
# one split-home correction: the SHIPPED npm mosaic is resolved from the real
|
||||
# user's home (passwd database), never from $HOME. Under split-home seat
|
||||
# layouts HOME is a seat home: it carries no npm prefix, and a
|
||||
# $HOME/.npm-global there would be a plantable descriptor, so the $HOME
|
||||
# candidate is consulted only when the passwd lookup itself fails, and then
|
||||
# only with a full symlink-component refusal (secure descriptor traversal).
|
||||
#
|
||||
# Contract:
|
||||
# 1. SHIPPED npm mosaic is the default. Candidate order:
|
||||
# a. <real-home>/.npm-global/bin/mosaic — real home from the passwd
|
||||
# database. The final component may be npm's own bin symlink into
|
||||
# lib/node_modules; that indirection is npm's layout, not a plant.
|
||||
# b. $HOME/.npm-global/bin/mosaic — ONLY when the passwd lookup
|
||||
# fails, and then only when the candidate is a trusted-shape
|
||||
# absolute path: relative HOME and parent-escape (..) components
|
||||
# are refused outright, and every remaining component must be a
|
||||
# non-symlink (secure descriptor traversal). Refused candidates
|
||||
# are never executed.
|
||||
# 2. Worktree build is DEV OPT-IN: used only when MOSAIC_CLI_WORKTREE is
|
||||
# explicitly set. Health-checked via --version; ANY doubt (absent,
|
||||
# unreadable, or failing) falls back to the shipped npm mosaic with a
|
||||
# warning on stderr. With no environment set, worktree candidates are
|
||||
# never consulted — stale worktree builds cannot regain precedence.
|
||||
# 3. MOSAIC_FLEET_CLI_OFF keeps its pass-through semantics: set (any
|
||||
# value) forces pure pass-through. The dev path is not consulted even
|
||||
# when MOSAIC_CLI_WORKTREE is also set.
|
||||
# 4. Typed failure: with no runnable candidate the launcher prints one
|
||||
# stderr line naming what was checked and exits 127.
|
||||
# 5. NEVER writes to the mosaic home or the npm prefix. Deployment to the
|
||||
# fleet goes through the real channel (PR to next -> mosaic update).
|
||||
#
|
||||
# Env:
|
||||
# MOSAIC_CLI_WORKTREE dev opt-in: path to a stack worktree whose
|
||||
# packages/mosaic/dist/cli.js is used (health-checked,
|
||||
# shipped fallback on doubt)
|
||||
# MOSAIC_FLEET_CLI_OFF set (any value) to force pure pass-through
|
||||
#
|
||||
# Component walk note: the descriptor guard splits on "/" without quoting so
|
||||
# multi-byte HOME paths with spaces are not supported for the FALLBACK
|
||||
# candidate; the passwd candidate needs no walk (trusted derivation).
|
||||
|
||||
set -u
|
||||
|
||||
fail() {
|
||||
echo "mosaic: $*" >&2
|
||||
exit 127
|
||||
}
|
||||
|
||||
# Real user home from the passwd database (HOME-independent).
|
||||
real_home() {
|
||||
getent passwd "$(id -u)" 2>/dev/null | cut -d: -f6
|
||||
}
|
||||
|
||||
# True when any component of an ABSOLUTE candidate path is a symlink. Only
|
||||
# ever called after fallback_candidate_usable's absolute-shape check.
|
||||
path_has_symlink_component() {
|
||||
local path="$1" dir base acc="" part
|
||||
dir="$(dirname -- "$path")"
|
||||
base="$(basename -- "$path")"
|
||||
local IFS='/'
|
||||
for part in $dir; do
|
||||
acc="$acc/$part"
|
||||
[ -L "$acc" ] && return 0
|
||||
done
|
||||
[ -L "$dir/$base" ] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# Reject the untrusted $HOME fallback candidate unless it is a trusted-shape
|
||||
# absolute path: absolute, no parent-escape (..) components, and no symlink
|
||||
# components anywhere on the path. Every rejection is named on stderr so the
|
||||
# typed failure explains itself. This is the launcher's descriptor guard; the
|
||||
# suite's mutation control (guard bypassed) must plant-exec, proving the guard
|
||||
# is what stands between a hostile HOME and code execution.
|
||||
fallback_candidate_usable() {
|
||||
local candidate="$1"
|
||||
case "$candidate" in
|
||||
/*) ;;
|
||||
*)
|
||||
echo "mosaic: refusing \$HOME candidate $candidate: relative path is untrusted without a passwd home" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
if printf '%s' "$candidate" | grep -qE '(^|/)\.\.(/|$)'; then
|
||||
echo "mosaic: refusing \$HOME candidate $candidate: parent-escape component" >&2
|
||||
return 1
|
||||
fi
|
||||
if path_has_symlink_component "$candidate"; then
|
||||
echo "mosaic: refusing \$HOME candidate $candidate: symlink component (untrusted without a passwd home)" >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Print shipped candidates in contract order. Refusals are reported on stderr
|
||||
# so the typed failure names the cause.
|
||||
shipped_candidates() {
|
||||
local rh home_candidate
|
||||
rh="$(real_home)"
|
||||
if [ -n "$rh" ]; then
|
||||
printf '%s\n' "$rh/.npm-global/bin/mosaic"
|
||||
return 0
|
||||
fi
|
||||
# passwd lookup failed: the only fallback is $HOME, descriptor-guarded.
|
||||
if [ -n "${HOME:-}" ]; then
|
||||
home_candidate="$HOME/.npm-global/bin/mosaic"
|
||||
if fallback_candidate_usable "$home_candidate"; then
|
||||
printf '%s\n' "$home_candidate"
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
resolve_shipped() {
|
||||
local candidate
|
||||
while IFS= read -r candidate; do
|
||||
[ -n "$candidate" ] || continue
|
||||
if [ -x "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done < <(shipped_candidates)
|
||||
return 1
|
||||
}
|
||||
|
||||
# Dev opt-in only: an explicit MOSAIC_CLI_WORKTREE reaches the worktree build,
|
||||
# and pure pass-through (MOSAIC_FLEET_CLI_OFF) outranks it.
|
||||
if [ -n "${MOSAIC_CLI_WORKTREE:-}" ] && [ -z "${MOSAIC_FLEET_CLI_OFF:-}" ]; then
|
||||
CLI="$MOSAIC_CLI_WORKTREE/packages/mosaic/dist/cli.js"
|
||||
if [ -r "$CLI" ]; then
|
||||
if v="$(node "$CLI" --version 2>/dev/null)" && [ -n "$v" ]; then
|
||||
exec node "$CLI" "$@"
|
||||
fi
|
||||
echo "mosaic: worktree build at $CLI failed its health check; using shipped npm mosaic" >&2
|
||||
else
|
||||
echo "mosaic: worktree build at $CLI absent or unreadable; using shipped npm mosaic" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
SHIPPED="$(resolve_shipped)" || true
|
||||
if [ -n "${SHIPPED:-}" ]; then
|
||||
exec "$SHIPPED" "$@"
|
||||
fi
|
||||
|
||||
fail "no runnable CLI (shipped npm mosaic absent from the passwd-home npm prefix and \$HOME; worktree build requires MOSAIC_CLI_WORKTREE)"
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hermetic suite for the fleet/bin/mosaic launcher (T110 / P5-RM-009).
|
||||
#
|
||||
# Arms cover the plan acceptance: split-home shipped-first positive, typed
|
||||
# failure on missing candidates, stale-worktree non-precedence, OFF
|
||||
# pass-through, and secure-descriptor refusal on the untrusted $HOME
|
||||
# fallback. No network, no real npm install, no node package build: the
|
||||
# "shipped mosaic" is a stub script and getent is PATH-stubbed (set
|
||||
# GETENT_STUB=fail to make the passwd lookup fail, exercising the guarded
|
||||
# $HOME fallback).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
LAUNCHER="$SCRIPT_DIR/mosaic"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -f "$LAUNCHER" ] || fail "missing launcher"
|
||||
[ -x "$LAUNCHER" ] || fail "launcher is not executable"
|
||||
bash -n "$LAUNCHER" || fail "launcher fails bash -n"
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
cleanup() { rm -rf "$WORK"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
REAL_HOME="$WORK/real-home"
|
||||
SEAT_HOME="$WORK/seat-home"
|
||||
STUB_BIN="$WORK/stub-bin"
|
||||
mkdir -p "$REAL_HOME/.npm-global/bin" "$SEAT_HOME" "$STUB_BIN"
|
||||
|
||||
cat >"$REAL_HOME/.npm-global/bin/mosaic" <<'SH'
|
||||
#!/bin/sh
|
||||
echo "0.0.0-shipped-stub"
|
||||
SH
|
||||
chmod +x "$REAL_HOME/.npm-global/bin/mosaic"
|
||||
|
||||
# PATH-stubbed getent: reports the real home for the current uid, unless
|
||||
# GETENT_STUB=fail is in the launcher environment (exercises the guarded
|
||||
# $HOME fallback path).
|
||||
cat >"$STUB_BIN/getent" <<SH
|
||||
#!/bin/sh
|
||||
if [ "\${GETENT_STUB:-}" = "fail" ]; then exit 2; fi
|
||||
if [ "\$1" = "passwd" ]; then
|
||||
echo "stub:x:$(id -u):$(id -g):stub:$REAL_HOME:/bin/sh"
|
||||
exit 0
|
||||
fi
|
||||
exit 2
|
||||
SH
|
||||
chmod +x "$STUB_BIN/getent"
|
||||
|
||||
run_launcher() { # run_launcher <home> [VAR=value ...] -- [args...]
|
||||
local home="$1"; shift
|
||||
[ "${1:-}" = "--" ] && shift
|
||||
env -i PATH="$STUB_BIN:/usr/bin:/bin" HOME="$home" TERM="${TERM:-dumb}" "$LAUNCHER" "$@"
|
||||
}
|
||||
|
||||
# A1 — acceptance 1: split-home positive. HOME is an empty seat home; the
|
||||
# shipped mosaic resolves through the passwd real home.
|
||||
out="$(printf '' | run_launcher "$SEAT_HOME" -- --version)"
|
||||
[ "$out" = "0.0.0-shipped-stub" ] || fail "A1 split-home positive: got '$out', want shipped stub version"
|
||||
|
||||
# A3 — acceptance 3: a stale worktree build is NEVER consulted without the
|
||||
# explicit opt-in, even when a worktree exists on disk.
|
||||
WT="$WORK/stale-wt"
|
||||
mkdir -p "$WT/packages/mosaic/dist"
|
||||
printf 'console.log("0.0.0-stale-worktree")\n' >"$WT/packages/mosaic/dist/cli.js"
|
||||
out="$(printf '' | run_launcher "$SEAT_HOME" -- --version)"
|
||||
[ "$out" = "0.0.0-shipped-stub" ] || fail "A3 stale worktree regained precedence without opt-in: got '$out'"
|
||||
|
||||
# A2 (opt-in healthy) — explicit MOSAIC_CLI_WORKTREE reaches the worktree.
|
||||
out="$(printf '' | env MOSAIC_CLI_WORKTREE="$WT" HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version)"
|
||||
[ "$out" = "0.0.0-stale-worktree" ] || fail "A2 opt-in worktree not used: got '$out'"
|
||||
|
||||
# A2b (opt-in unhealthy) — absent dist falls back to shipped with a warning.
|
||||
out2="$(printf '' | env MOSAIC_CLI_WORKTREE="$WORK/empty-wt" HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>/dev/null)"
|
||||
[ "$out2" = "0.0.0-shipped-stub" ] || fail "A2b unhealthy worktree fallback output: '$out2'"
|
||||
err2="$(printf '' | env MOSAIC_CLI_WORKTREE="$WORK/empty-wt" HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1 >/dev/null)"
|
||||
case "$err2" in *"absent or unreadable"*|*"health check"*) ;; *) fail "A2b unhealthy worktree fallback warning missing: '$err2'" ;; esac
|
||||
|
||||
# A4 — OFF pass-through: worktree opt-in is ignored when OFF is set.
|
||||
out="$(printf '' | env MOSAIC_FLEET_CLI_OFF=1 MOSAIC_CLI_WORKTREE="$WT" HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version)"
|
||||
[ "$out" = "0.0.0-shipped-stub" ] || fail "A4 OFF did not force pass-through: got '$out'"
|
||||
|
||||
# A5 — acceptance 4: typed failure when no candidate exists (passwd lookup
|
||||
# fails, seat home carries no npm prefix). Expect 127 + documented message.
|
||||
set +e
|
||||
err="$(printf '' | env GETENT_STUB=fail HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1 >/dev/null)"
|
||||
rc=$?
|
||||
set -e
|
||||
[ "$rc" = "127" ] || fail "A5 typed failure rc: got $rc, want 127"
|
||||
case "$err" in *"no runnable CLI"*) ;; *) fail "A5 typed failure message missing: '$err'" ;; esac
|
||||
|
||||
# A6 — secure descriptor traversal, ABSOLUTE symlink plant (corrected per
|
||||
# rev-code-02 B3: the symlink points at $PLANT/.npm-global so the candidate
|
||||
# resolves EXACTLY to the planted executable). passwd lookup fails and a
|
||||
# symlink-planted $HOME/.npm-global is refused without execution.
|
||||
PLANT="$WORK/planted-target"
|
||||
mkdir -p "$PLANT/.npm-global/bin"
|
||||
cat >"$PLANT/.npm-global/bin/mosaic" <<SH
|
||||
#!/bin/sh
|
||||
touch "$WORK/planted-sentinel"
|
||||
echo "0.0.0-planted"
|
||||
SH
|
||||
chmod +x "$PLANT/.npm-global/bin/mosaic"
|
||||
ln -s "$PLANT/.npm-global" "$SEAT_HOME/.npm-global"
|
||||
set +e
|
||||
err="$(printf '' | env GETENT_STUB=fail HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1)"
|
||||
rc=$?
|
||||
set -e
|
||||
[ "$rc" = "127" ] || fail "A6 planted descriptor was followed (rc $rc, out '$err')"
|
||||
case "$err" in *"symlink component"*) ;; *) fail "A6 refusal diagnostic missing: '$err'" ;; esac
|
||||
[ ! -e "$WORK/planted-sentinel" ] || fail "A6 planted mosaic EXECUTED"
|
||||
|
||||
# A6b — mutation control (rev-code-02 B3): a copy of the launcher with the
|
||||
# descriptor guard bypassed MUST execute the plant under the identical hostile
|
||||
# arm. If the mutant stays clean, the plant path is wrong and A6 proves
|
||||
# nothing.
|
||||
MUTANT="$WORK/mutant-mosaic"
|
||||
sed 's/if fallback_candidate_usable "\$home_candidate"; then/if true; then/' "$LAUNCHER" >"$MUTANT"
|
||||
chmod +x "$MUTANT"
|
||||
[ "$(grep -c 'if true; then' "$MUTANT")" -eq 1 ] || fail "A6b mutant not created (guard call not replaced)"
|
||||
set +e
|
||||
mout="$(printf '' | env GETENT_STUB=fail HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$MUTANT" --version 2>&1)"
|
||||
mrc=$?
|
||||
set -e
|
||||
[ "$mrc" = "0" ] || fail "A6b mutant did not execute the plant (rc $mrc, out '$mout') - A6 proves nothing"
|
||||
[ -e "$WORK/planted-sentinel" ] || fail "A6b mutant ran but sentinel absent - plant path wrong, A6 proves nothing"
|
||||
|
||||
# A7 — relative-HOME hostile arm (rev-code-02 B2): a relative HOME whose name
|
||||
# is a symlink in the launcher CWD must be refused outright, never resolved
|
||||
# against the working directory.
|
||||
CWD_SANDBOX="$WORK/cwd-sandbox"
|
||||
REL_PLANT="$WORK/relative-plant"
|
||||
mkdir -p "$CWD_SANDBOX" "$REL_PLANT/.npm-global/bin"
|
||||
cat >"$REL_PLANT/.npm-global/bin/mosaic" <<SH
|
||||
#!/bin/sh
|
||||
touch "$WORK/relative-sentinel"
|
||||
echo "0.0.0-relative-planted"
|
||||
SH
|
||||
chmod +x "$REL_PLANT/.npm-global/bin/mosaic"
|
||||
ln -s "$REL_PLANT" "$CWD_SANDBOX/relative-home"
|
||||
set +e
|
||||
rout="$(cd "$CWD_SANDBOX" && printf '' | env GETENT_STUB=fail HOME="relative-home" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1)"
|
||||
rrc=$?
|
||||
set -e
|
||||
[ "$rrc" = "127" ] || fail "A7 relative HOME was followed (rc $rrc, out '$rout')"
|
||||
case "$rout" in *"relative path"*) ;; *) fail "A7 relative-refusal diagnostic missing: '$rout'" ;; esac
|
||||
[ ! -e "$WORK/relative-sentinel" ] || fail "A7 relative plant EXECUTED"
|
||||
|
||||
# A7b — mutation control for the absolute-shape check: the same mutant (guard
|
||||
# bypassed) MUST execute the relative plant under the identical arm.
|
||||
set +e
|
||||
rmout="$(cd "$CWD_SANDBOX" && printf '' | env GETENT_STUB=fail HOME="relative-home" PATH="$STUB_BIN:/usr/bin:/bin" "$MUTANT" --version 2>&1)"
|
||||
rmrc=$?
|
||||
set -e
|
||||
[ "$rmrc" = "0" ] || fail "A7b mutant did not execute the relative plant (rc $rmrc, out '$rmout') - A7 proves nothing"
|
||||
[ -e "$WORK/relative-sentinel" ] || fail "A7b mutant ran but relative sentinel absent - arm wrong, A7 proves nothing"
|
||||
|
||||
# A8 — parent-escape hostile arm (rev-code-02 delta, B2 remains): an absolute
|
||||
# HOME containing a literal '..' component must be refused by the
|
||||
# parent-escape check — the traversal would otherwise land on a planted tree
|
||||
# OUTSIDE the seat home with no symlink involved.
|
||||
ESC_BASE="$WORK/escape-base"
|
||||
ESC_TARGET="$WORK/escape-target"
|
||||
mkdir -p "$ESC_BASE" "$ESC_TARGET/.npm-global/bin"
|
||||
cat >"$ESC_TARGET/.npm-global/bin/mosaic" <<SH
|
||||
#!/bin/sh
|
||||
touch "$WORK/escape-sentinel"
|
||||
echo "0.0.0-escape-planted"
|
||||
SH
|
||||
chmod +x "$ESC_TARGET/.npm-global/bin/mosaic"
|
||||
set +e
|
||||
eout="$(printf '' | env GETENT_STUB=fail HOME="$ESC_BASE/../escape-target" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1)"
|
||||
erc=$?
|
||||
set -e
|
||||
[ "$erc" = "127" ] || fail "A8 parent-escape HOME was followed (rc $erc, out '$eout')"
|
||||
case "$eout" in *"parent-escape component"*) ;; *) fail "A8 parent-escape diagnostic missing: '$eout'" ;; esac
|
||||
[ ! -e "$WORK/escape-sentinel" ] || fail "A8 escape plant EXECUTED"
|
||||
|
||||
# A8b — mutation control: the guard-bypassed copy MUST execute the parent-
|
||||
# escape plant under the identical arm (sentinel present, rc 0), proving the
|
||||
# parent-escape check is what stands.
|
||||
set +e
|
||||
emout="$(printf '' | env GETENT_STUB=fail HOME="$ESC_BASE/../escape-target" PATH="$STUB_BIN:/usr/bin:/bin" "$MUTANT" --version 2>&1)"
|
||||
emrc=$?
|
||||
set -e
|
||||
[ "$emrc" = "0" ] || fail "A8b mutant did not execute the escape plant (rc $emrc, out '$emout') - A8 proves nothing"
|
||||
[ -e "$WORK/escape-sentinel" ] || fail "A8b mutant ran but escape sentinel absent - arm wrong, A8 proves nothing"
|
||||
|
||||
echo "mosaic launcher suite: all arms passed"
|
||||
@@ -46,7 +46,12 @@ systemd/**
|
||||
templates/**
|
||||
tools/**
|
||||
# Fleet: only the framework-seeded fleet subtrees are framework-owned.
|
||||
# fleet/bin is exact-entry on purpose (T110 B1): the estate's fleet/bin carries
|
||||
# operator-owned executables this package does not ship; a subtree glob here
|
||||
# would make keep-mode update prune them.
|
||||
fleet/README.md
|
||||
fleet/bin/mosaic
|
||||
fleet/bin/test-mosaic-launcher.sh
|
||||
fleet/examples/**
|
||||
fleet/profiles/**
|
||||
fleet/roles/**
|
||||
|
||||
@@ -148,6 +148,26 @@ describe.skipIf(!hasBash)('bash ↔ TS manifest parity (§6.1)', () => {
|
||||
const manifest = loadManifest(FRAMEWORK_ROOT);
|
||||
expect(bashSubtreeRoots().sort()).toEqual(frameworkSubtreeRoots(manifest).sort());
|
||||
});
|
||||
|
||||
it('fleet/bin ownership is exact and does not prune existing executables (T110 B1)', () => {
|
||||
// fleet/bin carries estate executables this package does not ship. A
|
||||
// subtree glob here would classify them framework-owned and keep-mode
|
||||
// update would prune them. The manifest must own EXACTLY the two shipped
|
||||
// launcher files and nothing else in fleet/bin, on BOTH resolvers.
|
||||
const manifest = loadManifest(FRAMEWORK_ROOT);
|
||||
const expected: Array<[string, string]> = [
|
||||
['fleet/bin/mosaic', 'framework'],
|
||||
['fleet/bin/test-mosaic-launcher.sh', 'framework'],
|
||||
['fleet/bin/seat-up.sh', 'operator'], // shipped-by-estate, unshipped here
|
||||
['fleet/bin/launch-seat.sh', 'operator'],
|
||||
['fleet/bin', 'operator'], // the directory itself is unlisted
|
||||
];
|
||||
for (const [path, want] of expected) {
|
||||
const ts = resolveOwnership(manifest, path);
|
||||
expect(ts).toBe(want);
|
||||
expect(bashResolve(path)).toBe(want);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Executable
+144
@@ -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'
|
||||
Reference in new issue
Block a user