Compare commits
7 Commits
feat/per-a
...
feat/comms
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e7c124e4d | ||
| 529c177830 | |||
| a32ce4c8f9 | |||
| d351caad36 | |||
| 76b86a246e | |||
| 4422231bdb | |||
| 8504216964 |
@@ -29,6 +29,7 @@ export default tseslint.config(
|
|||||||
'apps/web/playwright.config.ts',
|
'apps/web/playwright.config.ts',
|
||||||
'apps/gateway/vitest.config.ts',
|
'apps/gateway/vitest.config.ts',
|
||||||
'plugins/discord/vitest.config.ts',
|
'plugins/discord/vitest.config.ts',
|
||||||
|
'packages/comms/vitest.config.ts',
|
||||||
'packages/db/vitest.config.ts',
|
'packages/db/vitest.config.ts',
|
||||||
'packages/storage/vitest.config.ts',
|
'packages/storage/vitest.config.ts',
|
||||||
'packages/mosaic/vitest.config.ts',
|
'packages/mosaic/vitest.config.ts',
|
||||||
|
|||||||
3
infra/matrix/.gitignore
vendored
Normal file
3
infra/matrix/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# DEV runtime state: rendered config, sqlite db, signing key, self-signed
|
||||||
|
# certs, throwaway secrets. Never committed.
|
||||||
|
.data/
|
||||||
53
infra/matrix/README.md
Normal file
53
infra/matrix/README.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# infra/matrix — DEV Synapse for RFC-001 P1 (presence)
|
||||||
|
|
||||||
|
> **DEV-ONLY. LOCAL SANDBOX.** This stack is for local presence validation. It
|
||||||
|
> must **never** be pointed at, or run alongside, a live/production homeserver.
|
||||||
|
> It is single-instance, federation-OFF, self-signed TLS — the smallest slice
|
||||||
|
> RFC-002 §9 says P1 needs (Mode B single-domain, no federation, no IP-only, no
|
||||||
|
> secret-rotation story).
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A single Synapse homeserver rendered from committed **templates** (no hardcoded
|
||||||
|
topology — RFC-002 G2). Every topology fact is an environment variable with a
|
||||||
|
dev default:
|
||||||
|
|
||||||
|
| Var | Default | Meaning |
|
||||||
|
| -------------------- | ------------------ | --------------------------------------------------- |
|
||||||
|
| `MATRIX_SERVER_NAME` | `matrix.localhost` | Synapse `server_name` (the `:suffix` of every MXID) |
|
||||||
|
| `MOSAIC_AS_ID` | `mosaic-as` | appservice id / registration filename |
|
||||||
|
| `MATRIX_HTTP_PORT` | `18008` | host port → Synapse 8008 (plain HTTP) |
|
||||||
|
| `MATRIX_TLS_PORT` | `18448` | host port → Synapse 8448 (self-signed TLS) |
|
||||||
|
|
||||||
|
Secrets (`as_token`, `hs_token`, Synapse macaroon/form/registration secrets)
|
||||||
|
are **throwaway values generated at bring-up** into `.data/dev-secrets.env`
|
||||||
|
(gitignored). In production these are crown-jewel secrets held by the
|
||||||
|
SecretBackend (RFC-001 §8 / RFC-002 §4) — never committed.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `docker-compose.dev.yml` — Synapse (+ optional Element under `--profile element`).
|
||||||
|
- `synapse/homeserver.dev.yaml.tpl` — rendered Synapse config (Mode B, `enable_registration: false`, appservice wired, native TLS).
|
||||||
|
- `synapse/log.config` — Synapse logging.
|
||||||
|
- `appservice/mosaic-as.dev.yaml.tpl` — minimal AS registration (declares the `@agent-*` user namespace).
|
||||||
|
- `dev-up.sh` / `dev-down.sh` — bring up / tear down (`--purge` wipes `.data`).
|
||||||
|
- `.data/` — **gitignored** runtime state (rendered config, sqlite db, signing key, self-signed certs, dev secrets).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dev-up.sh # render config, gen signing key + TLS cert, boot Synapse
|
||||||
|
# ... run the validation harness (tools/matrix-presence-harness/run.sh) ...
|
||||||
|
./dev-down.sh # stop, keep .data
|
||||||
|
./dev-down.sh --purge # stop and wipe .data for a pristine next boot
|
||||||
|
|
||||||
|
# optional human view (A4) — Element pointed at the dev server:
|
||||||
|
docker compose -f docker-compose.dev.yml --profile element up -d element
|
||||||
|
# -> http://127.0.0.1:18080
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance evidence (A1)
|
||||||
|
|
||||||
|
- TLS (self-signed) reachable: `curl -sk https://127.0.0.1:18448/_matrix/client/versions` → `200`.
|
||||||
|
- Open registration OFF: `POST /_matrix/client/v3/register` → `M_FORBIDDEN "Registration has been disabled"`.
|
||||||
|
- AS-token registration bypasses the flag by design (that is how agents are provisioned).
|
||||||
30
infra/matrix/appservice/mosaic-as.dev.yaml.tpl
Normal file
30
infra/matrix/appservice/mosaic-as.dev.yaml.tpl
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# mosaic-as.dev.yaml.tpl — Mosaic Appservice registration (DEV)
|
||||||
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# *** DEV-ONLY. The tokens below are throwaway placeholders rendered at
|
||||||
|
# bring-up by dev-up.sh. NEVER commit real hs_token/as_token — in
|
||||||
|
# production they are crown-jewel secrets held by the SecretBackend
|
||||||
|
# (RFC-001 §8, RFC-002 §4). ***
|
||||||
|
#
|
||||||
|
# This is the MINIMAL P1 registration: it declares the @agent-* user
|
||||||
|
# namespace so the P1 provisioner can register a few virtual agent MXIDs and
|
||||||
|
# carry heartbeats. It intentionally does NOT model the full P2 taxonomy /
|
||||||
|
# token-minting appservice.
|
||||||
|
#
|
||||||
|
# Rendered by infra/matrix/dev-up.sh (envsubst -> .data/${MOSAIC_AS_ID}.yaml).
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
id: "${MOSAIC_AS_ID}"
|
||||||
|
url: null # P1: provisioner drives the AS API directly; Synapse pushes no txns.
|
||||||
|
as_token: "${MOSAIC_AS_TOKEN}"
|
||||||
|
hs_token: "${MOSAIC_HS_TOKEN}"
|
||||||
|
sender_localpart: "mosaic-as"
|
||||||
|
rate_limited: false
|
||||||
|
namespaces:
|
||||||
|
users:
|
||||||
|
- exclusive: true
|
||||||
|
regex: "@agent-.*:${MATRIX_SERVER_NAME}"
|
||||||
|
aliases:
|
||||||
|
- exclusive: false
|
||||||
|
regex: "#mosaic-.*:${MATRIX_SERVER_NAME}"
|
||||||
|
rooms: []
|
||||||
12
infra/matrix/dev-down.sh
Executable file
12
infra/matrix/dev-down.sh
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# dev-down.sh — tear down the DEV Synapse (matrix-p1-dev). DEV-ONLY.
|
||||||
|
# ./dev-down.sh # stop + remove containers, keep ./.data
|
||||||
|
# ./dev-down.sh --purge # also delete ./.data (fresh next bring-up)
|
||||||
|
set -euo pipefail
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
docker compose -f "${HERE}/docker-compose.dev.yml" --profile element down -v --remove-orphans || true
|
||||||
|
if [[ "${1:-}" == "--purge" ]]; then
|
||||||
|
rm -rf "${HERE}/.data"
|
||||||
|
echo "[dev-down] purged ${HERE}/.data"
|
||||||
|
fi
|
||||||
|
echo "[dev-down] matrix-p1-dev stopped"
|
||||||
108
infra/matrix/dev-up.sh
Executable file
108
infra/matrix/dev-up.sh
Executable file
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================================
|
||||||
|
# dev-up.sh — bring up the DEV Synapse for RFC-001 P1 (presence)
|
||||||
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# *** DEV-ONLY. LOCAL SANDBOX. This script must never be pointed at live
|
||||||
|
# infra. It writes only into infra/matrix/.data (gitignored) and drives
|
||||||
|
# a dedicated compose project (matrix-p1-dev). ***
|
||||||
|
#
|
||||||
|
# It renders the Synapse config + appservice registration from the committed
|
||||||
|
# templates (envsubst), generates a DEV signing key + self-signed TLS cert,
|
||||||
|
# and starts Synapse. All topology facts come from the environment with dev
|
||||||
|
# defaults (RFC-002 G2: nothing hardcoded).
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
DATA="${HERE}/.data"
|
||||||
|
COMPOSE=(docker compose -f "${HERE}/docker-compose.dev.yml")
|
||||||
|
|
||||||
|
# ---- Topology inputs (dev defaults; override via env) ----------------------
|
||||||
|
export MATRIX_SERVER_NAME="${MATRIX_SERVER_NAME:-matrix.localhost}"
|
||||||
|
export MOSAIC_AS_ID="${MOSAIC_AS_ID:-mosaic-as}"
|
||||||
|
export MATRIX_HTTP_PORT="${MATRIX_HTTP_PORT:-18008}"
|
||||||
|
export MATRIX_TLS_PORT="${MATRIX_TLS_PORT:-18448}"
|
||||||
|
|
||||||
|
# ---- DEV secrets (throwaway; regenerated if absent) ------------------------
|
||||||
|
SECRETS_ENV="${DATA}/dev-secrets.env"
|
||||||
|
mkdir -p "${DATA}"
|
||||||
|
if [[ ! -f "${SECRETS_ENV}" ]]; then
|
||||||
|
{
|
||||||
|
echo "MOSAIC_AS_TOKEN=devas_$(openssl rand -hex 16)"
|
||||||
|
echo "MOSAIC_HS_TOKEN=devhs_$(openssl rand -hex 16)"
|
||||||
|
echo "SYNAPSE_REG_SHARED_SECRET=devreg_$(openssl rand -hex 16)"
|
||||||
|
echo "SYNAPSE_MACAROON_SECRET=devmac_$(openssl rand -hex 16)"
|
||||||
|
echo "SYNAPSE_FORM_SECRET=devform_$(openssl rand -hex 16)"
|
||||||
|
} > "${SECRETS_ENV}"
|
||||||
|
echo "[dev-up] generated throwaway DEV secrets -> ${SECRETS_ENV}"
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a; source "${SECRETS_ENV}"; set +a
|
||||||
|
|
||||||
|
# DEV: let the synapse container user (uid 991) write the sqlite db / media.
|
||||||
|
chmod 0777 "${DATA}" || true
|
||||||
|
|
||||||
|
# ---- Render config from templates ------------------------------------------
|
||||||
|
envsubst < "${HERE}/synapse/homeserver.dev.yaml.tpl" > "${DATA}/homeserver.yaml"
|
||||||
|
envsubst < "${HERE}/appservice/mosaic-as.dev.yaml.tpl" > "${DATA}/${MOSAIC_AS_ID}.yaml"
|
||||||
|
cp "${HERE}/synapse/log.config" "${DATA}/log.config"
|
||||||
|
echo "[dev-up] rendered homeserver.yaml + ${MOSAIC_AS_ID}.yaml (server_name=${MATRIX_SERVER_NAME})"
|
||||||
|
|
||||||
|
# ---- DEV signing key -------------------------------------------------------
|
||||||
|
# Generate as the synapse container user (uid 991) so the running container
|
||||||
|
# can read it; owned-by-991 mode-600 is fine (the container IS 991).
|
||||||
|
SIGNING_KEY="${DATA}/${MATRIX_SERVER_NAME}.signing.key"
|
||||||
|
if [[ ! -f "${SIGNING_KEY}" ]]; then
|
||||||
|
docker run --rm --user 991:991 -v "${DATA}:/data" --entrypoint generate_signing_key \
|
||||||
|
matrixdotorg/synapse:latest -o "/data/${MATRIX_SERVER_NAME}.signing.key"
|
||||||
|
echo "[dev-up] generated DEV signing key"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- DEV self-signed TLS cert (A1) -----------------------------------------
|
||||||
|
if [[ ! -f "${DATA}/dev-cert.pem" ]]; then
|
||||||
|
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||||
|
-keyout "${DATA}/dev-key.pem" -out "${DATA}/dev-cert.pem" \
|
||||||
|
-days 90 -subj "/CN=${MATRIX_SERVER_NAME}" \
|
||||||
|
-addext "subjectAltName=DNS:${MATRIX_SERVER_NAME},DNS:localhost,IP:127.0.0.1" >/dev/null 2>&1
|
||||||
|
echo "[dev-up] generated DEV self-signed TLS cert for ${MATRIX_SERVER_NAME}"
|
||||||
|
fi
|
||||||
|
# Files hermes owns must be world-readable so the synapse (uid 991) container
|
||||||
|
# can read them (DEV-only; the signing key stays owned by 991 from above).
|
||||||
|
chmod 0644 "${DATA}/dev-cert.pem" "${DATA}/dev-key.pem" \
|
||||||
|
"${DATA}/homeserver.yaml" "${DATA}/${MOSAIC_AS_ID}.yaml" "${DATA}/log.config" || true
|
||||||
|
|
||||||
|
# ---- Element config (optional human view, A4) ------------------------------
|
||||||
|
cat > "${DATA}/element-config.json" <<JSON
|
||||||
|
{
|
||||||
|
"default_server_config": {
|
||||||
|
"m.homeserver": {
|
||||||
|
"base_url": "https://localhost:${MATRIX_TLS_PORT}",
|
||||||
|
"server_name": "${MATRIX_SERVER_NAME}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"disable_guests": true,
|
||||||
|
"brand": "Mosaic P1 (DEV)"
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
# ---- Boot ------------------------------------------------------------------
|
||||||
|
"${COMPOSE[@]}" up -d synapse
|
||||||
|
|
||||||
|
echo -n "[dev-up] waiting for Synapse health"
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
status="$("${COMPOSE[@]}" ps --format '{{.Health}}' synapse 2>/dev/null || true)"
|
||||||
|
if [[ "${status}" == "healthy" ]]; then echo " ... healthy"; break; fi
|
||||||
|
echo -n "."; sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! curl -fsS "http://127.0.0.1:${MATRIX_HTTP_PORT}/health" >/dev/null 2>&1; then
|
||||||
|
echo "[dev-up] ERROR: Synapse did not become healthy" >&2
|
||||||
|
"${COMPOSE[@]}" logs --tail 60 synapse >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[dev-up] Synapse UP:"
|
||||||
|
echo " plain HTTP : http://127.0.0.1:${MATRIX_HTTP_PORT}"
|
||||||
|
echo " TLS : https://127.0.0.1:${MATRIX_TLS_PORT} (self-signed, DEV)"
|
||||||
|
echo " server_name: ${MATRIX_SERVER_NAME} registration: OFF"
|
||||||
60
infra/matrix/docker-compose.dev.yml
Normal file
60
infra/matrix/docker-compose.dev.yml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# docker-compose.dev.yml — Synapse DEV homeserver for RFC-001 P1 (presence)
|
||||||
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# *** DEV-ONLY. LOCAL SANDBOX. Do NOT point this at, or run alongside, any
|
||||||
|
# live/production homeserver. ***
|
||||||
|
#
|
||||||
|
# Single-instance Synapse (RFC-002 Mode B, federation OFF) + an optional
|
||||||
|
# Element web client for the human view (A4). Brought up by ./dev-up.sh, which
|
||||||
|
# renders config into ./.data (gitignored) first.
|
||||||
|
#
|
||||||
|
# Isolated by design: dedicated project name + network + high host ports so it
|
||||||
|
# never collides with other stacks on the box (A5).
|
||||||
|
#
|
||||||
|
# Host ports: ${MATRIX_HTTP_PORT:-18008} -> Synapse 8008 (plain HTTP)
|
||||||
|
# ${MATRIX_TLS_PORT:-18448} -> Synapse 8448 (self-signed TLS)
|
||||||
|
# ${ELEMENT_PORT:-18080} -> Element web (profile: element)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
name: matrix-p1-dev
|
||||||
|
|
||||||
|
services:
|
||||||
|
synapse:
|
||||||
|
image: matrixdotorg/synapse:latest
|
||||||
|
container_name: matrix-p1-synapse
|
||||||
|
restart: 'no'
|
||||||
|
# Run against the rendered config in /data (dev-up.sh puts it there).
|
||||||
|
environment:
|
||||||
|
SYNAPSE_CONFIG_DIR: /data
|
||||||
|
SYNAPSE_CONFIG_PATH: /data/homeserver.yaml
|
||||||
|
volumes:
|
||||||
|
- ./.data:/data
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:${MATRIX_HTTP_PORT:-18008}:8008'
|
||||||
|
- '127.0.0.1:${MATRIX_TLS_PORT:-18448}:8448'
|
||||||
|
networks:
|
||||||
|
- matrix-p1-net
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'curl -fsS http://localhost:8008/health || exit 1']
|
||||||
|
interval: 3s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 40
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
# Optional human view (A4). `docker compose --profile element up -d element`.
|
||||||
|
element:
|
||||||
|
image: vectorim/element-web:latest
|
||||||
|
container_name: matrix-p1-element
|
||||||
|
restart: 'no'
|
||||||
|
profiles: [element]
|
||||||
|
volumes:
|
||||||
|
- ./.data/element-config.json:/app/config.json:ro
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:${ELEMENT_PORT:-18080}:80'
|
||||||
|
networks:
|
||||||
|
- matrix-p1-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
matrix-p1-net:
|
||||||
|
name: matrix-p1-net
|
||||||
|
driver: bridge
|
||||||
84
infra/matrix/synapse/homeserver.dev.yaml.tpl
Normal file
84
infra/matrix/synapse/homeserver.dev.yaml.tpl
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# homeserver.dev.yaml.tpl — Synapse DEV homeserver config (RFC-002 Mode B)
|
||||||
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# *** DEV-ONLY. NOT FOR PRODUCTION. ***
|
||||||
|
#
|
||||||
|
# This is the RFC-001 P1 (presence) single-instance homeserver. It is
|
||||||
|
# RFC-002 "Mode B — single-domain" (server_name == homeserver host), with
|
||||||
|
# federation OFF (standalone), which is all P1 needs (RFC-002 §9 P1 row).
|
||||||
|
#
|
||||||
|
# NOTHING here is a production value. Every topology fact is a variable
|
||||||
|
# rendered by dev-up.sh from the environment; there is no baked-in fleet
|
||||||
|
# domain (RFC-002 G2 "zero hardcoded topology"). The secrets below are
|
||||||
|
# throwaway DEV placeholders rendered at bring-up — never reuse them.
|
||||||
|
#
|
||||||
|
# Rendered by: infra/matrix/dev-up.sh (envsubst -> .data/homeserver.yaml)
|
||||||
|
# Variables: MATRIX_SERVER_NAME, MOSAIC_AS_ID (+ dev secrets)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
server_name: "${MATRIX_SERVER_NAME}"
|
||||||
|
pid_file: /data/homeserver.pid
|
||||||
|
report_stats: false
|
||||||
|
suppress_key_server_warning: true
|
||||||
|
|
||||||
|
# --- Listeners -------------------------------------------------------------
|
||||||
|
# 8008: plain HTTP (client + federation) for in-container/local tooling.
|
||||||
|
# 8448: TLS (self-signed in DEV) — satisfies A1 "reachable over TLS".
|
||||||
|
listeners:
|
||||||
|
- port: 8008
|
||||||
|
type: http
|
||||||
|
tls: false
|
||||||
|
bind_addresses: ['0.0.0.0']
|
||||||
|
x_forwarded: true
|
||||||
|
resources:
|
||||||
|
- names: [client, federation]
|
||||||
|
compress: false
|
||||||
|
|
||||||
|
- port: 8448
|
||||||
|
type: http
|
||||||
|
tls: true
|
||||||
|
bind_addresses: ['0.0.0.0']
|
||||||
|
resources:
|
||||||
|
- names: [client, federation]
|
||||||
|
compress: false
|
||||||
|
|
||||||
|
# --- DEV TLS (self-signed; generated by dev-up.sh) -------------------------
|
||||||
|
tls_certificate_path: "/data/dev-cert.pem"
|
||||||
|
tls_private_key_path: "/data/dev-key.pem"
|
||||||
|
|
||||||
|
# --- Store: sqlite is the simplest dev store (RFC-002 §1 allows it) --------
|
||||||
|
database:
|
||||||
|
name: sqlite3
|
||||||
|
args:
|
||||||
|
database: /data/homeserver.db
|
||||||
|
|
||||||
|
log_config: "/data/log.config"
|
||||||
|
media_store_path: /data/media_store
|
||||||
|
signing_key_path: "/data/${MATRIX_SERVER_NAME}.signing.key"
|
||||||
|
|
||||||
|
# --- Hardening (RFC-001 §8 / RFC-002 §8.5), rendered so a dev gets it ------
|
||||||
|
# Registration is OFF: agents come ONLY via the appservice (A1). AS user
|
||||||
|
# registration bypasses this flag, which is exactly the design.
|
||||||
|
enable_registration: false
|
||||||
|
enable_registration_without_verification: false
|
||||||
|
registration_shared_secret: "${SYNAPSE_REG_SHARED_SECRET}"
|
||||||
|
macaroon_secret_key: "${SYNAPSE_MACAROON_SECRET}"
|
||||||
|
form_secret: "${SYNAPSE_FORM_SECRET}"
|
||||||
|
|
||||||
|
# Presence EDUs ON so Element shows the native dot for humans (RFC-001 §4.5);
|
||||||
|
# the AUTHORITATIVE liveness is still the mosaic.presence heartbeat.
|
||||||
|
presence:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# --- Federation: OFF for P1 (standalone). Empty whitelist = federate with
|
||||||
|
# nobody (RFC-002 §2.4 / NG5). No public-network federation. ----------------
|
||||||
|
federation_domain_whitelist: []
|
||||||
|
trusted_key_servers: []
|
||||||
|
|
||||||
|
# --- Appservice registration wired in (A1). The file is rendered next to
|
||||||
|
# this one by dev-up.sh. -----------------------------------------------------
|
||||||
|
app_service_config_files:
|
||||||
|
- "/data/${MOSAIC_AS_ID}.yaml"
|
||||||
|
|
||||||
|
# Keep default rate-limiting ON (RFC-001 §8). No overrides here.
|
||||||
16
infra/matrix/synapse/log.config
Normal file
16
infra/matrix/synapse/log.config
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Synapse DEV log config. DEV-ONLY.
|
||||||
|
version: 1
|
||||||
|
formatters:
|
||||||
|
precise:
|
||||||
|
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
|
||||||
|
handlers:
|
||||||
|
console:
|
||||||
|
class: logging.StreamHandler
|
||||||
|
formatter: precise
|
||||||
|
loggers:
|
||||||
|
synapse.storage.SQL:
|
||||||
|
level: WARNING
|
||||||
|
root:
|
||||||
|
level: INFO
|
||||||
|
handlers: [console]
|
||||||
|
disable_existing_loggers: false
|
||||||
41
packages/comms/README.md
Normal file
41
packages/comms/README.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# @mosaicstack/comms
|
||||||
|
|
||||||
|
MACP presence SDK — the **P1 (presence)** slice of RFC-001 (§4.5 liveness,
|
||||||
|
§4.2 event envelope). Minimal by design: set Matrix presence, run the
|
||||||
|
`mosaic.presence` heartbeat, and compute **deterministic** fleet liveness.
|
||||||
|
|
||||||
|
Out of P1 scope (later phases): enrollment/auto-detect, room taxonomy,
|
||||||
|
per-agent token minting, signed-authorship, federation.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `classifyLiveness(ageMs, policy)` / `computeFleetLiveness(observations, now, policy)`
|
||||||
|
— pure, deterministic online/away/offline from heartbeat age. The
|
||||||
|
authoritative liveness source (RFC-001 §4.5): native Matrix presence is _not_
|
||||||
|
relied upon.
|
||||||
|
- `HeartbeatEmitter` / `startHeartbeatLoop(...)` — build and drive the
|
||||||
|
`mosaic.presence` heartbeat (monotonic `seq`, `interval_ms`).
|
||||||
|
- `MinimalMatrixClient` — tiny C-S client: `setPresence`, `sendHeartbeat`,
|
||||||
|
`readHeartbeats`, `joinRoom`. Supports Application-Service masquerade
|
||||||
|
(`actAsUserId`) for the P1 provisioner, or a per-agent `accessToken`.
|
||||||
|
- `PresenceAgent` — high-level: join the fleet room, go present, heartbeat.
|
||||||
|
`pauseHeartbeat()` models a crash (no graceful signal).
|
||||||
|
- `FleetLivenessReader` — reads the fleet room and computes the liveness board
|
||||||
|
(`read()` / `formatBoard()`), the surface a human or watchdog reads.
|
||||||
|
|
||||||
|
## Liveness policy (RFC-001 §4.5)
|
||||||
|
|
||||||
|
```
|
||||||
|
online : age <= heartbeatIntervalMs * missTolerance
|
||||||
|
away : age < darkThresholdMs
|
||||||
|
offline: otherwise (or never-seen / non-finite age -> fail safe to offline)
|
||||||
|
```
|
||||||
|
|
||||||
|
Defaults: interval 30s, miss-tolerance 2, dark-threshold 10min
|
||||||
|
(`DEFAULT_LIVENESS_POLICY`). All runtime-tunable per RFC-002 §5.3.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`pnpm --filter @mosaicstack/comms test` — the liveness core is written
|
||||||
|
RED-FIRST; an end-to-end proof against a real Synapse lives in
|
||||||
|
`tools/matrix-presence-harness`.
|
||||||
37
packages/comms/package.json
Normal file
37
packages/comms/package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "@mosaicstack/comms",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
|
"directory": "packages/comms"
|
||||||
|
},
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run --passWithNoTests"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@vitest/coverage-v8": "^2.0.0",
|
||||||
|
"typescript": "^5.8.0",
|
||||||
|
"vitest": "^2.0.0"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
90
packages/comms/src/__tests__/heartbeat.test.ts
Normal file
90
packages/comms/src/__tests__/heartbeat.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { HeartbeatEmitter, startHeartbeatLoop } from '../heartbeat.js';
|
||||||
|
import type { PresenceHeartbeatContent } from '../types.js';
|
||||||
|
|
||||||
|
const agent = { mxid: '@agent-alpha:matrix.localhost', slug: 'alpha', harness: 'claude-code' };
|
||||||
|
|
||||||
|
describe('HeartbeatEmitter', () => {
|
||||||
|
it('increments seq starting at 1 and stamps the envelope', () => {
|
||||||
|
let t = 1000;
|
||||||
|
const em = new HeartbeatEmitter({ agent, intervalMs: 5000, now: () => t });
|
||||||
|
const a = em.next();
|
||||||
|
t = 6000;
|
||||||
|
const b = em.next('away');
|
||||||
|
|
||||||
|
expect(a.seq).toBe(1);
|
||||||
|
expect(a.ts).toBe(1000);
|
||||||
|
expect(a.status).toBe('online');
|
||||||
|
expect(a.macp_type).toBe('presence');
|
||||||
|
expect(a.msgtype).toBe('mosaic.presence');
|
||||||
|
expect(a.macp_version).toBe('1.0');
|
||||||
|
expect(a.interval_ms).toBe(5000);
|
||||||
|
expect(a.agent).toEqual(agent);
|
||||||
|
expect(a.body).toContain('alpha');
|
||||||
|
|
||||||
|
expect(b.seq).toBe(2);
|
||||||
|
expect(b.ts).toBe(6000);
|
||||||
|
expect(b.status).toBe('away');
|
||||||
|
expect(em.currentSeq).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes mission_id only when provided', () => {
|
||||||
|
const withMission = new HeartbeatEmitter({
|
||||||
|
agent,
|
||||||
|
intervalMs: 1000,
|
||||||
|
missionId: 'KBN-101',
|
||||||
|
}).next();
|
||||||
|
const without = new HeartbeatEmitter({ agent, intervalMs: 1000 }).next();
|
||||||
|
expect(withMission.mission_id).toBe('KBN-101');
|
||||||
|
expect(without.mission_id).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('startHeartbeatLoop', () => {
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it('emits immediately, then once per interval, until stopped', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const sent: PresenceHeartbeatContent[] = [];
|
||||||
|
const em = new HeartbeatEmitter({ agent, intervalMs: 1000, now: () => Date.now() });
|
||||||
|
const loop = startHeartbeatLoop({
|
||||||
|
emitter: em,
|
||||||
|
intervalMs: 1000,
|
||||||
|
send: (c) => {
|
||||||
|
sent.push(c);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sent).toHaveLength(1); // immediate beat
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
expect(sent).toHaveLength(4); // +3 beats
|
||||||
|
expect(sent.map((s) => s.seq)).toEqual([1, 2, 3, 4]);
|
||||||
|
|
||||||
|
loop.stop();
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
expect(sent).toHaveLength(4); // no more after stop
|
||||||
|
loop.stop(); // idempotent
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes a rejected async send to onError without killing the loop', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onError = vi.fn();
|
||||||
|
let n = 0;
|
||||||
|
const em = new HeartbeatEmitter({ agent, intervalMs: 1000 });
|
||||||
|
const loop = startHeartbeatLoop({
|
||||||
|
emitter: em,
|
||||||
|
intervalMs: 1000,
|
||||||
|
onError,
|
||||||
|
send: () => {
|
||||||
|
n += 1;
|
||||||
|
return Promise.reject(new Error(`boom ${n}`));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(2000); // immediate + 2
|
||||||
|
expect(n).toBe(3);
|
||||||
|
expect(onError).toHaveBeenCalledTimes(3);
|
||||||
|
loop.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
89
packages/comms/src/__tests__/liveness.test.ts
Normal file
89
packages/comms/src/__tests__/liveness.test.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { classifyLiveness, computeFleetLiveness } from '../liveness.js';
|
||||||
|
import type { HeartbeatObservation, LivenessPolicy } from '../types.js';
|
||||||
|
|
||||||
|
// Small, dev-scale policy so the arithmetic is obvious:
|
||||||
|
// online window = interval * missTolerance = 1000 * 2 = 2000ms
|
||||||
|
// dark threshold = 5000ms
|
||||||
|
const policy: LivenessPolicy = {
|
||||||
|
heartbeatIntervalMs: 1000,
|
||||||
|
missTolerance: 2,
|
||||||
|
darkThresholdMs: 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('classifyLiveness (deterministic, heartbeat-age based — RFC-001 §4.5)', () => {
|
||||||
|
it('is online when age is within interval * missTolerance', () => {
|
||||||
|
expect(classifyLiveness(0, policy)).toBe('online');
|
||||||
|
expect(classifyLiveness(1999, policy)).toBe('online');
|
||||||
|
expect(classifyLiveness(2000, policy)).toBe('online'); // inclusive boundary
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is away when past the online window but before dark threshold', () => {
|
||||||
|
expect(classifyLiveness(2001, policy)).toBe('away');
|
||||||
|
expect(classifyLiveness(4999, policy)).toBe('away');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is offline/dark at or past the dark threshold', () => {
|
||||||
|
expect(classifyLiveness(5000, policy)).toBe('offline');
|
||||||
|
expect(classifyLiveness(50_000, policy)).toBe('offline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a never-seen agent (Infinity age) as offline', () => {
|
||||||
|
expect(classifyLiveness(Number.POSITIVE_INFINITY, policy)).toBe('offline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns online for a negative-but-huge misconfig (guards NaN)', () => {
|
||||||
|
// A NaN age must fail safe to offline, not silently report online.
|
||||||
|
expect(classifyLiveness(Number.NaN, policy)).toBe('offline');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeFleetLiveness (A2/A3 core)', () => {
|
||||||
|
const now = 100_000;
|
||||||
|
const obs = (slug: string, lastSeenTs: number, lastSeq = 1): HeartbeatObservation => ({
|
||||||
|
slug,
|
||||||
|
mxid: `@agent-${slug}:matrix.localhost`,
|
||||||
|
lastSeenTs,
|
||||||
|
lastSeq,
|
||||||
|
assertedStatus: 'online',
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies a live fleet: fresh=online, stale=away, dark=offline', () => {
|
||||||
|
const result = computeFleetLiveness(
|
||||||
|
[
|
||||||
|
obs('alpha', now - 500), // 500ms old -> online
|
||||||
|
obs('bravo', now - 3000), // 3000ms old -> away
|
||||||
|
obs('charlie', now - 8000), // 8000ms old -> offline
|
||||||
|
],
|
||||||
|
now,
|
||||||
|
policy,
|
||||||
|
);
|
||||||
|
const byslug = Object.fromEntries(result.map((r) => [r.slug, r.status]));
|
||||||
|
expect(byslug).toEqual({ alpha: 'online', bravo: 'away', charlie: 'offline' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A3: a previously-online agent flips to offline once age crosses dark threshold', () => {
|
||||||
|
const lastBeat = 100_000; // agent was hard-killed right after this beat
|
||||||
|
// Just before the threshold it is still merely "away"...
|
||||||
|
const justBefore = computeFleetLiveness([obs('victim', lastBeat, 7)], lastBeat + 4999, policy);
|
||||||
|
expect(justBefore[0]?.status).toBe('away');
|
||||||
|
// ...and the instant age reaches darkThresholdMs it is deterministically offline,
|
||||||
|
// with no dependence on native Matrix presence timeouts.
|
||||||
|
const atThreshold = computeFleetLiveness([obs('victim', lastBeat, 7)], lastBeat + 5000, policy);
|
||||||
|
expect(atThreshold[0]?.status).toBe('offline');
|
||||||
|
expect(atThreshold[0]?.ageMs).toBe(5000);
|
||||||
|
expect(atThreshold[0]?.lastSeq).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports ageMs and preserves mxid/slug/seq for the human view', () => {
|
||||||
|
const [row] = computeFleetLiveness([obs('alpha', now - 1200, 42)], now, policy);
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
slug: 'alpha',
|
||||||
|
mxid: '@agent-alpha:matrix.localhost',
|
||||||
|
ageMs: 1200,
|
||||||
|
lastSeq: 42,
|
||||||
|
status: 'online',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
131
packages/comms/src/__tests__/matrix-client.test.ts
Normal file
131
packages/comms/src/__tests__/matrix-client.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { MatrixError, MinimalMatrixClient, toMatrixPresence } from '../matrix-client.js';
|
||||||
|
|
||||||
|
const jsonResponse = (status: number, body: unknown): Response =>
|
||||||
|
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
|
||||||
|
// A fetch mock typed with the (URL, RequestInit?) shape the client actually
|
||||||
|
// calls, so mock.calls has a proper tuple type under noUncheckedIndexedAccess.
|
||||||
|
const mkFetch = (impl: (url: URL, init?: RequestInit) => Promise<Response>) => vi.fn(impl);
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
homeserverUrl: 'https://matrix.localhost:8448',
|
||||||
|
accessToken: 'as-secret',
|
||||||
|
actAsUserId: '@agent-alpha:matrix.localhost',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('toMatrixPresence', () => {
|
||||||
|
it('maps liveness states to native presence EDU values', () => {
|
||||||
|
expect(toMatrixPresence('online')).toBe('online');
|
||||||
|
expect(toMatrixPresence('away')).toBe('unavailable');
|
||||||
|
expect(toMatrixPresence('offline')).toBe('offline');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MinimalMatrixClient', () => {
|
||||||
|
it('setPresence PUTs native presence and masquerades via user_id', async () => {
|
||||||
|
const fetchMock = mkFetch(async () => jsonResponse(200, {}));
|
||||||
|
const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch);
|
||||||
|
await client.setPresence('@agent-alpha:matrix.localhost', 'away', 'hb');
|
||||||
|
|
||||||
|
const [url, init] = fetchMock.mock.calls[0]!;
|
||||||
|
const u = new URL((url as URL).toString());
|
||||||
|
expect(u.pathname).toBe('/_matrix/client/v3/presence/%40agent-alpha%3Amatrix.localhost/status');
|
||||||
|
expect(u.searchParams.get('user_id')).toBe('@agent-alpha:matrix.localhost');
|
||||||
|
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
|
||||||
|
presence: 'unavailable',
|
||||||
|
status_msg: 'hb',
|
||||||
|
});
|
||||||
|
expect((init as RequestInit).method).toBe('PUT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendHeartbeat posts an m.room.message and returns the event_id', async () => {
|
||||||
|
const fetchMock = mkFetch(async () => jsonResponse(200, { event_id: '$evt1' }));
|
||||||
|
const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch);
|
||||||
|
const id = await client.sendHeartbeat('!room:matrix.localhost', {
|
||||||
|
macp_version: '1.0',
|
||||||
|
macp_type: 'presence',
|
||||||
|
msgtype: 'mosaic.presence',
|
||||||
|
agent: { mxid: cfg.actAsUserId, slug: 'alpha', harness: 'claude-code' },
|
||||||
|
ts: 1,
|
||||||
|
body: 'alpha online (seq 1)',
|
||||||
|
status: 'online',
|
||||||
|
seq: 1,
|
||||||
|
interval_ms: 1000,
|
||||||
|
});
|
||||||
|
expect(id).toBe('$evt1');
|
||||||
|
const [url] = fetchMock.mock.calls[0]!;
|
||||||
|
expect((url as URL).pathname).toContain('/rooms/!room%3Amatrix.localhost/send/m.room.message/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws a MatrixError carrying errcode on a non-2xx', async () => {
|
||||||
|
const fetchMock = mkFetch(async () =>
|
||||||
|
jsonResponse(403, { errcode: 'M_FORBIDDEN', error: 'nope' }),
|
||||||
|
);
|
||||||
|
const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch);
|
||||||
|
await expect(client.whoami()).rejects.toMatchObject({
|
||||||
|
name: 'MatrixError',
|
||||||
|
status: 403,
|
||||||
|
errcode: 'M_FORBIDDEN',
|
||||||
|
});
|
||||||
|
await expect(client.whoami()).rejects.toBeInstanceOf(MatrixError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('readHeartbeats reduces the timeline to the latest beat per agent', async () => {
|
||||||
|
// Timeline (dir=b => most-recent first). alpha has two beats; keep highest seq.
|
||||||
|
const chunk = [
|
||||||
|
{
|
||||||
|
sender: '@agent-bravo:matrix.localhost',
|
||||||
|
origin_server_ts: 9000,
|
||||||
|
content: {
|
||||||
|
msgtype: 'mosaic.presence',
|
||||||
|
agent: { slug: 'bravo', mxid: '@agent-bravo:matrix.localhost' },
|
||||||
|
seq: 5,
|
||||||
|
status: 'online',
|
||||||
|
ts: 8999,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sender: '@agent-alpha:matrix.localhost',
|
||||||
|
origin_server_ts: 8000,
|
||||||
|
content: {
|
||||||
|
msgtype: 'mosaic.presence',
|
||||||
|
agent: { slug: 'alpha', mxid: '@agent-alpha:matrix.localhost' },
|
||||||
|
seq: 12,
|
||||||
|
status: 'online',
|
||||||
|
ts: 7999,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// an ordinary chat message must be ignored
|
||||||
|
sender: '@human:matrix.localhost',
|
||||||
|
origin_server_ts: 7000,
|
||||||
|
content: { msgtype: 'm.text', body: 'hi' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sender: '@agent-alpha:matrix.localhost',
|
||||||
|
origin_server_ts: 6000,
|
||||||
|
content: {
|
||||||
|
msgtype: 'mosaic.presence',
|
||||||
|
agent: { slug: 'alpha', mxid: '@agent-alpha:matrix.localhost' },
|
||||||
|
seq: 11,
|
||||||
|
status: 'online',
|
||||||
|
ts: 5999,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const fetchMock = mkFetch(async () => jsonResponse(200, { chunk }));
|
||||||
|
const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch);
|
||||||
|
const obs = await client.readHeartbeats('!room:matrix.localhost');
|
||||||
|
|
||||||
|
const bySlug = Object.fromEntries(obs.map((o) => [o.slug, o]));
|
||||||
|
expect(Object.keys(bySlug).sort()).toEqual(['alpha', 'bravo']);
|
||||||
|
expect(bySlug.alpha).toMatchObject({ lastSeq: 12, lastSeenTs: 8000 }); // highest seq wins, server ts
|
||||||
|
expect(bySlug.bravo).toMatchObject({ lastSeq: 5, lastSeenTs: 9000 });
|
||||||
|
|
||||||
|
const [url] = fetchMock.mock.calls[0]!;
|
||||||
|
const u = new URL((url as URL).toString());
|
||||||
|
expect(u.searchParams.get('dir')).toBe('b');
|
||||||
|
});
|
||||||
|
});
|
||||||
151
packages/comms/src/__tests__/presence-flow.test.ts
Normal file
151
packages/comms/src/__tests__/presence-flow.test.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { FleetLivenessReader } from '../liveness-reader.js';
|
||||||
|
import type { MinimalMatrixClient } from '../matrix-client.js';
|
||||||
|
import { PresenceAgent } from '../presence-agent.js';
|
||||||
|
import type { HeartbeatObservation, LivenessPolicy, PresenceStatus } from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An in-memory fake homeserver room: records heartbeats with a controllable
|
||||||
|
* server clock and reduces them exactly like the real readHeartbeats. Lets us
|
||||||
|
* prove the PresenceAgent -> room -> FleetLivenessReader flow (including the A3
|
||||||
|
* hard-kill -> offline transition) deterministically, with no network.
|
||||||
|
*/
|
||||||
|
class FakeRoomClient {
|
||||||
|
readonly beats: Array<{
|
||||||
|
slug: string;
|
||||||
|
mxid: string;
|
||||||
|
seq: number;
|
||||||
|
ts: number;
|
||||||
|
status: PresenceStatus;
|
||||||
|
}> = [];
|
||||||
|
presence: Record<string, PresenceStatus> = {};
|
||||||
|
|
||||||
|
constructor(private readonly clock: () => number) {}
|
||||||
|
|
||||||
|
async joinRoom(roomId: string): Promise<string> {
|
||||||
|
return roomId;
|
||||||
|
}
|
||||||
|
async setPresence(userId: string, status: PresenceStatus): Promise<void> {
|
||||||
|
this.presence[userId] = status;
|
||||||
|
}
|
||||||
|
async sendHeartbeat(
|
||||||
|
_roomId: string,
|
||||||
|
content: { agent: { slug: string; mxid: string }; seq: number; status: PresenceStatus },
|
||||||
|
): Promise<string> {
|
||||||
|
this.beats.push({
|
||||||
|
slug: content.agent.slug,
|
||||||
|
mxid: content.agent.mxid,
|
||||||
|
seq: content.seq,
|
||||||
|
ts: this.clock(), // server receive time
|
||||||
|
status: content.status,
|
||||||
|
});
|
||||||
|
return `$evt${this.beats.length}`;
|
||||||
|
}
|
||||||
|
async readHeartbeats(): Promise<HeartbeatObservation[]> {
|
||||||
|
const bySlug = new Map<string, HeartbeatObservation>();
|
||||||
|
for (const b of this.beats) {
|
||||||
|
const prev = bySlug.get(b.slug);
|
||||||
|
if (!prev || b.seq > prev.lastSeq) {
|
||||||
|
bySlug.set(b.slug, {
|
||||||
|
slug: b.slug,
|
||||||
|
mxid: b.mxid,
|
||||||
|
lastSeenTs: b.ts,
|
||||||
|
lastSeq: b.seq,
|
||||||
|
assertedStatus: b.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...bySlug.values()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy: LivenessPolicy = {
|
||||||
|
heartbeatIntervalMs: 1000,
|
||||||
|
missTolerance: 2,
|
||||||
|
darkThresholdMs: 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('presence flow (A2 + A3 at unit level)', () => {
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it('shows agents online while beating, then A3: a hard-killed agent goes offline within dark_threshold', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(0);
|
||||||
|
|
||||||
|
const fake = new FakeRoomClient(() => Date.now());
|
||||||
|
const client = fake as unknown as MinimalMatrixClient;
|
||||||
|
const reader = new FleetLivenessReader({
|
||||||
|
client,
|
||||||
|
roomId: '!fleet',
|
||||||
|
policy,
|
||||||
|
now: () => Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mk = (slug: string) =>
|
||||||
|
new PresenceAgent({
|
||||||
|
client,
|
||||||
|
agent: { mxid: `@agent-${slug}:matrix.localhost`, slug, harness: 'claude-code' },
|
||||||
|
roomId: '!fleet',
|
||||||
|
intervalMs: 1000,
|
||||||
|
policy,
|
||||||
|
});
|
||||||
|
|
||||||
|
const alpha = mk('alpha');
|
||||||
|
const bravo = mk('bravo');
|
||||||
|
const charlie = mk('charlie');
|
||||||
|
|
||||||
|
for (const a of [alpha, bravo, charlie]) {
|
||||||
|
await a.connect();
|
||||||
|
a.start();
|
||||||
|
}
|
||||||
|
// native presence set online for all three (Element dot)
|
||||||
|
expect(fake.presence['@agent-alpha:matrix.localhost']).toBe('online');
|
||||||
|
|
||||||
|
// let a couple of beats flow — all three fresh => online (A2)
|
||||||
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
|
const board1 = Object.fromEntries((await reader.read()).map((r) => [r.slug, r.status]));
|
||||||
|
expect(board1).toEqual({ alpha: 'online', bravo: 'online', charlie: 'online' });
|
||||||
|
|
||||||
|
// HARD-KILL charlie: stop its loop, no more beats. alpha/bravo keep beating.
|
||||||
|
charlie.pauseHeartbeat(); // hard-kill: no graceful presence signal
|
||||||
|
|
||||||
|
// advance to just before dark threshold from charlie's last beat...
|
||||||
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
|
const mid = Object.fromEntries((await reader.read()).map((r) => [r.slug, r.status]));
|
||||||
|
expect(mid.alpha).toBe('online');
|
||||||
|
expect(mid.charlie).not.toBe('online'); // already stale (away)
|
||||||
|
|
||||||
|
// ...advance past dark_threshold: charlie is deterministically offline.
|
||||||
|
await vi.advanceTimersByTimeAsync(4000);
|
||||||
|
const final = await reader.read();
|
||||||
|
const byslug = Object.fromEntries(final.map((r) => [r.slug, r]));
|
||||||
|
expect(byslug.charlie!.status).toBe('offline');
|
||||||
|
expect(byslug.alpha!.status).toBe('online');
|
||||||
|
expect(byslug.bravo!.status).toBe('online');
|
||||||
|
|
||||||
|
for (const a of [alpha, bravo]) await a.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formatBoard renders a human-readable liveness board (A4)', async () => {
|
||||||
|
const fake = new FakeRoomClient(() => 10_000);
|
||||||
|
fake.beats.push({
|
||||||
|
slug: 'alpha',
|
||||||
|
mxid: '@agent-alpha:matrix.localhost',
|
||||||
|
seq: 3,
|
||||||
|
ts: 9_500,
|
||||||
|
status: 'online',
|
||||||
|
});
|
||||||
|
const reader = new FleetLivenessReader({
|
||||||
|
client: fake as unknown as MinimalMatrixClient,
|
||||||
|
roomId: '!fleet',
|
||||||
|
policy,
|
||||||
|
now: () => 10_000,
|
||||||
|
});
|
||||||
|
const board = await reader.formatBoard();
|
||||||
|
expect(board).toContain('Fleet presence');
|
||||||
|
expect(board).toContain('alpha');
|
||||||
|
expect(board).toContain('online');
|
||||||
|
expect(board).toContain('online=1');
|
||||||
|
});
|
||||||
|
});
|
||||||
124
packages/comms/src/heartbeat.ts
Normal file
124
packages/comms/src/heartbeat.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* `mosaic.presence` heartbeat construction and loop (RFC-001 §4.2/§4.5).
|
||||||
|
*
|
||||||
|
* The emitter is deterministic and side-effect free (easy to unit test): it
|
||||||
|
* owns the monotonic `seq` and stamps each beat. The loop wires the emitter to
|
||||||
|
* a sender on an interval; timers are injectable so the loop is testable with
|
||||||
|
* fake clocks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MACP_VERSION, type PresenceHeartbeatContent, type PresenceStatus } from './types.js';
|
||||||
|
|
||||||
|
export interface HeartbeatAgentIdentity {
|
||||||
|
mxid: string;
|
||||||
|
slug: string;
|
||||||
|
harness: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeartbeatEmitterOptions {
|
||||||
|
agent: HeartbeatAgentIdentity;
|
||||||
|
/** Nominal interval advertised in each beat (interval_ms). */
|
||||||
|
intervalMs: number;
|
||||||
|
/** Optional mission correlation (RFC-001 §4.2 envelope). */
|
||||||
|
missionId?: string;
|
||||||
|
/** Injectable clock for deterministic tests. Default Date.now. */
|
||||||
|
now?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces successive heartbeat contents with a monotonically increasing seq.
|
||||||
|
* The first `next()` returns seq=1.
|
||||||
|
*/
|
||||||
|
export class HeartbeatEmitter {
|
||||||
|
private seq = 0;
|
||||||
|
private readonly now: () => number;
|
||||||
|
|
||||||
|
constructor(private readonly opts: HeartbeatEmitterOptions) {
|
||||||
|
this.now = opts.now ?? Date.now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current sequence number (0 before the first beat). */
|
||||||
|
get currentSeq(): number {
|
||||||
|
return this.seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the next heartbeat content, advancing the sequence. */
|
||||||
|
next(status: PresenceStatus = 'online'): PresenceHeartbeatContent {
|
||||||
|
this.seq += 1;
|
||||||
|
const ts = this.now();
|
||||||
|
const content: PresenceHeartbeatContent = {
|
||||||
|
macp_version: MACP_VERSION,
|
||||||
|
macp_type: 'presence',
|
||||||
|
msgtype: 'mosaic.presence',
|
||||||
|
agent: {
|
||||||
|
mxid: this.opts.agent.mxid,
|
||||||
|
slug: this.opts.agent.slug,
|
||||||
|
harness: this.opts.agent.harness,
|
||||||
|
},
|
||||||
|
ts,
|
||||||
|
body: `${this.opts.agent.slug} ${status} (seq ${this.seq})`,
|
||||||
|
status,
|
||||||
|
seq: this.seq,
|
||||||
|
interval_ms: this.opts.intervalMs,
|
||||||
|
};
|
||||||
|
if (this.opts.missionId !== undefined) {
|
||||||
|
content.mission_id = this.opts.missionId;
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HeartbeatSender = (content: PresenceHeartbeatContent) => void | Promise<void>;
|
||||||
|
|
||||||
|
export interface HeartbeatLoopOptions {
|
||||||
|
emitter: HeartbeatEmitter;
|
||||||
|
send: HeartbeatSender;
|
||||||
|
intervalMs: number;
|
||||||
|
/** Status supplier evaluated each beat. Default: always 'online'. */
|
||||||
|
status?: () => PresenceStatus;
|
||||||
|
/** Called if a beat's send rejects (so a transient failure doesn't kill the loop). */
|
||||||
|
onError?: (err: unknown) => void;
|
||||||
|
/** Injectable timer (tests). Defaults to global setInterval/clearInterval. */
|
||||||
|
setIntervalFn?: (cb: () => void, ms: number) => unknown;
|
||||||
|
clearIntervalFn?: (handle: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A running heartbeat loop; call stop() to end it. */
|
||||||
|
export interface HeartbeatLoopHandle {
|
||||||
|
stop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a heartbeat loop: emits one beat immediately, then every intervalMs.
|
||||||
|
* Returns a handle whose `stop()` is idempotent.
|
||||||
|
*/
|
||||||
|
export function startHeartbeatLoop(opts: HeartbeatLoopOptions): HeartbeatLoopHandle {
|
||||||
|
const status = opts.status ?? (() => 'online' as PresenceStatus);
|
||||||
|
const onError = opts.onError ?? (() => {});
|
||||||
|
const setIntervalFn = opts.setIntervalFn ?? ((cb, ms) => setInterval(cb, ms));
|
||||||
|
const clearIntervalFn =
|
||||||
|
opts.clearIntervalFn ?? ((h) => clearInterval(h as ReturnType<typeof setInterval>));
|
||||||
|
|
||||||
|
const beat = (): void => {
|
||||||
|
try {
|
||||||
|
const result = opts.send(opts.emitter.next(status()));
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
result.catch(onError);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
onError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
beat(); // immediate first beat so liveness is fresh at once
|
||||||
|
const handle = setIntervalFn(beat, opts.intervalMs);
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
return {
|
||||||
|
stop: () => {
|
||||||
|
if (stopped) return;
|
||||||
|
stopped = true;
|
||||||
|
clearIntervalFn(handle);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
42
packages/comms/src/index.ts
Normal file
42
packages/comms/src/index.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* @mosaicstack/comms — MACP presence SDK (RFC-001 P1).
|
||||||
|
*
|
||||||
|
* Minimal, dev-validated slice: set Matrix presence, run the `mosaic.presence`
|
||||||
|
* heartbeat, and compute deterministic fleet liveness. Enrollment, room
|
||||||
|
* taxonomy, token minting and signed-authorship are explicitly out of P1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { classifyLiveness, computeFleetLiveness } from './liveness.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
HeartbeatEmitter,
|
||||||
|
startHeartbeatLoop,
|
||||||
|
type HeartbeatAgentIdentity,
|
||||||
|
type HeartbeatEmitterOptions,
|
||||||
|
type HeartbeatSender,
|
||||||
|
type HeartbeatLoopOptions,
|
||||||
|
type HeartbeatLoopHandle,
|
||||||
|
} from './heartbeat.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
MinimalMatrixClient,
|
||||||
|
MatrixError,
|
||||||
|
toMatrixPresence,
|
||||||
|
type MatrixClientConfig,
|
||||||
|
} from './matrix-client.js';
|
||||||
|
|
||||||
|
export { FleetLivenessReader, type FleetLivenessReaderOptions } from './liveness-reader.js';
|
||||||
|
|
||||||
|
export { PresenceAgent, type PresenceAgentOptions } from './presence-agent.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
DEFAULT_LIVENESS_POLICY,
|
||||||
|
MACP_VERSION,
|
||||||
|
type AgentLiveness,
|
||||||
|
type HeartbeatObservation,
|
||||||
|
type LivenessPolicy,
|
||||||
|
type MacpEnvelope,
|
||||||
|
type MatrixPresence,
|
||||||
|
type PresenceHeartbeatContent,
|
||||||
|
type PresenceStatus,
|
||||||
|
} from './types.js';
|
||||||
58
packages/comms/src/liveness-reader.ts
Normal file
58
packages/comms/src/liveness-reader.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Fleet liveness reader (RFC-001 §4.5, A2/A4).
|
||||||
|
*
|
||||||
|
* Reads `mosaic.presence` heartbeats from the fleet presence room and computes
|
||||||
|
* deterministic online/away/offline for every agent. This is the surface a
|
||||||
|
* human (or the escalation watchdog, P2+) reads to answer "who's alive?".
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computeFleetLiveness } from './liveness.js';
|
||||||
|
import type { MinimalMatrixClient } from './matrix-client.js';
|
||||||
|
import { DEFAULT_LIVENESS_POLICY, type AgentLiveness, type LivenessPolicy } from './types.js';
|
||||||
|
|
||||||
|
export interface FleetLivenessReaderOptions {
|
||||||
|
client: MinimalMatrixClient;
|
||||||
|
/** The fleet presence room (id or resolved id). */
|
||||||
|
roomId: string;
|
||||||
|
policy?: LivenessPolicy;
|
||||||
|
/** Injectable clock for tests. Default Date.now. */
|
||||||
|
now?: () => number;
|
||||||
|
/** How many timeline events to scan back. Default 200. */
|
||||||
|
scanLimit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FleetLivenessReader {
|
||||||
|
private readonly policy: LivenessPolicy;
|
||||||
|
private readonly now: () => number;
|
||||||
|
|
||||||
|
constructor(private readonly opts: FleetLivenessReaderOptions) {
|
||||||
|
this.policy = opts.policy ?? DEFAULT_LIVENESS_POLICY;
|
||||||
|
this.now = opts.now ?? Date.now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the room and compute current liveness for every seen agent. */
|
||||||
|
async read(): Promise<AgentLiveness[]> {
|
||||||
|
const observations = await this.opts.client.readHeartbeats(
|
||||||
|
this.opts.roomId,
|
||||||
|
this.opts.scanLimit ?? 200,
|
||||||
|
);
|
||||||
|
return computeFleetLiveness(observations, this.now(), this.policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A compact human-readable liveness board (A4 CLI view). */
|
||||||
|
async formatBoard(): Promise<string> {
|
||||||
|
const rows = await this.read();
|
||||||
|
rows.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||||
|
const dot: Record<string, string> = { online: '🟢', away: '🟡', offline: '🔴' };
|
||||||
|
const lines = rows.map(
|
||||||
|
(r) =>
|
||||||
|
`${dot[r.status] ?? '⚪'} ${r.slug.padEnd(16)} ${r.status.padEnd(8)} ` +
|
||||||
|
`age=${(r.ageMs / 1000).toFixed(1)}s seq=${r.lastSeq} ${r.mxid}`,
|
||||||
|
);
|
||||||
|
const summary =
|
||||||
|
`online=${rows.filter((r) => r.status === 'online').length} ` +
|
||||||
|
`away=${rows.filter((r) => r.status === 'away').length} ` +
|
||||||
|
`offline=${rows.filter((r) => r.status === 'offline').length}`;
|
||||||
|
return [`Fleet presence — ${summary}`, ...lines].join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
63
packages/comms/src/liveness.ts
Normal file
63
packages/comms/src/liveness.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Deterministic liveness computation (RFC-001 §4.5).
|
||||||
|
*
|
||||||
|
* The authoritative liveness signal is the `mosaic.presence` heartbeat, NOT
|
||||||
|
* native Matrix presence. Given the age of an agent's last heartbeat and a
|
||||||
|
* policy, these pure functions classify online/away/offline the same way every
|
||||||
|
* time — which is exactly what makes the A3 "hard-killed agent flips to
|
||||||
|
* offline within dark_threshold" guarantee deterministic and testable without
|
||||||
|
* standing up a homeserver.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AgentLiveness,
|
||||||
|
HeartbeatObservation,
|
||||||
|
LivenessPolicy,
|
||||||
|
PresenceStatus,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a single agent from the age (ms) of its last heartbeat.
|
||||||
|
*
|
||||||
|
* - `age <= heartbeatIntervalMs * missTolerance` → **online**
|
||||||
|
* - `age < darkThresholdMs` → **away**
|
||||||
|
* - otherwise (or non-finite age) → **offline / dark**
|
||||||
|
*
|
||||||
|
* A non-finite age (never seen / NaN) fails safe to `offline`: we never assert
|
||||||
|
* a liveness we cannot substantiate.
|
||||||
|
*/
|
||||||
|
export function classifyLiveness(ageMs: number, policy: LivenessPolicy): PresenceStatus {
|
||||||
|
if (!Number.isFinite(ageMs)) {
|
||||||
|
return 'offline';
|
||||||
|
}
|
||||||
|
const onlineWindowMs = policy.heartbeatIntervalMs * policy.missTolerance;
|
||||||
|
if (ageMs <= onlineWindowMs) {
|
||||||
|
return 'online';
|
||||||
|
}
|
||||||
|
if (ageMs < policy.darkThresholdMs) {
|
||||||
|
return 'away';
|
||||||
|
}
|
||||||
|
return 'offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute liveness for every observed agent at wall-clock `nowMs`.
|
||||||
|
* The result order mirrors the input order (stable for display).
|
||||||
|
*/
|
||||||
|
export function computeFleetLiveness(
|
||||||
|
observations: readonly HeartbeatObservation[],
|
||||||
|
nowMs: number,
|
||||||
|
policy: LivenessPolicy,
|
||||||
|
): AgentLiveness[] {
|
||||||
|
return observations.map((o) => {
|
||||||
|
const ageMs = nowMs - o.lastSeenTs;
|
||||||
|
return {
|
||||||
|
slug: o.slug,
|
||||||
|
mxid: o.mxid,
|
||||||
|
status: classifyLiveness(ageMs, policy),
|
||||||
|
lastSeenTs: o.lastSeenTs,
|
||||||
|
ageMs,
|
||||||
|
lastSeq: o.lastSeq,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
204
packages/comms/src/matrix-client.ts
Normal file
204
packages/comms/src/matrix-client.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Minimal Matrix Client-Server API client for the P1 presence slice.
|
||||||
|
*
|
||||||
|
* Deliberately tiny: only the calls presence needs (whoami, set native
|
||||||
|
* presence, send a timeline event, read recent timeline). Auth is a single
|
||||||
|
* bearer token; an optional `actAsUserId` enables Application-Service
|
||||||
|
* masquerade (`?user_id=`) so the P1 provisioner can drive several virtual
|
||||||
|
* agents with one as_token in dev (RFC-001 §2.2 step 4/Appendix A). Agents
|
||||||
|
* holding their own access_token simply omit `actAsUserId`.
|
||||||
|
*
|
||||||
|
* `fetch` is injectable for unit tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
HeartbeatObservation,
|
||||||
|
MatrixPresence,
|
||||||
|
PresenceHeartbeatContent,
|
||||||
|
PresenceStatus,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export interface MatrixClientConfig {
|
||||||
|
/** Client-Server API base, e.g. https://matrix.localhost:8448 */
|
||||||
|
homeserverUrl: string;
|
||||||
|
/** Bearer token (a per-agent access_token, or an as_token for masquerade). */
|
||||||
|
accessToken: string;
|
||||||
|
/** If set, all calls masquerade as this MXID via ?user_id= (AS mode). */
|
||||||
|
actAsUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MatrixError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly errcode: string | undefined,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'MatrixError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchLike = typeof fetch;
|
||||||
|
|
||||||
|
/** Map our authoritative liveness state to the native Matrix presence EDU. */
|
||||||
|
export function toMatrixPresence(status: PresenceStatus): MatrixPresence {
|
||||||
|
switch (status) {
|
||||||
|
case 'online':
|
||||||
|
return 'online';
|
||||||
|
case 'away':
|
||||||
|
return 'unavailable';
|
||||||
|
case 'offline':
|
||||||
|
return 'offline';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MinimalMatrixClient {
|
||||||
|
private readonly fetchImpl: FetchLike;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly cfg: MatrixClientConfig,
|
||||||
|
fetchImpl?: FetchLike,
|
||||||
|
) {
|
||||||
|
this.fetchImpl = fetchImpl ?? fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
options: { query?: Record<string, string>; body?: unknown } = {},
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const url = new URL(this.cfg.homeserverUrl.replace(/\/$/, '') + path);
|
||||||
|
if (this.cfg.actAsUserId) {
|
||||||
|
url.searchParams.set('user_id', this.cfg.actAsUserId);
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(options.query ?? {})) {
|
||||||
|
url.searchParams.set(k, v);
|
||||||
|
}
|
||||||
|
const res = await this.fetchImpl(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.cfg.accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
const data = (text ? JSON.parse(text) : {}) as Record<string, unknown>;
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new MatrixError(
|
||||||
|
res.status,
|
||||||
|
typeof data.errcode === 'string' ? data.errcode : undefined,
|
||||||
|
`${method} ${path} -> ${res.status}: ${text.slice(0, 300)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /account/whoami — resolves the acting MXID. */
|
||||||
|
async whoami(): Promise<string> {
|
||||||
|
const data = await this.request('GET', '/_matrix/client/v3/account/whoami');
|
||||||
|
if (typeof data.user_id !== 'string') {
|
||||||
|
throw new MatrixError(500, undefined, 'whoami returned no user_id');
|
||||||
|
}
|
||||||
|
return data.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the native Matrix presence EDU (so Element shows the right dot for
|
||||||
|
* humans). NOT the authoritative liveness signal — the heartbeat is.
|
||||||
|
*/
|
||||||
|
async setPresence(userId: string, status: PresenceStatus, statusMsg?: string): Promise<void> {
|
||||||
|
const user = encodeURIComponent(userId);
|
||||||
|
await this.request('PUT', `/_matrix/client/v3/presence/${user}/status`, {
|
||||||
|
body: {
|
||||||
|
presence: toMatrixPresence(status),
|
||||||
|
...(statusMsg ? { status_msg: statusMsg } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send an arbitrary timeline event; returns its event_id. */
|
||||||
|
async sendEvent(
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
content: Record<string, unknown>,
|
||||||
|
): Promise<string> {
|
||||||
|
const room = encodeURIComponent(roomId);
|
||||||
|
const txn = `mosaic-comms-${crypto.randomUUID()}`;
|
||||||
|
const data = await this.request(
|
||||||
|
'PUT',
|
||||||
|
`/_matrix/client/v3/rooms/${room}/send/${encodeURIComponent(eventType)}/${txn}`,
|
||||||
|
{ body: content },
|
||||||
|
);
|
||||||
|
if (typeof data.event_id !== 'string') {
|
||||||
|
throw new MatrixError(500, undefined, 'send returned no event_id');
|
||||||
|
}
|
||||||
|
return data.event_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Post a `mosaic.presence` heartbeat (m.room.message carrier) to the room. */
|
||||||
|
async sendHeartbeat(roomId: string, content: PresenceHeartbeatContent): Promise<string> {
|
||||||
|
return this.sendEvent(roomId, 'm.room.message', content as unknown as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Join a room (by id or alias). Idempotent on the server. */
|
||||||
|
async joinRoom(roomIdOrAlias: string): Promise<string> {
|
||||||
|
const data = await this.request(
|
||||||
|
'POST',
|
||||||
|
`/_matrix/client/v3/join/${encodeURIComponent(roomIdOrAlias)}`,
|
||||||
|
{ body: {} },
|
||||||
|
);
|
||||||
|
if (typeof data.room_id !== 'string') {
|
||||||
|
throw new MatrixError(500, undefined, 'join returned no room_id');
|
||||||
|
}
|
||||||
|
return data.room_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read recent `mosaic.presence` heartbeats from a room and reduce them to the
|
||||||
|
* latest observation per agent. Walks the timeline backwards (most-recent
|
||||||
|
* first) and keeps, per slug, the beat with the highest seq.
|
||||||
|
*
|
||||||
|
* `lastSeenTs` uses the server's `origin_server_ts` (honest "when we last
|
||||||
|
* heard from it"), falling back to the agent-stamped envelope `ts`.
|
||||||
|
*/
|
||||||
|
async readHeartbeats(roomId: string, limit = 200): Promise<HeartbeatObservation[]> {
|
||||||
|
const room = encodeURIComponent(roomId);
|
||||||
|
const data = await this.request('GET', `/_matrix/client/v3/rooms/${room}/messages`, {
|
||||||
|
query: { dir: 'b', limit: String(limit) },
|
||||||
|
});
|
||||||
|
const chunk = Array.isArray(data.chunk) ? (data.chunk as Array<Record<string, unknown>>) : [];
|
||||||
|
const bySlug = new Map<string, HeartbeatObservation>();
|
||||||
|
|
||||||
|
for (const ev of chunk) {
|
||||||
|
const content = ev.content as Record<string, unknown> | undefined;
|
||||||
|
if (!content || content.msgtype !== 'mosaic.presence') continue;
|
||||||
|
const agent = content.agent as Record<string, unknown> | undefined;
|
||||||
|
const slug = agent && typeof agent.slug === 'string' ? agent.slug : undefined;
|
||||||
|
const mxid =
|
||||||
|
agent && typeof agent.mxid === 'string'
|
||||||
|
? agent.mxid
|
||||||
|
: typeof ev.sender === 'string'
|
||||||
|
? ev.sender
|
||||||
|
: undefined;
|
||||||
|
if (!slug || !mxid) continue;
|
||||||
|
|
||||||
|
const seq = typeof content.seq === 'number' ? content.seq : 0;
|
||||||
|
const serverTs = typeof ev.origin_server_ts === 'number' ? ev.origin_server_ts : undefined;
|
||||||
|
const envelopeTs = typeof content.ts === 'number' ? content.ts : undefined;
|
||||||
|
const lastSeenTs = serverTs ?? envelopeTs ?? 0;
|
||||||
|
const assertedStatus =
|
||||||
|
content.status === 'online' || content.status === 'away' || content.status === 'offline'
|
||||||
|
? (content.status as PresenceStatus)
|
||||||
|
: 'offline';
|
||||||
|
|
||||||
|
const prev = bySlug.get(slug);
|
||||||
|
if (!prev || seq > prev.lastSeq) {
|
||||||
|
bySlug.set(slug, { slug, mxid, lastSeenTs, lastSeq: seq, assertedStatus });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...bySlug.values()];
|
||||||
|
}
|
||||||
|
}
|
||||||
92
packages/comms/src/presence-agent.ts
Normal file
92
packages/comms/src/presence-agent.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* High-level presence agent (RFC-001 §4.1 steps 10–11, §4.5).
|
||||||
|
*
|
||||||
|
* Ties the pieces together for one agent: join the fleet presence room, set
|
||||||
|
* native Matrix presence online (for Element's dot), and run the authoritative
|
||||||
|
* `mosaic.presence` heartbeat loop. This is the P1 slice of what a harness does
|
||||||
|
* on spin — no enrollment/token-minting/introductions (those are P2).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
HeartbeatEmitter,
|
||||||
|
startHeartbeatLoop,
|
||||||
|
type HeartbeatAgentIdentity,
|
||||||
|
type HeartbeatLoopHandle,
|
||||||
|
} from './heartbeat.js';
|
||||||
|
import type { MinimalMatrixClient } from './matrix-client.js';
|
||||||
|
import { DEFAULT_LIVENESS_POLICY, type LivenessPolicy, type PresenceStatus } from './types.js';
|
||||||
|
|
||||||
|
export interface PresenceAgentOptions {
|
||||||
|
client: MinimalMatrixClient;
|
||||||
|
agent: HeartbeatAgentIdentity;
|
||||||
|
/** Fleet presence room id (or alias) to heartbeat into. */
|
||||||
|
roomId: string;
|
||||||
|
/** Heartbeat cadence; defaults to the policy interval. */
|
||||||
|
intervalMs?: number;
|
||||||
|
policy?: LivenessPolicy;
|
||||||
|
missionId?: string;
|
||||||
|
onError?: (err: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PresenceAgent {
|
||||||
|
private readonly intervalMs: number;
|
||||||
|
private readonly emitter: HeartbeatEmitter;
|
||||||
|
private loop: HeartbeatLoopHandle | undefined;
|
||||||
|
private resolvedRoomId: string | undefined;
|
||||||
|
|
||||||
|
constructor(private readonly opts: PresenceAgentOptions) {
|
||||||
|
const policy = opts.policy ?? DEFAULT_LIVENESS_POLICY;
|
||||||
|
this.intervalMs = opts.intervalMs ?? policy.heartbeatIntervalMs;
|
||||||
|
this.emitter = new HeartbeatEmitter({
|
||||||
|
agent: opts.agent,
|
||||||
|
intervalMs: this.intervalMs,
|
||||||
|
missionId: opts.missionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Join the fleet room and go present. Returns the resolved room id. */
|
||||||
|
async connect(): Promise<string> {
|
||||||
|
this.resolvedRoomId = await this.opts.client.joinRoom(this.opts.roomId);
|
||||||
|
await this.opts.client.setPresence(this.opts.agent.mxid, 'online', 'mosaic.presence heartbeat');
|
||||||
|
return this.resolvedRoomId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start the heartbeat loop (emits immediately, then every intervalMs). */
|
||||||
|
start(status: () => PresenceStatus = () => 'online'): void {
|
||||||
|
const roomId = this.resolvedRoomId ?? this.opts.roomId;
|
||||||
|
this.loop = startHeartbeatLoop({
|
||||||
|
emitter: this.emitter,
|
||||||
|
intervalMs: this.intervalMs,
|
||||||
|
status,
|
||||||
|
onError: this.opts.onError,
|
||||||
|
send: async (content) => {
|
||||||
|
await this.opts.client.sendHeartbeat(roomId, content);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentSeq(): number {
|
||||||
|
return this.emitter.currentSeq;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop only the heartbeat loop, sending NO graceful signal. This models a
|
||||||
|
* hard crash/kill: the authoritative liveness path must detect it purely from
|
||||||
|
* the absence of heartbeats (RFC-001 §4.5, A3), not from any native presence
|
||||||
|
* change. Idempotent.
|
||||||
|
*/
|
||||||
|
pauseHeartbeat(): void {
|
||||||
|
this.loop?.stop();
|
||||||
|
this.loop = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Graceful stop: stop heartbeating and drop native presence to offline. */
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.pauseHeartbeat();
|
||||||
|
try {
|
||||||
|
await this.opts.client.setPresence(this.opts.agent.mxid, 'offline');
|
||||||
|
} catch (err) {
|
||||||
|
this.opts.onError?.(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
99
packages/comms/src/types.ts
Normal file
99
packages/comms/src/types.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* @mosaicstack/comms — MACP P1 (presence) types.
|
||||||
|
*
|
||||||
|
* Implements the presence/liveness slice of RFC-001 §4.5 and the MACP event
|
||||||
|
* envelope of RFC-001 §4.2. P1 scope only: presence heartbeat + deterministic
|
||||||
|
* liveness. No enrollment, room-taxonomy, token-minting or signed-authorship
|
||||||
|
* (those are P2+).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The three human-visible liveness states (RFC-001 §4.5). */
|
||||||
|
export type PresenceStatus = 'online' | 'away' | 'offline';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native Matrix presence EDU states. We still emit these (so Element shows the
|
||||||
|
* right dot for humans, RFC-001 §4.5) but they are NOT the authoritative
|
||||||
|
* liveness source — the heartbeat is.
|
||||||
|
*/
|
||||||
|
export type MatrixPresence = 'online' | 'unavailable' | 'offline';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common MACP event envelope carried in `content` on every custom event
|
||||||
|
* (RFC-001 §4.2). P1 uses only the fields the presence heartbeat needs; the
|
||||||
|
* `signature` field (gate actions, §4.4) is intentionally absent in P1.
|
||||||
|
*/
|
||||||
|
export interface MacpEnvelope {
|
||||||
|
macp_version: string;
|
||||||
|
macp_type: string;
|
||||||
|
agent: {
|
||||||
|
mxid: string;
|
||||||
|
slug: string;
|
||||||
|
harness: string;
|
||||||
|
};
|
||||||
|
ts: number;
|
||||||
|
mission_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `mosaic.presence` heartbeat content (RFC-001 §4.2 "presence" row + §4.5).
|
||||||
|
* Carried as an `m.room.message` with `msgtype: "mosaic.presence"` and a
|
||||||
|
* human-visible `body` fallback, posted into the fleet presence room.
|
||||||
|
*/
|
||||||
|
export interface PresenceHeartbeatContent extends MacpEnvelope {
|
||||||
|
macp_type: 'presence';
|
||||||
|
msgtype: 'mosaic.presence';
|
||||||
|
/** Human-visible fallback so the event renders in a stock client. */
|
||||||
|
body: string;
|
||||||
|
/** Liveness state the agent asserts about itself. */
|
||||||
|
status: PresenceStatus;
|
||||||
|
/** Monotonic per-agent sequence number, increments once per beat. */
|
||||||
|
seq: number;
|
||||||
|
/** The agent's configured heartbeat interval, so readers can reason. */
|
||||||
|
interval_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic liveness policy (RFC-001 §4.5). Defaults per §4.5/§5.3:
|
||||||
|
* interval 30s, miss-tolerance 2, dark threshold a policy value (10 min in
|
||||||
|
* prod §5; small in dev harness).
|
||||||
|
*/
|
||||||
|
export interface LivenessPolicy {
|
||||||
|
/** Nominal heartbeat interval in ms. Default 30_000. */
|
||||||
|
heartbeatIntervalMs: number;
|
||||||
|
/** How many intervals may be missed before "away". Default 2. */
|
||||||
|
missTolerance: number;
|
||||||
|
/** Age past which an agent is declared offline/dark. Default 600_000. */
|
||||||
|
darkThresholdMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single agent's last observed heartbeat, as read from the fleet room. */
|
||||||
|
export interface HeartbeatObservation {
|
||||||
|
slug: string;
|
||||||
|
mxid: string;
|
||||||
|
/** Wall-clock ms of the last heartbeat seen for this agent. */
|
||||||
|
lastSeenTs: number;
|
||||||
|
/** Last seq observed (monotonic per agent). */
|
||||||
|
lastSeq: number;
|
||||||
|
/** The status the agent last asserted about itself. */
|
||||||
|
assertedStatus: PresenceStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Computed liveness for one agent (what a human/watchdog reads). */
|
||||||
|
export interface AgentLiveness {
|
||||||
|
slug: string;
|
||||||
|
mxid: string;
|
||||||
|
/** Authoritative, heartbeat-derived status. */
|
||||||
|
status: PresenceStatus;
|
||||||
|
lastSeenTs: number;
|
||||||
|
/** now - lastSeenTs, in ms. */
|
||||||
|
ageMs: number;
|
||||||
|
lastSeq: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LIVENESS_POLICY: LivenessPolicy = {
|
||||||
|
heartbeatIntervalMs: 30_000,
|
||||||
|
missTolerance: 2,
|
||||||
|
darkThresholdMs: 600_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MACP_VERSION = '1.0';
|
||||||
9
packages/comms/tsconfig.json
Normal file
9
packages/comms/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
13
packages/comms/vitest.config.ts
Normal file
13
packages/comms/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['src/index.ts'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -18,12 +18,33 @@ set -Eeuo pipefail
|
|||||||
# MOSAIC_INSTALL_MODE — prompt|keep|overwrite (default: prompt)
|
# MOSAIC_INSTALL_MODE — prompt|keep|overwrite (default: prompt)
|
||||||
# MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING — 1 to bypass MCP check
|
# MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING — 1 to bypass MCP check
|
||||||
# MOSAIC_SKIP_SKILLS_SYNC — 1 to skip skill sync
|
# MOSAIC_SKIP_SKILLS_SYNC — 1 to skip skill sync
|
||||||
|
#
|
||||||
|
# Flags (CLI args, NOT environment variables — see #869 Point-1 C2):
|
||||||
|
# --allow-inactive-enforcement Explicit, per-invocation opt-out that lets the
|
||||||
|
# lease-enforcement hooks (mutator-gate.py,
|
||||||
|
# receipt-observer-client.py) be wired into
|
||||||
|
# ~/.claude/settings.json even when this host
|
||||||
|
# cannot confirm it can ACTIVATE them. Loud on
|
||||||
|
# use (see mosaic-link-runtime-assets). Default
|
||||||
|
# (flag absent) is fail-loud: the enforcement
|
||||||
|
# hooks are NOT wired and the framework's
|
||||||
|
# runtime-asset-link step reports a failure.
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||||
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||||
|
|
||||||
|
# Deliberately parsed from "$@" (a real, explicit, per-invocation argument) —
|
||||||
|
# never an environment variable — so this opt-out can never sit silently
|
||||||
|
# inherited in a shell profile. See #869 Point-1 C2.
|
||||||
|
ALLOW_INACTIVE_ENFORCEMENT=0
|
||||||
|
for _arg in "$@"; do
|
||||||
|
case "$_arg" in
|
||||||
|
--allow-inactive-enforcement) ALLOW_INACTIVE_ENFORCEMENT=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
# Shared framework path-ownership manifest reader (#791). Parity with
|
# Shared framework path-ownership manifest reader (#791). Parity with
|
||||||
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
||||||
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
||||||
@@ -639,6 +660,10 @@ reconcile_framework_files
|
|||||||
# Ensure tool scripts are executable
|
# Ensure tool scripts are executable
|
||||||
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
||||||
find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true
|
find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true
|
||||||
|
# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh
|
||||||
|
# suffix — git resolves credential helpers by exact name/path, not extension —
|
||||||
|
# so the *.sh glob above does not cover it; chmod it explicitly.
|
||||||
|
[[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] && chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic" 2>/dev/null || true
|
||||||
|
|
||||||
ok "Framework synced to $TARGET_DIR"
|
ok "Framework synced to $TARGET_DIR"
|
||||||
|
|
||||||
@@ -666,10 +691,15 @@ step "Post-install tasks"
|
|||||||
SCRIPTS="$TARGET_DIR/tools/_scripts"
|
SCRIPTS="$TARGET_DIR/tools/_scripts"
|
||||||
|
|
||||||
if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
|
if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
|
||||||
if "$SCRIPTS/mosaic-link-runtime-assets" >/dev/null 2>&1; then
|
link_args=()
|
||||||
|
[[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement)
|
||||||
|
# stdout is suppressed as before, but stderr is left connected: the
|
||||||
|
# install-ordering guard's FAIL LOUD message (#869 Point-1 C2) must reach
|
||||||
|
# the operator, not be swallowed silently.
|
||||||
|
if "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}" >/dev/null; then
|
||||||
ok "Runtime assets linked"
|
ok "Runtime assets linked"
|
||||||
else
|
else
|
||||||
warn "Runtime asset linking failed (non-fatal)"
|
warn "Runtime asset linking failed (non-fatal) — see message above for details."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ set -euo pipefail
|
|||||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||||
backup_stamp="$(date +%Y%m%d%H%M%S)"
|
backup_stamp="$(date +%Y%m%d%H%M%S)"
|
||||||
|
|
||||||
|
# ─── Install-ordering guard opt-out (#869 Point-1 C2) ───────────────────────
|
||||||
|
# Explicit, per-invocation CLI flag ONLY — deliberately NOT read from an
|
||||||
|
# environment variable, so it can never sit as a silently-inherited default in
|
||||||
|
# a shell profile or CI env. Absent (the default) => hard fail-loud path.
|
||||||
|
allow_inactive_enforcement=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--allow-inactive-enforcement) allow_inactive_enforcement=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Tracks whether the Claude settings install-ordering guard (below) reported a
|
||||||
|
# degraded (enforcement-not-wired) outcome, so this script's own exit status
|
||||||
|
# reflects it even though the rest of the runtime-asset sync must still run.
|
||||||
|
guard_degraded=0
|
||||||
|
|
||||||
copy_file_managed() {
|
copy_file_managed() {
|
||||||
local src="$1"
|
local src="$1"
|
||||||
local dst="$2"
|
local dst="$2"
|
||||||
@@ -24,6 +40,103 @@ copy_file_managed() {
|
|||||||
cp "$src" "$dst"
|
cp "$src" "$dst"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── Install-ordering guard for settings.json (#869 Point-1 C2) ─────────────
|
||||||
|
#
|
||||||
|
# settings.json is where #828's enforcement hooks (PreToolUse mutator-gate.py,
|
||||||
|
# Stop receipt-observer-client.py) get wired unconditionally. Before copying
|
||||||
|
# it, delegate to `mosaic __link-claude-settings` (packages/mosaic/src/commands/
|
||||||
|
# install-ordering-guard.ts) so the wiring decision is made by importing the
|
||||||
|
# C1 activation probe (`leaseEnforcementActivatable()`) directly, rather than
|
||||||
|
# re-implementing the capability/supervisor checks in shell. That subcommand:
|
||||||
|
# - activatable -> writes settings.json with hooks intact, exits 0
|
||||||
|
# - NOT activatable -> writes settings.json with hooks STRIPPED,
|
||||||
|
# prints an actionable message, exits 1
|
||||||
|
# - NOT activatable + opt-out -> writes settings.json with hooks intact,
|
||||||
|
# prints a loud warning, exits 0
|
||||||
|
# The `mosaic` CLI is expected on PATH at this point ("No executables are
|
||||||
|
# placed on PATH — the mosaic npm CLI is the only binary", per install.sh).
|
||||||
|
# If it is not resolvable at all, that is itself strong evidence the
|
||||||
|
# activation half is absent, so the same fail-loud default applies via a
|
||||||
|
# minimal python3 fallback (this repo already depends on python3 for the
|
||||||
|
# lease broker itself).
|
||||||
|
copy_claude_settings_guarded() {
|
||||||
|
local src="$1"
|
||||||
|
local dst="$2"
|
||||||
|
|
||||||
|
local guard_args=(__link-claude-settings "$src" "$dst")
|
||||||
|
if [[ "$allow_inactive_enforcement" == "1" ]]; then
|
||||||
|
guard_args+=(--allow-inactive-enforcement)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v mosaic >/dev/null 2>&1; then
|
||||||
|
if mosaic "${guard_args[@]}"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "[mosaic-link] Enforcement hooks were NOT wired into $dst (see message above)." >&2
|
||||||
|
guard_degraded=1
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[mosaic-link] ERROR: 'mosaic' CLI not found on PATH — cannot confirm lease-enforcement" >&2
|
||||||
|
echo "[mosaic-link] activation capability. enforcement requested but activation half absent —" >&2
|
||||||
|
echo "[mosaic-link] needs a published CLI carrying launch-runtime activation + a broker" >&2
|
||||||
|
echo "[mosaic-link] supervisor; refusing to wire a dead gate (see #869)." >&2
|
||||||
|
|
||||||
|
if [[ "$allow_inactive_enforcement" == "1" ]]; then
|
||||||
|
echo "[mosaic-link] WARNING: --allow-inactive-enforcement set — wiring $dst AS-IS (with" >&2
|
||||||
|
echo "[mosaic-link] enforcement hooks) despite being unable to confirm activation." >&2
|
||||||
|
copy_file_managed "$src" "$dst"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$dst")"
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
python3 - "$src" "$dst" <<'PYEOF'
|
||||||
|
import json, sys
|
||||||
|
|
||||||
|
src, dest = sys.argv[1], sys.argv[2]
|
||||||
|
with open(src) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
hooks = data.get("hooks", {})
|
||||||
|
|
||||||
|
pre = hooks.get("PreToolUse", [])
|
||||||
|
hooks["PreToolUse"] = [
|
||||||
|
t for t in pre
|
||||||
|
if not any("mutator-gate.py" in h.get("command", "") for h in t.get("hooks", []))
|
||||||
|
]
|
||||||
|
if not hooks["PreToolUse"]:
|
||||||
|
del hooks["PreToolUse"]
|
||||||
|
|
||||||
|
stop = hooks.get("Stop", [])
|
||||||
|
new_stop = []
|
||||||
|
for t in stop:
|
||||||
|
kept = [h for h in t.get("hooks", []) if "receipt-observer-client.py" not in h.get("command", "")]
|
||||||
|
if kept:
|
||||||
|
t = dict(t)
|
||||||
|
t["hooks"] = kept
|
||||||
|
new_stop.append(t)
|
||||||
|
if new_stop:
|
||||||
|
hooks["Stop"] = new_stop
|
||||||
|
elif "Stop" in hooks:
|
||||||
|
del hooks["Stop"]
|
||||||
|
|
||||||
|
if hooks:
|
||||||
|
data["hooks"] = hooks
|
||||||
|
else:
|
||||||
|
data.pop("hooks", None)
|
||||||
|
|
||||||
|
with open(dest, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
PYEOF
|
||||||
|
else
|
||||||
|
cp "$src" "$dst"
|
||||||
|
fi
|
||||||
|
guard_degraded=1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
remove_legacy_path() {
|
remove_legacy_path() {
|
||||||
local p="$1"
|
local p="$1"
|
||||||
|
|
||||||
@@ -110,6 +223,13 @@ for runtime_file in \
|
|||||||
fi
|
fi
|
||||||
src="$MOSAIC_HOME/runtime/claude/$runtime_file"
|
src="$MOSAIC_HOME/runtime/claude/$runtime_file"
|
||||||
[[ -f "$src" ]] || continue
|
[[ -f "$src" ]] || continue
|
||||||
|
if [[ "$runtime_file" == "settings.json" ]]; then
|
||||||
|
# Install-ordering guard (#869 Point-1 C2): gate enforcement-hook wiring
|
||||||
|
# on confirmed activation instead of the plain copy_file_managed used for
|
||||||
|
# every other runtime file. See copy_claude_settings_guarded() above.
|
||||||
|
copy_claude_settings_guarded "$src" "$HOME/.claude/$runtime_file"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
copy_file_managed "$src" "$HOME/.claude/$runtime_file"
|
copy_file_managed "$src" "$HOME/.claude/$runtime_file"
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -167,3 +287,12 @@ fi
|
|||||||
|
|
||||||
echo "[mosaic-link] Runtime assets synced (non-symlink mode)"
|
echo "[mosaic-link] Runtime assets synced (non-symlink mode)"
|
||||||
echo "[mosaic-link] Canonical source: $MOSAIC_HOME"
|
echo "[mosaic-link] Canonical source: $MOSAIC_HOME"
|
||||||
|
|
||||||
|
# Propagate the install-ordering guard's outcome (#869 Point-1 C2): every
|
||||||
|
# other runtime asset above is best-effort/non-fatal, but a degraded
|
||||||
|
# (enforcement-not-wired) settings.json must make THIS script's own exit
|
||||||
|
# status non-zero so callers (framework/install.sh, finalize.ts) can surface
|
||||||
|
# it — never silently.
|
||||||
|
if [[ "$guard_degraded" == "1" ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for issue #869 Point-1 C2 — the install-ordering guard
|
||||||
|
# wired into mosaic-link-runtime-assets.
|
||||||
|
#
|
||||||
|
# Root cause under test: mosaic-link-runtime-assets copies
|
||||||
|
# runtime/claude/settings.json (which embeds the PreToolUse mutator-gate.py
|
||||||
|
# hook and the Stop receipt-observer-client.py hook) straight into
|
||||||
|
# ~/.claude/settings.json, unconditionally. If the lease-broker activation
|
||||||
|
# half cannot be confirmed on this host, wiring those hooks bricks it with a
|
||||||
|
# fail-closed gate that can never be satisfied.
|
||||||
|
#
|
||||||
|
# This harness never invokes a real `mosaic` CLI build — it stubs the
|
||||||
|
# `__link-claude-settings` contract with a fake `mosaic` on PATH so the shell
|
||||||
|
# WIRING (does mosaic-link-runtime-assets call out correctly? does it
|
||||||
|
# propagate a degraded outcome? does it still copy every other runtime file?
|
||||||
|
# does --allow-inactive-enforcement forward through?) is exercised
|
||||||
|
# independently of the TS guard's own logic (already covered by
|
||||||
|
# install-ordering-guard.spec.ts). It also exercises the no-mosaic-on-PATH
|
||||||
|
# python3 fallback directly.
|
||||||
|
#
|
||||||
|
# Scenarios:
|
||||||
|
# 1. probe=true (fake mosaic exits 0) -> settings.json copied, script exits 0.
|
||||||
|
# 2. probe=false (fake mosaic exits 1) -> script exits 1 (guard_degraded
|
||||||
|
# propagated), but every OTHER runtime file is still copied.
|
||||||
|
# 3. probe=false + --allow-inactive-enforcement -> the flag is forwarded to
|
||||||
|
# the fake mosaic stub.
|
||||||
|
# 4. No `mosaic` on PATH at all (activation unconfirmable) -> the python3
|
||||||
|
# fallback strips the enforcement hooks itself and the script exits 1.
|
||||||
|
# 5. No `mosaic` on PATH + --allow-inactive-enforcement -> the python3
|
||||||
|
# fallback wires the hooks AS-IS and the script exits 0.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
LINK_SCRIPT="$SCRIPT_DIR/mosaic-link-runtime-assets"
|
||||||
|
|
||||||
|
TMP_ROOT=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP_ROOT"' EXIT
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
fail_msg() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
fail=1
|
||||||
|
}
|
||||||
|
|
||||||
|
FIXTURE_SETTINGS='{
|
||||||
|
"model": "opus",
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{ "matcher": ".*", "hooks": [ { "type": "command", "command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude" } ] },
|
||||||
|
{ "matcher": "Write|Edit|MultiEdit", "hooks": [ { "type": "command", "command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh" } ] }
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{ "hooks": [
|
||||||
|
{ "type": "command", "command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude" },
|
||||||
|
{ "type": "command", "command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh" }
|
||||||
|
] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Sets up a fresh $MOSAIC_HOME/runtime/claude/{settings.json,CLAUDE.md,
|
||||||
|
# hooks-config.json,context7-integration.md} + fresh $HOME, echoes both paths
|
||||||
|
# space-separated for the caller to `read`.
|
||||||
|
new_scenario_dirs() {
|
||||||
|
local scenario="$1"
|
||||||
|
local base="$TMP_ROOT/$scenario"
|
||||||
|
local mosaic_home="$base/mosaic-home"
|
||||||
|
local home="$base/home"
|
||||||
|
mkdir -p "$mosaic_home/runtime/claude" "$home"
|
||||||
|
printf '%s' "$FIXTURE_SETTINGS" > "$mosaic_home/runtime/claude/settings.json"
|
||||||
|
echo "claude.md fixture" > "$mosaic_home/runtime/claude/CLAUDE.md"
|
||||||
|
echo '{"hooks":{}}' > "$mosaic_home/runtime/claude/hooks-config.json"
|
||||||
|
echo "context7 fixture" > "$mosaic_home/runtime/claude/context7-integration.md"
|
||||||
|
echo "$mosaic_home" "$home"
|
||||||
|
}
|
||||||
|
|
||||||
|
settings_has_marker() {
|
||||||
|
local file="$1" marker="$2"
|
||||||
|
[[ -f "$file" ]] && grep -q "$marker" "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A fake `mosaic` binary implementing only the __link-claude-settings contract
|
||||||
|
# this harness needs: writes dest verbatim (fixture is unmodified either way —
|
||||||
|
# this stub only exercises the CALL CONTRACT, not the TS strip logic, which
|
||||||
|
# has its own vitest coverage) and exits with the code the scenario wants.
|
||||||
|
# Records the args it was called with so the harness can assert forwarding.
|
||||||
|
make_fake_mosaic() {
|
||||||
|
local bin_dir="$1" exit_code="$2"
|
||||||
|
mkdir -p "$bin_dir"
|
||||||
|
cat > "$bin_dir/mosaic" <<EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
echo "\$@" > "$bin_dir/mosaic.args"
|
||||||
|
if [[ "\$1" == "__link-claude-settings" ]]; then
|
||||||
|
cp "\$2" "\$3"
|
||||||
|
exit $exit_code
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod +x "$bin_dir/mosaic"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Scenario 1: probe=true (fake mosaic exits 0) ---------------------------
|
||||||
|
read -r MOSAIC_HOME_1 HOME_1 < <(new_scenario_dirs scenario1)
|
||||||
|
BIN_1="$TMP_ROOT/scenario1/bin"
|
||||||
|
make_fake_mosaic "$BIN_1" 0
|
||||||
|
|
||||||
|
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_1" HOME="$HOME_1" PATH="$BIN_1:$PATH" "$LINK_SCRIPT" 2>&1)
|
||||||
|
STATUS=$?
|
||||||
|
[[ "$STATUS" -eq 0 ]] || fail_msg "scenario1 (probe=true): expected exit 0, got $STATUS. Output: $OUTPUT"
|
||||||
|
[[ -f "$HOME_1/.claude/settings.json" ]] || fail_msg "scenario1: settings.json was not copied"
|
||||||
|
|
||||||
|
# --- Scenario 2: probe=false (fake mosaic exits 1) --------------------------
|
||||||
|
read -r MOSAIC_HOME_2 HOME_2 < <(new_scenario_dirs scenario2)
|
||||||
|
BIN_2="$TMP_ROOT/scenario2/bin"
|
||||||
|
make_fake_mosaic "$BIN_2" 1
|
||||||
|
|
||||||
|
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_2" HOME="$HOME_2" PATH="$BIN_2:$PATH" "$LINK_SCRIPT" 2>&1)
|
||||||
|
STATUS=$?
|
||||||
|
[[ "$STATUS" -ne 0 ]] || fail_msg "scenario2 (probe=false, default): expected non-zero exit, got 0. Output: $OUTPUT"
|
||||||
|
[[ -f "$HOME_2/.claude/CLAUDE.md" ]] || fail_msg "scenario2: CLAUDE.md was NOT copied even though it is independent of the settings.json guard"
|
||||||
|
[[ -f "$HOME_2/.claude/hooks-config.json" ]] || fail_msg "scenario2: hooks-config.json was NOT copied"
|
||||||
|
[[ -f "$HOME_2/.claude/context7-integration.md" ]] || fail_msg "scenario2: context7-integration.md was NOT copied"
|
||||||
|
case "$OUTPUT" in
|
||||||
|
*"NOT be wired"*|*"NOT wired"*) ;;
|
||||||
|
*) fail_msg "scenario2: expected an actionable degraded-wiring message in output, got: $OUTPUT" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- Scenario 3: probe=false + --allow-inactive-enforcement forwards the flag
|
||||||
|
read -r MOSAIC_HOME_3 HOME_3 < <(new_scenario_dirs scenario3)
|
||||||
|
BIN_3="$TMP_ROOT/scenario3/bin"
|
||||||
|
make_fake_mosaic "$BIN_3" 0
|
||||||
|
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME_3" HOME="$HOME_3" PATH="$BIN_3:$PATH" "$LINK_SCRIPT" --allow-inactive-enforcement >/dev/null 2>&1
|
||||||
|
RECORDED_ARGS="$(cat "$BIN_3/mosaic.args" 2>/dev/null || true)"
|
||||||
|
case "$RECORDED_ARGS" in
|
||||||
|
*"--allow-inactive-enforcement"*) ;;
|
||||||
|
*) fail_msg "scenario3: --allow-inactive-enforcement was not forwarded to the mosaic CLI invocation (got: '$RECORDED_ARGS')" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- Scenario 4: no `mosaic` on PATH at all -> python3 fallback strips hooks
|
||||||
|
read -r MOSAIC_HOME_4 HOME_4 < <(new_scenario_dirs scenario4)
|
||||||
|
EMPTY_BIN="$TMP_ROOT/scenario4/empty-bin"
|
||||||
|
mkdir -p "$EMPTY_BIN"
|
||||||
|
# A PATH containing only python3 (for the fallback) + core utils, no mosaic.
|
||||||
|
FALLBACK_PATH="$EMPTY_BIN:/usr/bin:/bin"
|
||||||
|
|
||||||
|
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_4" HOME="$HOME_4" PATH="$FALLBACK_PATH" "$LINK_SCRIPT" 2>&1)
|
||||||
|
STATUS=$?
|
||||||
|
[[ "$STATUS" -ne 0 ]] || fail_msg "scenario4 (no mosaic on PATH, default): expected non-zero exit, got 0. Output: $OUTPUT"
|
||||||
|
if settings_has_marker "$HOME_4/.claude/settings.json" "mutator-gate.py"; then
|
||||||
|
fail_msg "scenario4: mutator-gate.py hook was wired even though mosaic could not be resolved (activation unconfirmable)"
|
||||||
|
fi
|
||||||
|
if settings_has_marker "$HOME_4/.claude/settings.json" "receipt-observer-client.py"; then
|
||||||
|
fail_msg "scenario4: receipt-observer-client.py hook was wired even though mosaic could not be resolved"
|
||||||
|
fi
|
||||||
|
if ! settings_has_marker "$HOME_4/.claude/settings.json" "prevent-memory-write.sh"; then
|
||||||
|
fail_msg "scenario4: the unrelated prevent-memory-write.sh hook was incorrectly dropped too"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Scenario 5: no `mosaic` on PATH + --allow-inactive-enforcement --------
|
||||||
|
read -r MOSAIC_HOME_5 HOME_5 < <(new_scenario_dirs scenario5)
|
||||||
|
|
||||||
|
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_5" HOME="$HOME_5" PATH="$FALLBACK_PATH" "$LINK_SCRIPT" --allow-inactive-enforcement 2>&1)
|
||||||
|
STATUS=$?
|
||||||
|
[[ "$STATUS" -eq 0 ]] || fail_msg "scenario5 (no mosaic, opt-out): expected exit 0, got $STATUS. Output: $OUTPUT"
|
||||||
|
if ! settings_has_marker "$HOME_5/.claude/settings.json" "mutator-gate.py"; then
|
||||||
|
fail_msg "scenario5: mutator-gate.py hook should have been wired (explicit opt-out set)"
|
||||||
|
fi
|
||||||
|
case "$OUTPUT" in
|
||||||
|
*"WARNING"*"--allow-inactive-enforcement"*) ;;
|
||||||
|
*) fail_msg "scenario5: expected a loud WARNING mentioning --allow-inactive-enforcement, got: $OUTPUT" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
echo "install-ordering-guard regression passed (5/5 scenarios)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$fail"
|
||||||
@@ -33,3 +33,64 @@ The Gitea API token is **never passed on a curl command line.** An `Authorizatio
|
|||||||
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
|
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
|
||||||
|
|
||||||
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
|
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
|
||||||
|
|
||||||
|
## Per-agent Gitea identity (Gate-16 author≠reviewer)
|
||||||
|
|
||||||
|
By default, git push/fetch (via `git-credential-mosaic`) and the API wrappers above (via
|
||||||
|
`detect-platform.sh`'s `get_gitea_token`) all authenticate as the single shared Gitea
|
||||||
|
account/token configured through `tools/_lib/credentials.sh`. That means every agent in a
|
||||||
|
fleet commits, pushes, and opens PRs under one identity — with no cryptographic
|
||||||
|
separation between an author and a reviewer.
|
||||||
|
|
||||||
|
Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent
|
||||||
|
identity** before falling back to the shared account:
|
||||||
|
|
||||||
|
1. `MOSAIC_GIT_IDENTITY` environment variable, or
|
||||||
|
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
|
||||||
|
non-persistent shells — `git config mosaic.gitIdentity <agent-id>`), or
|
||||||
|
3. (git-credential-mosaic only) the username git itself supplies for the credential
|
||||||
|
request.
|
||||||
|
|
||||||
|
If the resolved identity has a token file at
|
||||||
|
`~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-<agent-id>.token`, that
|
||||||
|
identity + token is used. **Nothing configured → nothing changes**: with no per-slot
|
||||||
|
token file present, both tools fall through to the existing shared-account path
|
||||||
|
unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot
|
||||||
|
tokens.
|
||||||
|
|
||||||
|
### Enabling it for a clone
|
||||||
|
|
||||||
|
The framework installer syncs `git-credential-mosaic` to
|
||||||
|
`~/.config/mosaic/tools/git/git-credential-mosaic` (executable) on every install/update,
|
||||||
|
but does **not** register it as git's credential helper automatically. Registration is a
|
||||||
|
one-time, explicit step:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Per-repo (recommended — scopes the helper to this clone only):
|
||||||
|
git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||||
|
|
||||||
|
# Per-worktree identity pin (Gate-16 separation):
|
||||||
|
git config mosaic.gitIdentity <agent-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
This is deliberately **not** auto-registered on install/update: `credential.helper` is
|
||||||
|
global, order-sensitive git config (`~/.gitconfig`) that can already hold an
|
||||||
|
operator-chosen credential manager (keychain, `store`, `manager-core`, …) for
|
||||||
|
repositories unrelated to Mosaic. Silently inserting an entry on every framework
|
||||||
|
install/upgrade risks reordering or shadowing that operator-owned surface across the
|
||||||
|
whole host — the same operator-owned config the installer's manifest system is
|
||||||
|
otherwise careful never to touch. Because identity is already resolved per-worktree
|
||||||
|
(`mosaic.gitIdentity`), the correct granularity for registering the helper is per-clone
|
||||||
|
too, so a documented manual step is the right shape here, not a global auto-write.
|
||||||
|
|
||||||
|
### PowerShell parity
|
||||||
|
|
||||||
|
`detect-platform.ps1`'s Gitea wrappers authenticate through `tea` CLI logins
|
||||||
|
(`Get-GiteaLoginForHost`), not a raw-token `get_gitea_token`-equivalent function — there
|
||||||
|
is nothing to prepend the identity-resolution block to on the PowerShell side. A native
|
||||||
|
PowerShell git-credential helper is also unnecessary: `git-credential-mosaic` is invoked
|
||||||
|
by git's credential-helper protocol (stdin/stdout), which works identically under Git for
|
||||||
|
Windows' bundled `bash`/`sh` when configured via `credential.helper`, without a `.ps1`
|
||||||
|
counterpart. A `tea`-login-based per-agent identity for the PowerShell wrappers is a
|
||||||
|
separate, larger design (mapping identities to `tea login` profiles) and is out of scope
|
||||||
|
here.
|
||||||
|
|||||||
@@ -505,6 +505,28 @@ get_gitea_token() {
|
|||||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
local cred_loader="$script_dir/../_lib/credentials.sh"
|
local cred_loader="$script_dir/../_lib/credentials.sh"
|
||||||
|
|
||||||
|
# 0. Per-agent identity (Gate-16 author≠reviewer). If MOSAIC_GIT_IDENTITY, or the
|
||||||
|
# per-worktree `git config mosaic.gitIdentity`, resolves to an agent that has a
|
||||||
|
# stored per-slot token for this host, act AS that agent so API tooling
|
||||||
|
# (pr-create, issue-create, …) authors under the right identity — matching the
|
||||||
|
# git credential helper. Backward-compatible: nothing resolvable → shared logic below.
|
||||||
|
local _ident="${MOSAIC_GIT_IDENTITY:-}"
|
||||||
|
[[ -z "$_ident" ]] && _ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
||||||
|
if [[ -n "$_ident" ]]; then
|
||||||
|
local _idpfx=""
|
||||||
|
case "$host" in
|
||||||
|
git.uscllc.com) _idpfx=gitea-usc ;;
|
||||||
|
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
|
||||||
|
esac
|
||||||
|
if [[ -n "$_idpfx" ]]; then
|
||||||
|
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
||||||
|
if [[ -r "$_idtok" ]]; then
|
||||||
|
cat "$_idtok"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
|
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
|
||||||
if [[ -f "$cred_loader" ]]; then
|
if [[ -f "$cred_loader" ]]; then
|
||||||
local token
|
local token
|
||||||
|
|||||||
69
packages/mosaic/framework/tools/git/git-credential-mosaic
Executable file
69
packages/mosaic/framework/tools/git/git-credential-mosaic
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# git-credential-mosaic — git credential helper — resolves Gitea tokens from
|
||||||
|
# the Mosaic credential store at runtime so remote URLs never embed secrets.
|
||||||
|
#
|
||||||
|
# Install (one-time, per clone or globally):
|
||||||
|
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||||
|
# # or, fleet-wide: git config --global credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||||
|
#
|
||||||
|
# Per-agent Gate-16 identity (author != reviewer separation):
|
||||||
|
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
|
||||||
|
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
|
||||||
|
#
|
||||||
|
# Resolution priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity
|
||||||
|
# (per-worktree, survives across non-persistent shells) > git-supplied username
|
||||||
|
# (credential.username / URL). When the resolved identity has a matching
|
||||||
|
# per-agent token file, use it instead of the shared account. Backward
|
||||||
|
# compatible: nothing resolvable -> shared token (unchanged behavior).
|
||||||
|
[ "$1" = "get" ] || exit 0
|
||||||
|
host=""; username_in=""
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -z "$line" ] && break
|
||||||
|
case "$line" in
|
||||||
|
host=*) host=${line#host=};;
|
||||||
|
username=*) username_in=${line#username=};;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
# Per-agent identity resolution (Gate-16 author≠reviewer separation).
|
||||||
|
# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree,
|
||||||
|
# survives across non-persistent shells) > git-supplied username (credential.username
|
||||||
|
# / URL). When the resolved identity has a matching per-agent token, use it instead of
|
||||||
|
# the shared account. Backward-compatible: nothing resolvable → shared token.
|
||||||
|
ident="$MOSAIC_GIT_IDENTITY"
|
||||||
|
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||||
|
[ -z "$ident" ] && ident="$username_in"
|
||||||
|
if [ -n "$ident" ]; then
|
||||||
|
case "$host" in
|
||||||
|
git.uscllc.com) idpfx=gitea-usc;;
|
||||||
|
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
||||||
|
*) idpfx="";;
|
||||||
|
esac
|
||||||
|
if [ -n "$idpfx" ]; then
|
||||||
|
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
|
||||||
|
if [ -r "$idtok" ]; then
|
||||||
|
echo "username=${ident}"
|
||||||
|
echo "password=$(cat "$idtok")"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
case "$host" in
|
||||||
|
git.uscllc.com) svc=gitea-usc;;
|
||||||
|
git.mosaicstack.dev) svc=gitea-mosaicstack;;
|
||||||
|
*) exit 0;;
|
||||||
|
esac
|
||||||
|
# Script-relative (not $HOME-absolute) so this resolves correctly regardless
|
||||||
|
# of where the framework installer places tools/ under $HOME — mirrors
|
||||||
|
# detect-platform.sh's own cred_loader resolution in this same directory.
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=../_lib/credentials.sh
|
||||||
|
source "$script_dir/../_lib/credentials.sh"
|
||||||
|
load_credentials "$svc" >/dev/null 2>&1 || exit 0
|
||||||
|
# GITEA_USER is not populated by load_credentials (it only exports
|
||||||
|
# GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's
|
||||||
|
# git-over-HTTP auth authenticates from the token itself (the password field),
|
||||||
|
# not from the username string, so any non-empty placeholder works here — this
|
||||||
|
# is deliberately NOT a real account name (framework files must stay
|
||||||
|
# operator-agnostic; see tools/quality/scripts/verify-sanitized.sh).
|
||||||
|
echo "username=${GITEA_USER:-git}"
|
||||||
|
echo "password=$GITEA_TOKEN"
|
||||||
@@ -203,7 +203,15 @@ try:
|
|||||||
if not url:
|
if not url:
|
||||||
return False
|
return False
|
||||||
origin, path = _origin_and_path(url)
|
origin, path = _origin_and_path(url)
|
||||||
return origin == base_origin and path == expected_path
|
# Repo owner/repo slugs are case-insensitive (Gitea canonicalizes the
|
||||||
|
# pull_request_url slug to lowercase on return), while EXPECTED_REPO_SLUG
|
||||||
|
# is taken verbatim from GITEA_API_BASE and may be mixed-case. The
|
||||||
|
# remainder of the path (".../pulls/<number>") is numeric, so lowercasing
|
||||||
|
# the whole path for this comparison only relaxes case, not identity: the
|
||||||
|
# origin tuple (scheme+host+port) above still pins the provider host, and
|
||||||
|
# the path is still compared in FULL (no endswith/suffix match), so the
|
||||||
|
# look-alike-host and same-host decoy-prefix protections are unchanged.
|
||||||
|
return origin == base_origin and path.lower() == expected_path.lower()
|
||||||
|
|
||||||
if comment.get("id") != expected_id:
|
if comment.get("id") != expected_id:
|
||||||
raise ValueError("read-back id does not match the created id")
|
raise ValueError("read-back id does not match the created id")
|
||||||
|
|||||||
161
packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh
Executable file
161
packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh
Executable file
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for `git-credential-mosaic` — per-agent Gitea identity
|
||||||
|
# resolution (Gate-16 author≠reviewer separation).
|
||||||
|
#
|
||||||
|
# Covers:
|
||||||
|
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
|
||||||
|
# mosaic.gitIdentity (per-worktree) > git-supplied username.
|
||||||
|
# 2. Correct per-slot token file path chosen per host
|
||||||
|
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
|
||||||
|
# 3. Per-slot token present -> emits that identity + token.
|
||||||
|
# 4. Per-slot token absent -> falls back to the shared account
|
||||||
|
# (backward-compat / no-op for hosts without per-slot tokens).
|
||||||
|
# 5. Unknown/unrelated host -> exits 0 with no output (passthrough).
|
||||||
|
#
|
||||||
|
# Uses stubbed token files under a fake HOME + a real (throwaway) git repo.
|
||||||
|
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
|
||||||
|
FAKE_HOME="$WORK_DIR/home"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
# Mirror the real deployed layout (~/.config/mosaic/tools/{git,_lib}/) under the
|
||||||
|
# fake HOME: git-credential-mosaic resolves its credentials.sh sibling via a
|
||||||
|
# script-relative path (BASH_SOURCE), so the copy must live next to a stubbed
|
||||||
|
# _lib/credentials.sh, not the real one, to keep this test hermetic.
|
||||||
|
HELPER="$FAKE_HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \
|
||||||
|
"$FAKE_HOME/.config/mosaic/tools/git" \
|
||||||
|
"$FAKE_HOME/.config/mosaic/tools/_lib" \
|
||||||
|
"$REPO_DIR"
|
||||||
|
|
||||||
|
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
||||||
|
chmod +x "$HELPER"
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" config user.email "test@example.invalid"
|
||||||
|
git -C "$REPO_DIR" config user.name "Test"
|
||||||
|
|
||||||
|
# Fake shared-account credential loader — stands in for
|
||||||
|
# tools/_lib/credentials.sh's load_credentials(), scoped to this test only.
|
||||||
|
cat > "$FAKE_HOME/.config/mosaic/tools/_lib/credentials.sh" <<'SH'
|
||||||
|
load_credentials() {
|
||||||
|
case "$1" in
|
||||||
|
gitea-mosaicstack) GITEA_URL="https://git.mosaicstack.dev"; GITEA_TOKEN="shared-mosaicstack-token"; export GITEA_URL GITEA_TOKEN; return 0 ;;
|
||||||
|
gitea-usc) GITEA_URL="https://git.uscllc.com"; GITEA_TOKEN="shared-usc-token"; export GITEA_URL GITEA_TOKEN; return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
SH
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
assert_eq() {
|
||||||
|
local desc="$1" expected="$2" actual="$3"
|
||||||
|
if [[ "$expected" != "$actual" ]]; then
|
||||||
|
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Feed "host=<h>\nusername=<u>\n\n" on stdin (mirrors git's credential protocol)
|
||||||
|
# and run the helper with the fake HOME, inside REPO_DIR (so `git config
|
||||||
|
# mosaic.gitIdentity` resolves per-worktree), plus any extra env passed in $@.
|
||||||
|
run_helper() {
|
||||||
|
local host="$1" username_in="$2"; shift 2
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
env -i HOME="$FAKE_HOME" PATH="$PATH" "$@" bash "$HELPER" get <<EOF
|
||||||
|
host=$host
|
||||||
|
username=$username_in
|
||||||
|
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. No identity resolvable anywhere, no per-slot token -> shared fallback
|
||||||
|
# (backward-compat: unchanged behavior when nothing is configured).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||||
|
out=$(run_helper "git.mosaicstack.dev" "")
|
||||||
|
assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||||
|
assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. git-supplied username resolves to an identity WITH a per-slot token ->
|
||||||
|
# that identity + token wins over the shared account.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
|
||||||
|
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
||||||
|
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
|
||||||
|
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token"
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity agentB
|
||||||
|
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
||||||
|
assert_eq "git-config beats username: username" "username=agentB" "$(echo "$out" | grep '^username=')"
|
||||||
|
assert_eq "git-config beats username: password" "password=agentB-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo -n "agentC-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentC.token"
|
||||||
|
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_GIT_IDENTITY=agentC)
|
||||||
|
assert_eq "env beats git-config: username" "username=agentC" "$(echo "$out" | grep '^username=')"
|
||||||
|
assert_eq "env beats git-config: password" "password=agentC-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Identity resolves, but no matching per-slot token file -> falls back to
|
||||||
|
# the shared account (per-agent identity is opt-in, not a hard requirement).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
out=$(run_helper "git.mosaicstack.dev" "no-such-agent")
|
||||||
|
assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||||
|
assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. Correct per-slot token PATH is chosen per host: same agent id, different
|
||||||
|
# host prefix (gitea-usc- vs gitea-mosaicstack-).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token"
|
||||||
|
out=$(run_helper "git.uscllc.com" "agentD")
|
||||||
|
assert_eq "host-scoped token path (usc): username" "username=agentD" "$(echo "$out" | grep '^username=')"
|
||||||
|
assert_eq "host-scoped token path (usc): password" "password=agentD-usc-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
# agentD has NO mosaicstack token -> must fall back to shared mosaicstack, not
|
||||||
|
# leak the usc token across hosts.
|
||||||
|
out=$(run_helper "git.mosaicstack.dev" "agentD")
|
||||||
|
assert_eq "host-scoped token path (cross-host must not leak): username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||||
|
assert_eq "host-scoped token path (cross-host must not leak): password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
|
||||||
|
# remotes, e.g. github.com via a different credential helper).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
out=$(run_helper "github.com" "agentA")
|
||||||
|
assert_eq "unknown host: no output" "" "$out"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
||||||
|
# protocol: this helper only implements get).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
|
||||||
|
host=git.mosaicstack.dev
|
||||||
|
username=agentA
|
||||||
|
password=whatever
|
||||||
|
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
assert_eq "store verb: no output" "" "$store_out"
|
||||||
|
|
||||||
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
echo "git-credential-mosaic identity resolution regression passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$fail"
|
||||||
122
packages/mosaic/framework/tools/git/test-gitea-token-identity.sh
Executable file
122
packages/mosaic/framework/tools/git/test-gitea-token-identity.sh
Executable file
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for detect-platform.sh's get_gitea_token() per-agent
|
||||||
|
# identity resolution (Gate-16 author≠reviewer separation) — the API-tooling
|
||||||
|
# counterpart to git-credential-mosaic, so pr-create.sh/issue-create.sh/etc.
|
||||||
|
# open records under the resolved agent identity, not the shared account.
|
||||||
|
#
|
||||||
|
# Covers:
|
||||||
|
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
|
||||||
|
# mosaic.gitIdentity (per-worktree).
|
||||||
|
# 2. Correct per-slot token file path chosen per host
|
||||||
|
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
|
||||||
|
# 3. Per-slot token present -> that token is returned (agent-authored calls).
|
||||||
|
# 4. Per-slot token absent -> falls back to the shared credential-loader
|
||||||
|
# token (backward-compat / no-op for hosts without per-slot tokens).
|
||||||
|
# 5. Unrelated host with no shared credentials configured -> failure
|
||||||
|
# (unchanged, existing behavior).
|
||||||
|
#
|
||||||
|
# Uses a stubbed credentials.json + stubbed per-slot token files under a fake
|
||||||
|
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}"
|
||||||
|
FAKE_HOME="$WORK_DIR/home"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$REPO_DIR"
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
|
||||||
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
|
{
|
||||||
|
"gitea": {
|
||||||
|
"mosaicstack": {
|
||||||
|
"url": "https://git.mosaicstack.dev",
|
||||||
|
"token": "shared-mosaicstack-token"
|
||||||
|
},
|
||||||
|
"usc": {
|
||||||
|
"url": "https://git.uscllc.com",
|
||||||
|
"token": "shared-usc-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
assert_eq() {
|
||||||
|
local desc="$1" expected="$2" actual="$3"
|
||||||
|
if [[ "$expected" != "$actual" ]]; then
|
||||||
|
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Runs get_gitea_token for $1=host inside REPO_DIR (per-worktree git config
|
||||||
|
# resolves there) with a fake HOME + the stub credentials.json, plus any
|
||||||
|
# extra env passed in $@.
|
||||||
|
call_get_gitea_token() {
|
||||||
|
local host="$1"; shift
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
# shellcheck disable=SC2016 # deliberately deferred: $DETECT_PLATFORM_SH is
|
||||||
|
# expanded by the INNER bash -c (via the exported env var below), not here.
|
||||||
|
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
|
DETECT_PLATFORM_SH="$SCRIPT_DIR/detect-platform.sh" "$@" \
|
||||||
|
bash -c 'source "$DETECT_PLATFORM_SH"; get_gitea_token "$1"' _ "$host"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. No identity resolvable -> shared credential-loader token (unchanged).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||||
|
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||||
|
assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot
|
||||||
|
# token -> that token wins over the shared account.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity agentA
|
||||||
|
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||||
|
assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token"
|
||||||
|
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentB)
|
||||||
|
assert_eq "env beats git-config identity token" "agentB-mosaicstack-token" "$out"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Identity resolves but has no per-slot token for THIS host -> falls back
|
||||||
|
# to the shared token (per-agent identity is opt-in per host).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity no-such-agent
|
||||||
|
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||||
|
assert_eq "no per-slot token falls back to shared" "shared-mosaicstack-token" "$out"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Correct per-slot token PATH per host: same agent id, only a usc token
|
||||||
|
# exists -> usc host returns it, mosaicstack host must NOT leak it and
|
||||||
|
# instead falls back to the shared mosaicstack token.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token"
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity agentD
|
||||||
|
out=$(call_get_gitea_token "git.uscllc.com")
|
||||||
|
assert_eq "host-scoped token path (usc)" "agentD-usc-token" "$out"
|
||||||
|
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||||
|
assert_eq "host-scoped token path (no cross-host leak)" "shared-mosaicstack-token" "$out"
|
||||||
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||||
|
|
||||||
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
echo "get_gitea_token identity resolution regression passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$fail"
|
||||||
@@ -406,6 +406,13 @@ elif mode == "comment-url-wrong-repo":
|
|||||||
elif mode == "comment-url-suffix-injection":
|
elif mode == "comment-url-suffix-injection":
|
||||||
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
|
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
|
||||||
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
|
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
|
||||||
|
elif mode == "comment-mixed-case-slug":
|
||||||
|
# #875: EXPECTED_REPO_SLUG is taken verbatim from GITEA_API_BASE and can be
|
||||||
|
# mixed-case (e.g. "USC/uconnect"), but Gitea canonicalizes the returned
|
||||||
|
# pull_request_url's owner/repo segment to LOWERCASE. Model that here by
|
||||||
|
# lowercasing only the slug path, independent of the (possibly mixed-case)
|
||||||
|
# web_base the wrapper was configured with.
|
||||||
|
pr_url = f"{_origin}{_slug.lower()}/pulls/123"
|
||||||
record = {
|
record = {
|
||||||
"id": 456,
|
"id": 456,
|
||||||
"body": body,
|
"body": body,
|
||||||
@@ -868,6 +875,19 @@ for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong
|
|||||||
assert_no_temp_leak "$bad_mode"
|
assert_no_temp_leak "$bad_mode"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Case 15b (#875): a MIXED-CASE repo slug (as embedded verbatim in
|
||||||
|
# GITEA_API_BASE, e.g. "USC/uconnect") must still verify when Gitea returns the
|
||||||
|
# comment's pull_request_url with its owner/repo segment canonicalized to
|
||||||
|
# LOWERCASE ("usc/uconnect"). This is a legitimate, unforged provider response —
|
||||||
|
# not a spoof — so `_belongs` must accept it (case-insensitive slug compare)
|
||||||
|
# while still requiring the origin (scheme+host+port) and the rest of the path
|
||||||
|
# to match in full. Pre-#875-fix this fails closed on a real success
|
||||||
|
# (false-negative); post-fix it verifies.
|
||||||
|
run_review comment-mixed-case-slug comment durable-body https://git.mosaicstack.dev \
|
||||||
|
https://git.mosaicstack.dev/USC/uconnect.git USC/uconnect
|
||||||
|
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||||
|
assert_no_temp_leak "comment-mixed-case-slug"
|
||||||
|
|
||||||
# Case 16 (#865 ITEM 1, current-head TOCTOU): the PR head advances between the
|
# Case 16 (#865 ITEM 1, current-head TOCTOU): the PR head advances between the
|
||||||
# pre-submit head read (which pins the review) and the post-verify re-read. The
|
# pre-submit head read (which pins the review) and the post-verify re-read. The
|
||||||
# review is genuinely created and verified as pinned to the OLD head, but the
|
# review is genuinely created and verified as pinned to the OLD head, but the
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Enforcement-side version-coupling gate (issue #869, Point-1 card C4).
|
||||||
|
|
||||||
|
Root cause this exists to guard against (#828 version skew, restated from
|
||||||
|
the C1 activation probe in ``lease-activation-probe.ts``): the lease
|
||||||
|
broker's ENFORCEMENT half (this toolkit — ``launch-runtime.py``,
|
||||||
|
``mutator-gate.py``, ``revoke-lease.py``) and its ACTIVATION half
|
||||||
|
(``execLeaseGatedRuntime()`` in ``launch.ts``, which chains the gated
|
||||||
|
runtime through ``launch-runtime.py`` and injects ``MOSAIC_LEASE_*``) ship
|
||||||
|
on different channels — an npm package and a framework/CLI reseed. C1 gave
|
||||||
|
the activation half a versioned, machine-checkable identity
|
||||||
|
(``LEASE_ACTIVATION_CAPABILITY``, printed by the CLI's hidden
|
||||||
|
``mosaic __lease-capability`` subcommand). That identity is inert on its
|
||||||
|
own: nothing yet asserted that ENFORCEMENT actually requires the version
|
||||||
|
ACTIVATION advertises. This module is that assertion, owned by the
|
||||||
|
enforcement side.
|
||||||
|
|
||||||
|
``EXPECTED_ACTIVATION_CAPABILITY`` below is this toolkit's own contract
|
||||||
|
declaration — bump it only when this toolkit's launch/gate seam starts
|
||||||
|
requiring a different activation contract (new env vars it depends on,
|
||||||
|
changed chaining behavior, etc.), independent of any package semver, for
|
||||||
|
the same reason C1's constant is: #828 happened precisely because a
|
||||||
|
version number that should have moved did not.
|
||||||
|
|
||||||
|
This module never talks to a real broker or a real installed CLI in its
|
||||||
|
own tests — both the probe's command resolution and its ``run`` transport
|
||||||
|
are injectable so tests can drive every branch with fakes/stubs (see
|
||||||
|
``version_coupling_unittest.py``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from typing import Final, TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class ActivationCapability(TypedDict):
|
||||||
|
name: str
|
||||||
|
version: int
|
||||||
|
|
||||||
|
|
||||||
|
# ENFORCEMENT-side expected activation contract. OWNED by this toolkit (the
|
||||||
|
# enforcement half). Mirrors — but is deliberately a SEPARATE constant from
|
||||||
|
# — `LEASE_ACTIVATION_CAPABILITY` in
|
||||||
|
# `packages/mosaic/src/commands/lease-activation-probe.ts` (the activation
|
||||||
|
# half's own declaration of what it implements). The two are compared at
|
||||||
|
# runtime by `assert_activation_capability_matches()`; drift between them is
|
||||||
|
# exactly the version-skew failure mode #828/#869 exist to catch, and must
|
||||||
|
# FAIL LOUD, never a silent pass and never a dead (always-true) gate.
|
||||||
|
EXPECTED_ACTIVATION_CAPABILITY: Final[ActivationCapability] = {
|
||||||
|
"name": "lease-runtime-activation",
|
||||||
|
"version": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Matches `LEASE_CAPABILITY_PROBE_COMMAND` in lease-activation-probe.ts —
|
||||||
|
# the hidden CLI subcommand that prints the activation half's advertised
|
||||||
|
# capability as compact JSON.
|
||||||
|
LEASE_CAPABILITY_PROBE_COMMAND: Final = "__lease-capability"
|
||||||
|
|
||||||
|
PROBE_TIMEOUT_SECONDS: Final = 2.0
|
||||||
|
|
||||||
|
# Override hook: a full shell-style command line (parsed with `shlex.split`)
|
||||||
|
# to run INSTEAD of resolving `mosaic` on PATH and appending the probe
|
||||||
|
# subcommand. Real deployments should never need this — `mosaic` is on PATH
|
||||||
|
# whenever a runtime was launched via `mosaic <cmd>` in the first place, the
|
||||||
|
# only real caller of this seam. It exists for integration tests that spawn
|
||||||
|
# `launch-runtime.py` directly (never through the real CLI) to supply a
|
||||||
|
# fake/stub CLI probe, matching the existing convention of those tests
|
||||||
|
# supplying a fake broker and a fake runtime binary rather than depending on
|
||||||
|
# host state.
|
||||||
|
MOSAIC_COMMAND_OVERRIDE_VAR: Final = "MOSAIC_LEASE_VERSION_PROBE_COMMAND"
|
||||||
|
|
||||||
|
|
||||||
|
class VersionCouplingError(Exception):
|
||||||
|
"""Raised when the activation capability is absent, unreadable, or does
|
||||||
|
not match what enforcement expects. Callers MUST fail loud on this
|
||||||
|
(non-zero exit, clear actionable stderr) — never swallow it into a
|
||||||
|
silent pass, and never let its absence be treated as compatible."""
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_probe_command(environ: Mapping[str, str]) -> list[str] | None:
|
||||||
|
override = environ.get(MOSAIC_COMMAND_OVERRIDE_VAR)
|
||||||
|
if override:
|
||||||
|
parsed = shlex.split(override)
|
||||||
|
return parsed or None
|
||||||
|
resolved = shutil.which("mosaic")
|
||||||
|
if resolved is None:
|
||||||
|
return None
|
||||||
|
return [resolved, LEASE_CAPABILITY_PROBE_COMMAND]
|
||||||
|
|
||||||
|
|
||||||
|
def default_probe_activation_capability(
|
||||||
|
environ: Mapping[str, str] | None = None,
|
||||||
|
*,
|
||||||
|
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||||
|
) -> ActivationCapability | None:
|
||||||
|
"""Real capability lookup: resolves and executes the CLI's hidden
|
||||||
|
``__lease-capability`` probe subcommand out-of-process (the same
|
||||||
|
mechanism `defaultCapabilityProbe()` in lease-activation-probe.ts uses
|
||||||
|
from the activation side) and parses its JSON stdout. Any failure to
|
||||||
|
resolve a command, spawn it, have it exit zero, or produce a well-shaped
|
||||||
|
``{name, version}`` JSON object is treated as NO capability (``None``)
|
||||||
|
— fail-closed, never a fabricated/guessed capability.
|
||||||
|
"""
|
||||||
|
source_environment = os.environ if environ is None else environ
|
||||||
|
command = _resolve_probe_command(source_environment)
|
||||||
|
if command is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
completed = run(
|
||||||
|
command,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=PROBE_TIMEOUT_SECONDS,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired, ValueError):
|
||||||
|
return None
|
||||||
|
if completed.returncode != 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(completed.stdout)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
if (
|
||||||
|
not isinstance(parsed, dict)
|
||||||
|
or not isinstance(parsed.get("name"), str)
|
||||||
|
or not isinstance(parsed.get("version"), int)
|
||||||
|
or isinstance(parsed.get("version"), bool)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return {"name": parsed["name"], "version": parsed["version"]}
|
||||||
|
|
||||||
|
|
||||||
|
def format_mismatch_message(
|
||||||
|
activation: ActivationCapability | None,
|
||||||
|
expected: ActivationCapability,
|
||||||
|
) -> str:
|
||||||
|
"""Actionable, non-silent remediation message for either failure shape:
|
||||||
|
absent/unreadable capability, or a present-but-incompatible one."""
|
||||||
|
if activation is None:
|
||||||
|
return (
|
||||||
|
"Mosaic lease activation capability unreadable: enforcement "
|
||||||
|
f"expects '{expected['name']}' v{expected['version']} but the "
|
||||||
|
f"CLI's `mosaic {LEASE_CAPABILITY_PROBE_COMMAND}` probe produced "
|
||||||
|
"no usable result (mosaic not on PATH, non-zero exit, or "
|
||||||
|
"malformed output) — framework/CLI version skew; upgrade both "
|
||||||
|
"as one unit; see #869."
|
||||||
|
)
|
||||||
|
if activation["name"] != expected["name"]:
|
||||||
|
return (
|
||||||
|
f"activation capability name '{activation['name']}' != "
|
||||||
|
f"enforcement expects '{expected['name']}' — framework/CLI "
|
||||||
|
"version skew; upgrade both as one unit; see #869"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"activation capability v{activation['version']} != enforcement "
|
||||||
|
f"expects v{expected['version']} — framework/CLI version skew; "
|
||||||
|
"upgrade both as one unit; see #869"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_activation_capability_matches(
|
||||||
|
activation: ActivationCapability | None,
|
||||||
|
expected: ActivationCapability = EXPECTED_ACTIVATION_CAPABILITY,
|
||||||
|
) -> None:
|
||||||
|
"""Raise `VersionCouplingError` unless `activation` is present AND its
|
||||||
|
`name`/`version` exactly match `expected`. Absence is treated the same
|
||||||
|
as a mismatch — never a silent pass."""
|
||||||
|
if (
|
||||||
|
activation is None
|
||||||
|
or activation.get("name") != expected["name"]
|
||||||
|
or activation.get("version") != expected["version"]
|
||||||
|
):
|
||||||
|
raise VersionCouplingError(format_mismatch_message(activation, expected))
|
||||||
@@ -12,11 +12,24 @@ from collections.abc import Callable, Mapping, Sequence
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
|
from activation_version_gate import (
|
||||||
|
EXPECTED_ACTIVATION_CAPABILITY,
|
||||||
|
ActivationCapability,
|
||||||
|
VersionCouplingError,
|
||||||
|
assert_activation_capability_matches,
|
||||||
|
default_probe_activation_capability,
|
||||||
|
)
|
||||||
from lease_generation import initialize_runtime_generation
|
from lease_generation import initialize_runtime_generation
|
||||||
|
|
||||||
MAX_FRAME: Final = 64 * 1024
|
MAX_FRAME: Final = 64 * 1024
|
||||||
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
||||||
CLAUDE_DANGEROUS_FLAG: Final = "--dangerously-skip-permissions"
|
CLAUDE_DANGEROUS_FLAG: Final = "--dangerously-skip-permissions"
|
||||||
|
# Distinct, non-overlapping exit code for the C4 version-coupling gate (see
|
||||||
|
# `activation_version_gate.py`) — deliberately different from the `1`
|
||||||
|
# (broker registration failed closed) and `64` (usage error) codes already
|
||||||
|
# owned by this script, so a version-skew denial is unambiguous in caller
|
||||||
|
# logs/tests and is never confused with a broker-availability failure.
|
||||||
|
EXIT_VERSION_SKEW: Final = 65
|
||||||
|
|
||||||
|
|
||||||
def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
|
def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
|
||||||
@@ -47,6 +60,10 @@ def main(
|
|||||||
request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request,
|
request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request,
|
||||||
execute: Callable[[str, list[str], dict[str, str]], object] = os.execvpe,
|
execute: Callable[[str, list[str], dict[str, str]], object] = os.execvpe,
|
||||||
initialize_generation: Callable[[Path, int], None] = initialize_runtime_generation,
|
initialize_generation: Callable[[Path, int], None] = initialize_runtime_generation,
|
||||||
|
probe_activation_capability: Callable[
|
||||||
|
[Mapping[str, str]], ActivationCapability | None
|
||||||
|
] = default_probe_activation_capability,
|
||||||
|
expected_activation_capability: ActivationCapability = EXPECTED_ACTIVATION_CAPABILITY,
|
||||||
) -> int:
|
) -> int:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
|
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
|
||||||
@@ -66,6 +83,25 @@ def main(
|
|||||||
command = [command[0], CLAUDE_DANGEROUS_FLAG, *command[1:]]
|
command = [command[0], CLAUDE_DANGEROUS_FLAG, *command[1:]]
|
||||||
|
|
||||||
source_environment = os.environ if environ is None else environ
|
source_environment = os.environ if environ is None else environ
|
||||||
|
|
||||||
|
# C4 version-coupling gate (#869 Point-1): before this ENFORCEMENT half
|
||||||
|
# chains into anything, assert that the ACTIVATION contract it is about
|
||||||
|
# to rely on (MOSAIC_LEASE_* injection, broker chaining) matches what
|
||||||
|
# this enforcement build expects. This is a build/deploy-defect check,
|
||||||
|
# not a broker-availability question, so it runs before — and
|
||||||
|
# independently of — broker registration below, and it FAILS LOUD: a
|
||||||
|
# clear stderr message plus a dedicated non-zero exit code, never a
|
||||||
|
# silent pass and never folded into the generic registration-failure
|
||||||
|
# branch.
|
||||||
|
try:
|
||||||
|
assert_activation_capability_matches(
|
||||||
|
probe_activation_capability(source_environment),
|
||||||
|
expected_activation_capability,
|
||||||
|
)
|
||||||
|
except VersionCouplingError as version_error:
|
||||||
|
print(str(version_error), file=sys.stderr)
|
||||||
|
return EXIT_VERSION_SKEW
|
||||||
|
|
||||||
try:
|
try:
|
||||||
socket_path = Path(source_environment["MOSAIC_LEASE_BROKER_SOCKET"])
|
socket_path = Path(source_environment["MOSAIC_LEASE_BROKER_SOCKET"])
|
||||||
generation = int(source_environment.get("MOSAIC_RUNTIME_GENERATION", "1"))
|
generation = int(source_environment.get("MOSAIC_RUNTIME_GENERATION", "1"))
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||||
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh"
|
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { registerSkillCommand } from './commands/skill.js';
|
|||||||
// prdy is registered via launch.ts
|
// prdy is registered via launch.ts
|
||||||
import { registerLaunchCommands } from './commands/launch.js';
|
import { registerLaunchCommands } from './commands/launch.js';
|
||||||
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
|
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
|
||||||
|
import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js';
|
||||||
import { registerAuthCommand } from './commands/auth.js';
|
import { registerAuthCommand } from './commands/auth.js';
|
||||||
import { registerFederationCommand } from './commands/federation.js';
|
import { registerFederationCommand } from './commands/federation.js';
|
||||||
import { registerGatewayCommand } from './commands/gateway.js';
|
import { registerGatewayCommand } from './commands/gateway.js';
|
||||||
@@ -31,10 +32,7 @@ import {
|
|||||||
formatAllPackagesTable,
|
formatAllPackagesTable,
|
||||||
getInstallAllCommand,
|
getInstallAllCommand,
|
||||||
repairFleetCommsTools,
|
repairFleetCommsTools,
|
||||||
runFrameworkReseed,
|
runUpdateReseedFlow,
|
||||||
refreshActiveFleetUnits,
|
|
||||||
readRosterAgentNames,
|
|
||||||
buildRelaunchCommands,
|
|
||||||
checkFrameworkDrift,
|
checkFrameworkDrift,
|
||||||
FRAMEWORK_RESEED_PACKAGE,
|
FRAMEWORK_RESEED_PACKAGE,
|
||||||
} from './runtime/update-checker.js';
|
} from './runtime/update-checker.js';
|
||||||
@@ -83,6 +81,10 @@ registerLaunchCommands(program);
|
|||||||
|
|
||||||
registerLeaseCapabilityProbe(program);
|
registerLeaseCapabilityProbe(program);
|
||||||
|
|
||||||
|
// ─── install-ordering guard (hidden; #869 Point-1 C2) ───────────────────
|
||||||
|
|
||||||
|
registerInstallOrderingGuardCommand(program);
|
||||||
|
|
||||||
// ─── login ──────────────────────────────────────────────────────────────
|
// ─── login ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -440,12 +442,18 @@ program
|
|||||||
'--repair-tools',
|
'--repair-tools',
|
||||||
'Restore the supported current-version TOOLS contract and executable fleet helper',
|
'Restore the supported current-version TOOLS contract and executable fleet helper',
|
||||||
)
|
)
|
||||||
|
.option(
|
||||||
|
'--allow-inactive-enforcement',
|
||||||
|
'Wire lease-enforcement hooks into settings.json even when activation cannot be confirmed ' +
|
||||||
|
'(explicit, loud, non-default opt-out for the post-reseed install-ordering guard — see #869/#882)',
|
||||||
|
)
|
||||||
.action(
|
.action(
|
||||||
async (opts: {
|
async (opts: {
|
||||||
check?: boolean;
|
check?: boolean;
|
||||||
reseed?: boolean;
|
reseed?: boolean;
|
||||||
relaunch?: boolean;
|
relaunch?: boolean;
|
||||||
repairTools?: boolean;
|
repairTools?: boolean;
|
||||||
|
allowInactiveEnforcement?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
if (opts.repairTools) {
|
if (opts.repairTools) {
|
||||||
const repair = repairFleetCommsTools();
|
const repair = repairFleetCommsTools();
|
||||||
@@ -466,57 +474,24 @@ program
|
|||||||
// checkForAllUpdates imported statically above
|
// checkForAllUpdates imported statically above
|
||||||
const { execSync } = await import('node:child_process');
|
const { execSync } = await import('node:child_process');
|
||||||
|
|
||||||
// Re-seed the framework from the freshly-installed package, propagate shipped
|
// Re-seed the framework from the freshly-installed package, re-apply the
|
||||||
// systemd unit fixes to the active units, and (opt-in) relaunch durable
|
// install-ordering guard to settings.json (#882 (b) — closes the
|
||||||
// agents. Shared by the "packages updated" and the "framework drift" paths.
|
// `--sync-only` bypass so `mosaic update` never leaves enforcement-hook
|
||||||
|
// wiring stale/unguarded), propagate shipped systemd unit fixes to the
|
||||||
|
// active units, and (opt-in) relaunch durable agents. Shared by the
|
||||||
|
// "packages updated" and the "framework drift" paths. Extracted to
|
||||||
|
// update-checker.ts (`runUpdateReseedFlow`) for direct unit testability.
|
||||||
const reseedFramework = (reason: string): void => {
|
const reseedFramework = (reason: string): void => {
|
||||||
console.log(reason);
|
const flow = runUpdateReseedFlow(reason, {
|
||||||
const reseed = runFrameworkReseed();
|
reseed: opts.reseed,
|
||||||
if (!reseed.ok) {
|
relaunch: opts.relaunch,
|
||||||
console.error(
|
allowInactiveEnforcement: opts.allowInactiveEnforcement === true,
|
||||||
`\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
|
});
|
||||||
' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
|
if (flow.settingsGuard?.ran && flow.settingsGuard.result?.exitCode === 1) {
|
||||||
'(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
|
// Fail-loud: enforcement hooks were refused/stripped. Surface this
|
||||||
);
|
// in the command's own exit status without aborting the rest of
|
||||||
return;
|
// the update (mirrors mosaic-link-runtime-assets' guard_degraded).
|
||||||
}
|
process.exitCode = 1;
|
||||||
console.log('✔ Framework re-seeded.');
|
|
||||||
if (reseed.skillSyncError) {
|
|
||||||
console.error(` ⚠ Claude skill reconciliation skipped: ${reseed.skillSyncError}`);
|
|
||||||
}
|
|
||||||
const skillConflicts = reseed.skillSync?.conflicts ?? [];
|
|
||||||
const skillChanges =
|
|
||||||
(reseed.skillSync?.registered.length ?? 0) + (reseed.skillSync?.repaired.length ?? 0);
|
|
||||||
if (skillChanges > 0) {
|
|
||||||
console.log(`✔ Registered ${skillChanges.toString()} Mosaic skill(s) with Claude Code.`);
|
|
||||||
}
|
|
||||||
for (const conflict of skillConflicts) {
|
|
||||||
console.error(` ⚠ Skill registration skipped for ${conflict.name}: ${conflict.reason}`);
|
|
||||||
}
|
|
||||||
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
|
|
||||||
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
|
|
||||||
const units = refreshActiveFleetUnits();
|
|
||||||
if (units.refreshed.length > 0) {
|
|
||||||
console.log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`);
|
|
||||||
}
|
|
||||||
const agents = readRosterAgentNames();
|
|
||||||
if (agents.length === 0) return;
|
|
||||||
if (opts.relaunch) {
|
|
||||||
console.log(`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`);
|
|
||||||
for (const restart of buildRelaunchCommands(agents)) {
|
|
||||||
try {
|
|
||||||
execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
|
|
||||||
} catch {
|
|
||||||
console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log('✔ Agents relaunched.');
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` +
|
|
||||||
'Restart them to activate the update:\n mosaic update --relaunch ' +
|
|
||||||
'(or: mosaic fleet restart <agent>)',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -539,7 +514,7 @@ program
|
|||||||
// package is reported outdated. Detect that via the framework version and
|
// package is reported outdated. Detect that via the framework version and
|
||||||
// re-seed so shipped launcher/runtime fixes still activate.
|
// re-seed so shipped launcher/runtime fixes still activate.
|
||||||
const drift = checkFrameworkDrift();
|
const drift = checkFrameworkDrift();
|
||||||
if (drift.drifted && opts.reseed !== false) {
|
if (drift.drifted) {
|
||||||
reseedFramework(
|
reseedFramework(
|
||||||
`\nFramework drift detected (on-disk v${drift.installed} < bundled v${drift.bundled}) — ` +
|
`\nFramework drift detected (on-disk v${drift.installed} < bundled v${drift.bundled}) — ` +
|
||||||
'the CLI was updated outside `mosaic update`. Re-seeding framework files into ' +
|
'the CLI was updated outside `mosaic update`. Re-seeding framework files into ' +
|
||||||
@@ -577,7 +552,7 @@ program
|
|||||||
(r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE,
|
(r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE,
|
||||||
);
|
);
|
||||||
const drift = checkFrameworkDrift();
|
const drift = checkFrameworkDrift();
|
||||||
if ((mosaicUpdated || drift.drifted) && opts.reseed !== false) {
|
if (mosaicUpdated || drift.drifted) {
|
||||||
reseedFramework(
|
reseedFramework(
|
||||||
'\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…',
|
'\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…',
|
||||||
);
|
);
|
||||||
|
|||||||
301
packages/mosaic/src/commands/install-ordering-guard.spec.ts
Normal file
301
packages/mosaic/src/commands/install-ordering-guard.spec.ts
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { leaseEnforcementActivatable } from './lease-activation-probe.js';
|
||||||
|
import {
|
||||||
|
ENFORCEMENT_HOOK_MARKERS,
|
||||||
|
FAIL_LOUD_MESSAGE,
|
||||||
|
guardClaudeSettingsWiring,
|
||||||
|
loudOptOutMessage,
|
||||||
|
runInstallOrderingGuard,
|
||||||
|
settingsHasEnforcementHooks,
|
||||||
|
stripEnforcementHooks,
|
||||||
|
} from './install-ordering-guard.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Red-first tests for issue #869 Point-1 C2 — the install-ordering guard.
|
||||||
|
*
|
||||||
|
* Root cause under test: `mosaic-link-runtime-assets` copies
|
||||||
|
* `runtime/claude/settings.json` (which embeds the PreToolUse
|
||||||
|
* `mutator-gate.py` hook and the Stop `receipt-observer-client.py` hook)
|
||||||
|
* straight into `~/.claude/settings.json`, unconditionally. If the
|
||||||
|
* activation half (C1: `leaseEnforcementActivatable()`) cannot be confirmed,
|
||||||
|
* wiring those hooks bricks the host with a fail-closed gate that can never
|
||||||
|
* be satisfied. This guard must refuse to wire in that case by default, and
|
||||||
|
* only wire anyway on an explicit, loud opt-out.
|
||||||
|
*
|
||||||
|
* All fixtures use temp directories — this suite never reads or writes the
|
||||||
|
* real `~/.claude/settings.json`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FIXTURE_SETTINGS = {
|
||||||
|
model: 'opus',
|
||||||
|
hooks: {
|
||||||
|
PreCompact: [
|
||||||
|
{
|
||||||
|
matcher: '.*',
|
||||||
|
hooks: [{ type: 'command', command: 'python3 revoke-lease.py --reason pre-compact' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
PreToolUse: [
|
||||||
|
{
|
||||||
|
matcher: '.*',
|
||||||
|
hooks: [
|
||||||
|
{
|
||||||
|
type: 'command',
|
||||||
|
command: 'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude',
|
||||||
|
timeout: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matcher: 'Write|Edit|MultiEdit',
|
||||||
|
hooks: [{ type: 'command', command: '~/.config/mosaic/tools/qa/prevent-memory-write.sh' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
PostToolUse: [
|
||||||
|
{
|
||||||
|
matcher: 'Edit|MultiEdit|Write',
|
||||||
|
hooks: [{ type: 'command', command: '~/.config/mosaic/tools/qa/qa-hook-stdin.sh' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Stop: [
|
||||||
|
{
|
||||||
|
hooks: [
|
||||||
|
{
|
||||||
|
type: 'command',
|
||||||
|
command:
|
||||||
|
'python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude',
|
||||||
|
timeout: 3,
|
||||||
|
},
|
||||||
|
{ type: 'command', command: '~/.config/mosaic/tools/qa/reflect-stop-hook.sh' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabledPlugins: { 'feature-dev@claude-plugins-official': true },
|
||||||
|
};
|
||||||
|
|
||||||
|
function fixtureJson(): string {
|
||||||
|
return JSON.stringify(FIXTURE_SETTINGS, null, 2) + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('stripEnforcementHooks', () => {
|
||||||
|
it('removes the PreToolUse mutator-gate trigger entirely', () => {
|
||||||
|
const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS);
|
||||||
|
const hooks = settings['hooks'] as Record<string, unknown[]>;
|
||||||
|
const preToolUse = hooks['PreToolUse'] as Array<{ hooks: Array<{ command: string }> }>;
|
||||||
|
expect(preToolUse.some((t) => t.hooks.some((h) => h.command.includes('mutator-gate.py')))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the sibling prevent-memory-write.sh PreToolUse trigger', () => {
|
||||||
|
const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS);
|
||||||
|
const hooks = settings['hooks'] as Record<string, unknown[]>;
|
||||||
|
const preToolUse = hooks['PreToolUse'] as Array<{ hooks: Array<{ command: string }> }>;
|
||||||
|
expect(
|
||||||
|
preToolUse.some((t) => t.hooks.some((h) => h.command.includes('prevent-memory-write.sh'))),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes only the receipt-observer-client.py hook from Stop, keeping reflect-stop-hook.sh', () => {
|
||||||
|
const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS);
|
||||||
|
const hooks = settings['hooks'] as Record<string, unknown[]>;
|
||||||
|
const stop = hooks['Stop'] as Array<{ hooks: Array<{ command: string }> }>;
|
||||||
|
const commands = stop.flatMap((t) => t.hooks.map((h) => h.command));
|
||||||
|
expect(commands.some((c) => c.includes('receipt-observer-client.py'))).toBe(false);
|
||||||
|
expect(commands.some((c) => c.includes('reflect-stop-hook.sh'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves PreCompact/PostToolUse hooks byte-identical', () => {
|
||||||
|
const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS);
|
||||||
|
const hooks = settings['hooks'] as Record<string, unknown>;
|
||||||
|
expect(hooks['PreCompact']).toEqual(FIXTURE_SETTINGS.hooks.PreCompact);
|
||||||
|
expect(hooks['PostToolUse']).toEqual(FIXTURE_SETTINGS.hooks.PostToolUse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports what it removed', () => {
|
||||||
|
const { removed } = stripEnforcementHooks(FIXTURE_SETTINGS);
|
||||||
|
expect(removed).toContain(`PreToolUse:${ENFORCEMENT_HOOK_MARKERS.preToolUse}`);
|
||||||
|
expect(removed).toContain(`Stop:${ENFORCEMENT_HOOK_MARKERS.stop}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('settingsHasEnforcementHooks', () => {
|
||||||
|
it('is true for the unmodified fixture', () => {
|
||||||
|
expect(settingsHasEnforcementHooks(FIXTURE_SETTINGS)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false after stripping', () => {
|
||||||
|
const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS);
|
||||||
|
expect(settingsHasEnforcementHooks(settings)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for settings with no hooks key at all', () => {
|
||||||
|
expect(settingsHasEnforcementHooks({ model: 'opus' })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('guardClaudeSettingsWiring', () => {
|
||||||
|
it('probe=false (default, no opt-out): strips enforcement hooks and reports non-zero with a loud, actionable message', () => {
|
||||||
|
const outcome = guardClaudeSettingsWiring(fixtureJson(), {}, { activatable: () => false });
|
||||||
|
|
||||||
|
expect(outcome.exitCode).toBe(1);
|
||||||
|
expect(outcome.wired).toBe(false);
|
||||||
|
expect(settingsHasEnforcementHooks(JSON.parse(outcome.json) as Record<string, unknown>)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(outcome.logs).toHaveLength(1);
|
||||||
|
expect(outcome.logs[0]?.level).toBe('error');
|
||||||
|
expect(outcome.logs[0]?.message).toBe(FAIL_LOUD_MESSAGE);
|
||||||
|
expect(outcome.logs[0]?.message).toMatch(/refusing to wire a dead gate/i);
|
||||||
|
expect(outcome.logs[0]?.message).toMatch(/#869/);
|
||||||
|
expect(outcome.logs[0]?.message).toMatch(/--allow-inactive-enforcement/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probe=false + explicit opt-out flag: wires hooks as-is and emits a loud warning', () => {
|
||||||
|
const outcome = guardClaudeSettingsWiring(
|
||||||
|
fixtureJson(),
|
||||||
|
{ allowInactiveEnforcement: true },
|
||||||
|
{ activatable: () => false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(outcome.exitCode).toBe(0);
|
||||||
|
expect(outcome.wired).toBe(true);
|
||||||
|
expect(settingsHasEnforcementHooks(JSON.parse(outcome.json) as Record<string, unknown>)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(outcome.logs).toHaveLength(1);
|
||||||
|
expect(outcome.logs[0]?.level).toBe('warn');
|
||||||
|
expect(outcome.logs[0]?.message).toBe(loudOptOutMessage());
|
||||||
|
expect(outcome.logs[0]?.message).toMatch(/WITHOUT confirmed activation/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probe=true: wires hooks normally with no logs, regardless of opt-out', () => {
|
||||||
|
const outcome = guardClaudeSettingsWiring(fixtureJson(), {}, { activatable: () => true });
|
||||||
|
|
||||||
|
expect(outcome.exitCode).toBe(0);
|
||||||
|
expect(outcome.wired).toBe(true);
|
||||||
|
expect(outcome.logs).toHaveLength(0);
|
||||||
|
expect(JSON.parse(outcome.json)).toEqual(FIXTURE_SETTINGS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probe=true + opt-out flag set anyway: still wires normally, no spurious warning', () => {
|
||||||
|
const outcome = guardClaudeSettingsWiring(
|
||||||
|
fixtureJson(),
|
||||||
|
{ allowInactiveEnforcement: true },
|
||||||
|
{ activatable: () => true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(outcome.exitCode).toBe(0);
|
||||||
|
expect(outcome.wired).toBe(true);
|
||||||
|
expect(outcome.logs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to the real leaseEnforcementActivatable() when no activatable dep is injected', () => {
|
||||||
|
// Deliberately does not assume a fixed true/false value for the real
|
||||||
|
// probe (whether dist/cli.js happens to be built varies by environment —
|
||||||
|
// asserting a hardcoded expectation here would make the test flaky, not
|
||||||
|
// red-first). Instead it proves the wiring is genuinely delegated: the
|
||||||
|
// no-deps call must agree with an explicit call to the same real
|
||||||
|
// predicate, not some other hardcoded value.
|
||||||
|
const reallyActivatable = leaseEnforcementActivatable();
|
||||||
|
const outcome = guardClaudeSettingsWiring(fixtureJson());
|
||||||
|
|
||||||
|
if (reallyActivatable) {
|
||||||
|
expect(outcome.exitCode).toBe(0);
|
||||||
|
expect(outcome.wired).toBe(true);
|
||||||
|
} else {
|
||||||
|
expect(outcome.exitCode).toBe(1);
|
||||||
|
expect(outcome.wired).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runInstallOrderingGuard (file-level, temp dirs only)', () => {
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeSrc(): string {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'mosaic-install-ordering-guard-'));
|
||||||
|
const src = join(dir, 'settings.json');
|
||||||
|
writeFileSync(src, fixtureJson());
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('probe=false: writes a dest settings.json with hooks stripped and returns exitCode 1', () => {
|
||||||
|
const src = makeSrc();
|
||||||
|
const dest = join(dir, 'claude-settings.json');
|
||||||
|
|
||||||
|
const result = runInstallOrderingGuard(src, dest, {}, { activatable: () => false });
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.destWritten).toBe(true);
|
||||||
|
expect(existsSync(dest)).toBe(true);
|
||||||
|
const written = JSON.parse(readFileSync(dest, 'utf-8')) as Record<string, unknown>;
|
||||||
|
expect(settingsHasEnforcementHooks(written)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probe=false + opt-out: writes dest with hooks intact and returns exitCode 0', () => {
|
||||||
|
const src = makeSrc();
|
||||||
|
const dest = join(dir, 'claude-settings.json');
|
||||||
|
|
||||||
|
const result = runInstallOrderingGuard(
|
||||||
|
src,
|
||||||
|
dest,
|
||||||
|
{ allowInactiveEnforcement: true },
|
||||||
|
{ activatable: () => false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
const written = JSON.parse(readFileSync(dest, 'utf-8')) as Record<string, unknown>;
|
||||||
|
expect(settingsHasEnforcementHooks(written)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probe=true: writes dest with hooks intact and returns exitCode 0', () => {
|
||||||
|
const src = makeSrc();
|
||||||
|
const dest = join(dir, 'claude-settings.json');
|
||||||
|
|
||||||
|
const result = runInstallOrderingGuard(src, dest, {}, { activatable: () => true });
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
const written = JSON.parse(readFileSync(dest, 'utf-8')) as Record<string, unknown>;
|
||||||
|
expect(settingsHasEnforcementHooks(written)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backs up a pre-existing divergent dest before overwriting (copy_file_managed parity)', () => {
|
||||||
|
const src = makeSrc();
|
||||||
|
const dest = join(dir, 'claude-settings.json');
|
||||||
|
writeFileSync(dest, JSON.stringify({ preexisting: true }));
|
||||||
|
|
||||||
|
const result = runInstallOrderingGuard(src, dest, {}, { activatable: () => true });
|
||||||
|
|
||||||
|
expect(result.destWritten).toBe(true);
|
||||||
|
expect(result.backupPath).toBeDefined();
|
||||||
|
expect(existsSync(result.backupPath!)).toBe(true);
|
||||||
|
expect(JSON.parse(readFileSync(result.backupPath!, 'utf-8'))).toEqual({ preexisting: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op write when dest already matches the guarded content (idempotent)', () => {
|
||||||
|
const src = makeSrc();
|
||||||
|
const dest = join(dir, 'claude-settings.json');
|
||||||
|
|
||||||
|
const first = runInstallOrderingGuard(src, dest, {}, { activatable: () => true });
|
||||||
|
expect(first.destWritten).toBe(true);
|
||||||
|
|
||||||
|
const second = runInstallOrderingGuard(src, dest, {}, { activatable: () => true });
|
||||||
|
expect(second.destWritten).toBe(false);
|
||||||
|
expect(second.backupPath).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never touches the real home directory settings path used by this test file', () => {
|
||||||
|
// Sanity guard for the suite itself: every dest path used above lives
|
||||||
|
// under the mkdtemp() scratch dir, never under homedir()/.claude.
|
||||||
|
expect(dir).toContain('mosaic-install-ordering-guard-');
|
||||||
|
});
|
||||||
|
});
|
||||||
327
packages/mosaic/src/commands/install-ordering-guard.ts
Normal file
327
packages/mosaic/src/commands/install-ordering-guard.ts
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
/**
|
||||||
|
* Install-ordering guard (issue #869, Point-1 card C2).
|
||||||
|
*
|
||||||
|
* Root cause this exists to guard against (#828 version skew, restated): the
|
||||||
|
* framework reseed / install path (`framework/install.sh` →
|
||||||
|
* `mosaic-link-runtime-assets` → copies `runtime/claude/settings.json` to
|
||||||
|
* `~/.claude/settings.json`) wires the ENFORCEMENT half of the lease broker —
|
||||||
|
* the `PreToolUse` `mutator-gate.py` hook and the `Stop`
|
||||||
|
* `receipt-observer-client.py` hook — unconditionally. If the ACTIVATION half
|
||||||
|
* (a CLI build advertising launch-runtime activation + a running broker
|
||||||
|
* supervisor — see `lease-activation-probe.ts`, C1) is absent, the fail-closed
|
||||||
|
* gate then denies every tool call with GATE_UNAVAILABLE: a bricked host.
|
||||||
|
*
|
||||||
|
* This module is the WIRING gate, not the enforcement gate: it decides
|
||||||
|
* whether the enforcement hook entries are written into the settings.json
|
||||||
|
* that ships to `~/.claude/`. It never touches `mutator-gate.py`'s own
|
||||||
|
* fail-closed-on-absent-identity runtime behavior (test-locked in
|
||||||
|
* `runtime_tools_unittest.py` / `fail-closed-regression.spec.ts`).
|
||||||
|
*
|
||||||
|
* Default (no opt-out): NOT activatable → strip the enforcement hook entries
|
||||||
|
* from the written settings.json and report a non-zero outcome with a loud,
|
||||||
|
* actionable message (see FAIL_LOUD_MESSAGE below).
|
||||||
|
*
|
||||||
|
* Opt-out: `--allow-inactive-enforcement` (an explicit, per-invocation CLI
|
||||||
|
* flag — deliberately NOT an environment variable, so it can never sit as a
|
||||||
|
* silently-inherited default in a shell profile). When set on a NOT
|
||||||
|
* activatable host, the hooks ARE wired but a loud warning is emitted saying
|
||||||
|
* so, and the outcome is reported ok (this is a conscious, informed choice).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import type { Command } from 'commander';
|
||||||
|
import { leaseEnforcementActivatable } from './lease-activation-probe.js';
|
||||||
|
|
||||||
|
// ─── Enforcement hook identification ────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Substrings that identify the two enforcement hook commands #828 wired
|
||||||
|
* unconditionally. Matches the marker strings documented in
|
||||||
|
* `lease-activation-probe.ts`. */
|
||||||
|
export const ENFORCEMENT_HOOK_MARKERS = {
|
||||||
|
preToolUse: 'mutator-gate.py',
|
||||||
|
stop: 'receipt-observer-client.py',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface HookEntry {
|
||||||
|
command?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HookTrigger {
|
||||||
|
matcher?: string;
|
||||||
|
hooks?: HookEntry[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HooksMap = Record<string, HookTrigger[]>;
|
||||||
|
|
||||||
|
function cloneJson<T>(value: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(value)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandIncludes(hook: HookEntry, marker: string): boolean {
|
||||||
|
return String(hook.command ?? '').includes(marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a deep clone of `settings` with the enforcement hook entries removed:
|
||||||
|
* - Any `PreToolUse` trigger group containing a `mutator-gate.py` command is
|
||||||
|
* dropped in full (that trigger exists solely to run the gate).
|
||||||
|
* - Within `Stop` trigger groups, only the individual `receipt-observer-client.py`
|
||||||
|
* hook entry is dropped; sibling hooks in the same trigger (e.g.
|
||||||
|
* `reflect-stop-hook.sh`) are preserved.
|
||||||
|
* Every other hook (PreCompact/SessionStart revoke-lease, the
|
||||||
|
* `prevent-memory-write.sh` PreToolUse trigger, PostToolUse qa/typecheck
|
||||||
|
* hooks) is left byte-identical — this function only ever removes the two
|
||||||
|
* markers above.
|
||||||
|
*/
|
||||||
|
export function stripEnforcementHooks(settings: Record<string, unknown>): {
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
removed: string[];
|
||||||
|
} {
|
||||||
|
const cloned = cloneJson(settings);
|
||||||
|
const removed: string[] = [];
|
||||||
|
const hooks = cloned['hooks'] as HooksMap | undefined;
|
||||||
|
if (!hooks || typeof hooks !== 'object') {
|
||||||
|
return { settings: cloned, removed };
|
||||||
|
}
|
||||||
|
|
||||||
|
const preToolUse = hooks['PreToolUse'];
|
||||||
|
if (Array.isArray(preToolUse)) {
|
||||||
|
const kept = preToolUse.filter((trigger) => {
|
||||||
|
const hasGate = (trigger.hooks ?? []).some((h) =>
|
||||||
|
commandIncludes(h, ENFORCEMENT_HOOK_MARKERS.preToolUse),
|
||||||
|
);
|
||||||
|
if (hasGate) removed.push('PreToolUse:mutator-gate.py');
|
||||||
|
return !hasGate;
|
||||||
|
});
|
||||||
|
if (kept.length > 0) hooks['PreToolUse'] = kept;
|
||||||
|
else delete hooks['PreToolUse'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = hooks['Stop'];
|
||||||
|
if (Array.isArray(stop)) {
|
||||||
|
const rebuilt: HookTrigger[] = [];
|
||||||
|
for (const trigger of stop) {
|
||||||
|
const innerHooks = trigger.hooks ?? [];
|
||||||
|
const keptHooks = innerHooks.filter((h) => {
|
||||||
|
const isReceiptObserver = commandIncludes(h, ENFORCEMENT_HOOK_MARKERS.stop);
|
||||||
|
if (isReceiptObserver) removed.push('Stop:receipt-observer-client.py');
|
||||||
|
return !isReceiptObserver;
|
||||||
|
});
|
||||||
|
if (keptHooks.length > 0) {
|
||||||
|
rebuilt.push({ ...trigger, hooks: keptHooks });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rebuilt.length > 0) hooks['Stop'] = rebuilt;
|
||||||
|
else delete hooks['Stop'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(hooks).length === 0) {
|
||||||
|
delete cloned['hooks'];
|
||||||
|
} else {
|
||||||
|
cloned['hooks'] = hooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { settings: cloned, removed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff `settings` currently wires either enforcement hook. */
|
||||||
|
export function settingsHasEnforcementHooks(settings: Record<string, unknown>): boolean {
|
||||||
|
const hooks = settings['hooks'] as HooksMap | undefined;
|
||||||
|
if (!hooks || typeof hooks !== 'object') return false;
|
||||||
|
|
||||||
|
const preToolUse = hooks['PreToolUse'] ?? [];
|
||||||
|
const preHit = preToolUse.some((trigger) =>
|
||||||
|
(trigger.hooks ?? []).some((h) => commandIncludes(h, ENFORCEMENT_HOOK_MARKERS.preToolUse)),
|
||||||
|
);
|
||||||
|
if (preHit) return true;
|
||||||
|
|
||||||
|
const stop = hooks['Stop'] ?? [];
|
||||||
|
return stop.some((trigger) =>
|
||||||
|
(trigger.hooks ?? []).some((h) => commandIncludes(h, ENFORCEMENT_HOOK_MARKERS.stop)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Guard predicate ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const FAIL_LOUD_MESSAGE =
|
||||||
|
'[mosaic] ERROR: enforcement requested but activation half absent — needs a published CLI ' +
|
||||||
|
'carrying launch-runtime activation + a broker supervisor; refusing to wire a dead gate (see #869). ' +
|
||||||
|
'The PreToolUse mutator-gate.py hook and Stop receipt-observer-client.py hook were NOT written to ' +
|
||||||
|
'settings.json. Fix by installing/updating the CLI and broker, then re-run the framework reseed. ' +
|
||||||
|
'To wire anyway (NOT recommended — the fail-closed gate will deny every tool call with ' +
|
||||||
|
'GATE_UNAVAILABLE until activation is restored), re-run with --allow-inactive-enforcement.';
|
||||||
|
|
||||||
|
export function loudOptOutMessage(): string {
|
||||||
|
return (
|
||||||
|
'[mosaic] WARNING: wiring lease-enforcement hooks (mutator-gate.py / receipt-observer-client.py) ' +
|
||||||
|
'WITHOUT confirmed activation — --allow-inactive-enforcement was set explicitly. The fail-closed ' +
|
||||||
|
'gate will deny every tool call (GATE_UNAVAILABLE) until the activation half (launch-runtime ' +
|
||||||
|
'activation capability + a running broker supervisor) is present on this host (see #869).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GuardLogLevel = 'error' | 'warn';
|
||||||
|
|
||||||
|
export interface GuardLogLine {
|
||||||
|
level: GuardLogLevel;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallOrderingGuardOptions {
|
||||||
|
/** Explicit, per-invocation opt-out. Never source this from an environment
|
||||||
|
* variable — see module doc. */
|
||||||
|
allowInactiveEnforcement?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallOrderingGuardDeps {
|
||||||
|
/** Defaults to {@link leaseEnforcementActivatable}. Injectable for tests. */
|
||||||
|
activatable?: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallOrderingGuardOutcome {
|
||||||
|
/** The settings.json content to write (pretty-printed, trailing newline). */
|
||||||
|
json: string;
|
||||||
|
/** Whether the enforcement hooks are present in `json`. */
|
||||||
|
wired: boolean;
|
||||||
|
/** 0 = proceed normally; 1 = enforcement was refused (fail-loud default path). */
|
||||||
|
exitCode: 0 | 1;
|
||||||
|
logs: GuardLogLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The install-ordering guard: decide whether the enforcement hooks embedded
|
||||||
|
* in the Claude settings.json template may be wired into the settings.json
|
||||||
|
* actually shipped to `~/.claude/`.
|
||||||
|
*
|
||||||
|
* - activatable → wire as-is. exitCode 0, no logs.
|
||||||
|
* - NOT activatable, no opt-out → strip enforcement hooks. exitCode 1,
|
||||||
|
* one 'error' log with the actionable FAIL_LOUD_MESSAGE.
|
||||||
|
* - NOT activatable, opt-out set → wire as-is anyway. exitCode 0, one
|
||||||
|
* 'warn' log making the risk explicit and loud.
|
||||||
|
*
|
||||||
|
* Pure function: takes the raw settings.json text, returns the text to write
|
||||||
|
* plus metadata. No filesystem access — callers (the hidden CLI subcommand
|
||||||
|
* below, or a test) own reading/writing so this stays trivially testable with
|
||||||
|
* fakes/temp files and never risks touching a real `~/.claude/settings.json`.
|
||||||
|
*/
|
||||||
|
export function guardClaudeSettingsWiring(
|
||||||
|
rawSettingsJson: string,
|
||||||
|
options: InstallOrderingGuardOptions = {},
|
||||||
|
deps: InstallOrderingGuardDeps = {},
|
||||||
|
): InstallOrderingGuardOutcome {
|
||||||
|
const parsed = JSON.parse(rawSettingsJson) as Record<string, unknown>;
|
||||||
|
const activatable = deps.activatable ?? leaseEnforcementActivatable;
|
||||||
|
const isActivatable = activatable();
|
||||||
|
|
||||||
|
const serialize = (settings: Record<string, unknown>): string =>
|
||||||
|
JSON.stringify(settings, null, 2) + '\n';
|
||||||
|
|
||||||
|
if (isActivatable) {
|
||||||
|
return {
|
||||||
|
json: serialize(parsed),
|
||||||
|
wired: settingsHasEnforcementHooks(parsed),
|
||||||
|
exitCode: 0,
|
||||||
|
logs: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.allowInactiveEnforcement === true) {
|
||||||
|
return {
|
||||||
|
json: serialize(parsed),
|
||||||
|
wired: settingsHasEnforcementHooks(parsed),
|
||||||
|
exitCode: 0,
|
||||||
|
logs: [{ level: 'warn', message: loudOptOutMessage() }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { settings: stripped } = stripEnforcementHooks(parsed);
|
||||||
|
return {
|
||||||
|
json: serialize(stripped),
|
||||||
|
wired: settingsHasEnforcementHooks(stripped),
|
||||||
|
exitCode: 1,
|
||||||
|
logs: [{ level: 'error', message: FAIL_LOUD_MESSAGE }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── File-level runner (shared by the CLI action + tests) ──────────────────
|
||||||
|
|
||||||
|
export interface RunInstallOrderingGuardResult extends InstallOrderingGuardOutcome {
|
||||||
|
destWritten: boolean;
|
||||||
|
backupPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read `src`, guard it, and write the result to `dest` — mirroring
|
||||||
|
* `copy_file_managed`'s backup-on-change semantics from
|
||||||
|
* `mosaic-link-runtime-assets` (skip the write if content is unchanged;
|
||||||
|
* back up an existing divergent file once, timestamped). Exported standalone
|
||||||
|
* (not only reachable via the CLI action closure) so tests can exercise real
|
||||||
|
* file I/O against temp directories without ever touching `~/.claude/`.
|
||||||
|
*/
|
||||||
|
export function runInstallOrderingGuard(
|
||||||
|
src: string,
|
||||||
|
dest: string,
|
||||||
|
options: InstallOrderingGuardOptions = {},
|
||||||
|
deps: InstallOrderingGuardDeps = {},
|
||||||
|
): RunInstallOrderingGuardResult {
|
||||||
|
const raw = readFileSync(src, 'utf-8');
|
||||||
|
const outcome = guardClaudeSettingsWiring(raw, options, deps);
|
||||||
|
|
||||||
|
mkdirSync(dirname(dest), { recursive: true });
|
||||||
|
|
||||||
|
const existing = existsSync(dest) ? readFileSync(dest, 'utf-8') : null;
|
||||||
|
let destWritten = false;
|
||||||
|
let backupPath: string | undefined;
|
||||||
|
|
||||||
|
if (existing !== outcome.json) {
|
||||||
|
if (existing !== null) {
|
||||||
|
const stamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replace(/[-:]/g, '')
|
||||||
|
.replace(/\..+$/, '')
|
||||||
|
.replace('T', '');
|
||||||
|
backupPath = `${dest}.mosaic-bak-${stamp}`;
|
||||||
|
writeFileSync(backupPath, existing);
|
||||||
|
}
|
||||||
|
writeFileSync(dest, outcome.json);
|
||||||
|
destWritten = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...outcome, destWritten, backupPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hidden CLI bridge (bash → TS) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Hidden CLI subcommand name. `mosaic-link-runtime-assets` (bash) invokes
|
||||||
|
* this instead of its generic `copy_file_managed` for the settings.json
|
||||||
|
* runtime file specifically, so the guard's decision is made by importing
|
||||||
|
* `leaseEnforcementActivatable()` directly rather than re-implementing the
|
||||||
|
* capability/supervisor probes in shell. Deliberately undocumented (hidden
|
||||||
|
* from `--help`) — internal wiring, not a user-facing command. */
|
||||||
|
export const INSTALL_ORDERING_GUARD_COMMAND = '__link-claude-settings';
|
||||||
|
|
||||||
|
export function registerInstallOrderingGuardCommand(program: Command): void {
|
||||||
|
program
|
||||||
|
.command(`${INSTALL_ORDERING_GUARD_COMMAND} <src> <dest>`, { hidden: true })
|
||||||
|
.description(
|
||||||
|
'Internal: copy the Claude settings.json template, gating enforcement-hook ' +
|
||||||
|
'wiring on lease-activation capability (#869 Point-1 C2)',
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
'--allow-inactive-enforcement',
|
||||||
|
'Wire enforcement hooks even when activation cannot be confirmed on this host ' +
|
||||||
|
'(explicit, loud, non-default opt-out — see #869)',
|
||||||
|
)
|
||||||
|
.action((src: string, dest: string, opts: { allowInactiveEnforcement?: boolean }) => {
|
||||||
|
const result = runInstallOrderingGuard(src, dest, {
|
||||||
|
allowInactiveEnforcement: opts.allowInactiveEnforcement === true,
|
||||||
|
});
|
||||||
|
for (const line of result.logs) {
|
||||||
|
(line.level === 'error' ? console.error : console.warn)(line.message);
|
||||||
|
}
|
||||||
|
process.exit(result.exitCode);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import { readRegularFileSecure } from '../fleet/secure-file.js';
|
|||||||
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
||||||
import { canonicalizeRoleClass } from './fleet-personas.js';
|
import { canonicalizeRoleClass } from './fleet-personas.js';
|
||||||
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
||||||
|
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
|
||||||
|
|
||||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||||
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
|
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
|
||||||
@@ -1237,7 +1238,6 @@ export function registerLaunchCommands(program: Command): void {
|
|||||||
// Direct framework script delegates
|
// Direct framework script delegates
|
||||||
const directCommands: Record<string, { desc: string; script: string }> = {
|
const directCommands: Record<string, { desc: string; script: string }> = {
|
||||||
init: { desc: 'Generate SOUL.md (agent identity contract)', script: 'mosaic-init' },
|
init: { desc: 'Generate SOUL.md (agent identity contract)', script: 'mosaic-init' },
|
||||||
doctor: { desc: 'Health audit — detect drift and missing files', script: 'mosaic-doctor' },
|
|
||||||
sync: { desc: 'Sync skills from canonical source', script: 'mosaic-sync-skills' },
|
sync: { desc: 'Sync skills from canonical source', script: 'mosaic-sync-skills' },
|
||||||
bootstrap: {
|
bootstrap: {
|
||||||
desc: 'Bootstrap a repo with Mosaic standards',
|
desc: 'Bootstrap a repo with Mosaic standards',
|
||||||
@@ -1256,4 +1256,67 @@ export function registerLaunchCommands(program: Command): void {
|
|||||||
delegateToScript(fwScript(script), cmd.args);
|
delegateToScript(fwScript(script), cmd.args);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `doctor` — the framework drift audit (bash script) PLUS the #869
|
||||||
|
// Point-1 C5 lease-enforcement activation check (TS, reusing C1's
|
||||||
|
// `leaseEnforcementActivatable()` and C3's `checkBrokerSupervisorHealth()`).
|
||||||
|
// Kept out of the generic `directCommands` loop above because this check
|
||||||
|
// must run and report BEFORE the bash script's own exit, and must be able
|
||||||
|
// to force a non-zero exit on its own — a silent pass on "enforcement
|
||||||
|
// hooks wired but activation absent" would leave a bricked host
|
||||||
|
// undiagnosed (see lease-doctor-check.ts docstring).
|
||||||
|
program
|
||||||
|
.command('doctor')
|
||||||
|
.description('Health audit — detect drift, missing files, and #869 lease-activation gaps')
|
||||||
|
.allowUnknownOption(true)
|
||||||
|
.allowExcessArguments(true)
|
||||||
|
.action(async (_opts: unknown, cmd: Command) => {
|
||||||
|
checkMosaicHome();
|
||||||
|
const leaseCheck = await runLeaseEnforcementDoctorCheck();
|
||||||
|
const leaseCheckFailed = printLeaseDoctorCheck(leaseCheck);
|
||||||
|
runDoctorScriptAndExit(fwScript('mosaic-doctor'), cmd.args, leaseCheckFailed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print the #869 C5 lease-enforcement doctor result using the same
|
||||||
|
* `[mosaic-doctor]` prefix the bash audit script uses, but with a distinct
|
||||||
|
* `[ERROR]` severity token (louder than the script's own `[WARN]`) — this is
|
||||||
|
* a hard, actionable brick warning, not a soft drift warning, and must never
|
||||||
|
* read as just one more line among the script's routine warnings. Silent on
|
||||||
|
* an `ok` result, matching this file's other pre-flight checks
|
||||||
|
* (`checkMosaicHome`, `checkFile`, `checkRuntime`) which only print on
|
||||||
|
* failure. Returns whether the check failed, so the caller can force a
|
||||||
|
* non-zero exit regardless of the bash script's own exit code.
|
||||||
|
*/
|
||||||
|
function printLeaseDoctorCheck(
|
||||||
|
result: Awaited<ReturnType<typeof runLeaseEnforcementDoctorCheck>>,
|
||||||
|
): boolean {
|
||||||
|
if (result.status === 'error') {
|
||||||
|
console.error(`[mosaic-doctor] [ERROR] ${result.message}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the bash `mosaic-doctor` audit script (inheriting stdio, same as
|
||||||
|
* {@link delegateToScript}) and exit with a non-zero code if EITHER the
|
||||||
|
* script itself reported failure OR the lease-enforcement check above did —
|
||||||
|
* so `--fail-on-warn` and other script-level exit semantics are preserved,
|
||||||
|
* but the lease-enforcement ERROR can never be masked by an otherwise-green
|
||||||
|
* script run.
|
||||||
|
*/
|
||||||
|
function runDoctorScriptAndExit(scriptPath: string, args: string[], forceFailure: boolean): never {
|
||||||
|
if (!existsSync(scriptPath)) {
|
||||||
|
console.error(`[mosaic] Script not found: ${scriptPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
let scriptExitCode = 0;
|
||||||
|
try {
|
||||||
|
execFileSync('bash', [scriptPath, ...args], { stdio: 'inherit', env: process.env });
|
||||||
|
} catch (err) {
|
||||||
|
scriptExitCode = (err as { status?: number }).status ?? 1;
|
||||||
|
}
|
||||||
|
process.exit(forceFailure ? 1 : scriptExitCode);
|
||||||
}
|
}
|
||||||
|
|||||||
196
packages/mosaic/src/commands/lease-doctor-check.spec.ts
Normal file
196
packages/mosaic/src/commands/lease-doctor-check.spec.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
detectEnforcementHooksWired,
|
||||||
|
runLeaseEnforcementDoctorCheck,
|
||||||
|
} from './lease-doctor-check.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Red-first tests for issue #869 Point-1 C5 — the `mosaic doctor`
|
||||||
|
* lease-enforcement surfacing check.
|
||||||
|
*
|
||||||
|
* Root cause under test: enforcement hooks (`mutator-gate.py`,
|
||||||
|
* `receipt-observer-client.py`) can be wired into `~/.claude/settings.json`
|
||||||
|
* on a host where C1's `leaseEnforcementActivatable()` is false and/or C3's
|
||||||
|
* `checkBrokerSupervisorHealth()` reports unhealthy. That combination fails
|
||||||
|
* closed correctly, but must be surfaced LOUDLY by `mosaic doctor` rather
|
||||||
|
* than silently passing — this test suite exercises the three primary
|
||||||
|
* branches (wired+not-activatable, wired+healthy, not-wired) plus the
|
||||||
|
* broker-unhealthy variant.
|
||||||
|
*
|
||||||
|
* Every dependency is injected — no real `~/.claude/settings.json` and no
|
||||||
|
* real broker are ever touched.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WIRED_SETTINGS_JSON = JSON.stringify({
|
||||||
|
hooks: {
|
||||||
|
PreToolUse: [
|
||||||
|
{
|
||||||
|
matcher: '.*',
|
||||||
|
hooks: [
|
||||||
|
{
|
||||||
|
type: 'command',
|
||||||
|
command: 'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Stop: [
|
||||||
|
{
|
||||||
|
hooks: [
|
||||||
|
{
|
||||||
|
type: 'command',
|
||||||
|
command:
|
||||||
|
'python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const UNWIRED_SETTINGS_JSON = JSON.stringify({
|
||||||
|
hooks: {
|
||||||
|
PostToolUse: [
|
||||||
|
{
|
||||||
|
matcher: 'Edit|MultiEdit|Write',
|
||||||
|
hooks: [{ type: 'command', command: '~/.config/mosaic/tools/qa/qa-hook-stdin.sh' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectEnforcementHooksWired', () => {
|
||||||
|
it('detects the mutator-gate + receipt-observer markers when wired', () => {
|
||||||
|
const result = detectEnforcementHooksWired(JSON.parse(WIRED_SETTINGS_JSON));
|
||||||
|
expect(result.wired).toBe(true);
|
||||||
|
expect(result.matchedMarkers).toEqual(
|
||||||
|
expect.arrayContaining(['mutator-gate.py', 'receipt-observer-client.py']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports not wired when no enforcement markers are present', () => {
|
||||||
|
const result = detectEnforcementHooksWired(JSON.parse(UNWIRED_SETTINGS_JSON));
|
||||||
|
expect(result.wired).toBe(false);
|
||||||
|
expect(result.matchedMarkers).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports not wired for an empty settings object', () => {
|
||||||
|
expect(detectEnforcementHooksWired({}).wired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects wiring from just ONE marker (partial wiring is still dangerous)', () => {
|
||||||
|
const onlyMutatorGate = JSON.stringify({
|
||||||
|
hooks: {
|
||||||
|
PreToolUse: [
|
||||||
|
{
|
||||||
|
hooks: [{ type: 'command', command: 'python3 .../mutator-gate.py --runtime claude' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = detectEnforcementHooksWired(JSON.parse(onlyMutatorGate));
|
||||||
|
expect(result.wired).toBe(true);
|
||||||
|
expect(result.matchedMarkers).toEqual(['mutator-gate.py']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runLeaseEnforcementDoctorCheck', () => {
|
||||||
|
it('RED: wired + not-activatable ⇒ LOUD error (not a silent pass)', async () => {
|
||||||
|
const result = await runLeaseEnforcementDoctorCheck({
|
||||||
|
readSettingsRaw: () => WIRED_SETTINGS_JSON,
|
||||||
|
isActivatable: () => false,
|
||||||
|
isBrokerHealthy: async () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('error');
|
||||||
|
expect(result.wired).toBe(true);
|
||||||
|
expect(result.activatable).toBe(false);
|
||||||
|
expect(result.message).toMatch(/activation absent/);
|
||||||
|
expect(result.message).toMatch(/#869/);
|
||||||
|
expect(result.message.toLowerCase()).toMatch(/brick/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wired + activatable + broker-unhealthy ⇒ LOUD error', async () => {
|
||||||
|
const result = await runLeaseEnforcementDoctorCheck({
|
||||||
|
readSettingsRaw: () => WIRED_SETTINGS_JSON,
|
||||||
|
isActivatable: () => true,
|
||||||
|
isBrokerHealthy: async () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('error');
|
||||||
|
expect(result.wired).toBe(true);
|
||||||
|
expect(result.brokerHealthy).toBe(false);
|
||||||
|
expect(result.message).toMatch(/broker not healthy/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wired + not-activatable + broker-unhealthy ⇒ LOUD error citing both reasons', async () => {
|
||||||
|
const result = await runLeaseEnforcementDoctorCheck({
|
||||||
|
readSettingsRaw: () => WIRED_SETTINGS_JSON,
|
||||||
|
isActivatable: () => false,
|
||||||
|
isBrokerHealthy: async () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('error');
|
||||||
|
expect(result.message).toMatch(/activation absent/);
|
||||||
|
expect(result.message).toMatch(/broker not healthy/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GREEN: wired + activatable + broker-healthy ⇒ ok', async () => {
|
||||||
|
const result = await runLeaseEnforcementDoctorCheck({
|
||||||
|
readSettingsRaw: () => WIRED_SETTINGS_JSON,
|
||||||
|
isActivatable: () => true,
|
||||||
|
isBrokerHealthy: async () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('ok');
|
||||||
|
expect(result.wired).toBe(true);
|
||||||
|
expect(result.activatable).toBe(true);
|
||||||
|
expect(result.brokerHealthy).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GREEN: not-wired ⇒ ok, no false alarm (activation/broker never probed)', async () => {
|
||||||
|
let activatableCalled = false;
|
||||||
|
let brokerCalled = false;
|
||||||
|
|
||||||
|
const result = await runLeaseEnforcementDoctorCheck({
|
||||||
|
readSettingsRaw: () => UNWIRED_SETTINGS_JSON,
|
||||||
|
isActivatable: () => {
|
||||||
|
activatableCalled = true;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
isBrokerHealthy: async () => {
|
||||||
|
brokerCalled = true;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('ok');
|
||||||
|
expect(result.wired).toBe(false);
|
||||||
|
expect(result.activatable).toBeNull();
|
||||||
|
expect(result.brokerHealthy).toBeNull();
|
||||||
|
// Not wired must short-circuit — never even consult activation/broker.
|
||||||
|
expect(activatableCalled).toBe(false);
|
||||||
|
expect(brokerCalled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GREEN: settings.json absent ⇒ ok (never touches a real file — readSettingsRaw is injected)', async () => {
|
||||||
|
const result = await runLeaseEnforcementDoctorCheck({
|
||||||
|
readSettingsRaw: () => null,
|
||||||
|
isActivatable: () => false,
|
||||||
|
isBrokerHealthy: async () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('ok');
|
||||||
|
expect(result.wired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GREEN: malformed settings.json ⇒ ok (parse errors are not this card’s failure class)', async () => {
|
||||||
|
const result = await runLeaseEnforcementDoctorCheck({
|
||||||
|
readSettingsRaw: () => '{ not valid json',
|
||||||
|
isActivatable: () => false,
|
||||||
|
isBrokerHealthy: async () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
210
packages/mosaic/src/commands/lease-doctor-check.ts
Normal file
210
packages/mosaic/src/commands/lease-doctor-check.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* Lease-enforcement doctor check (issue #869, Point-1 card C5).
|
||||||
|
*
|
||||||
|
* Root cause this guards against (#828 version skew, the same one C1/C3
|
||||||
|
* exist for): the Claude Code enforcement hooks (`mutator-gate.py` gating
|
||||||
|
* PreToolUse, `receipt-observer-client.py` observing Stop) can be WIRED into
|
||||||
|
* `~/.claude/settings.json` on a host where the ACTIVATION half is absent —
|
||||||
|
* no compatible CLI build (C1's `leaseEnforcementActivatable()`), or no
|
||||||
|
* healthy broker supervisor (C3's `checkBrokerSupervisorHealth()`). That
|
||||||
|
* combination is a silent brick: every gated tool call denies with
|
||||||
|
* GATE_UNAVAILABLE, and the fail-closed behavior is *correct* — but nothing
|
||||||
|
* surfaces it to an operator running `mosaic doctor` on an already-bricked
|
||||||
|
* host.
|
||||||
|
*
|
||||||
|
* This module answers one question — "if I ran right now, would I be
|
||||||
|
* bricked?" — by combining:
|
||||||
|
*
|
||||||
|
* 1. wiring detection: does `~/.claude/settings.json` reference either
|
||||||
|
* enforcement-hook marker (`mutator-gate.py` / `receipt-observer-client.py`)?
|
||||||
|
* 2. C1's `leaseEnforcementActivatable()` — could activation satisfy
|
||||||
|
* enforcement if it were exercised right now?
|
||||||
|
* 3. C3's `checkBrokerSupervisorHealth()` — is the broker supervisor
|
||||||
|
* actually healthy?
|
||||||
|
*
|
||||||
|
* Not wired ⇒ ok (nothing to activate, no false alarm). Wired AND activatable
|
||||||
|
* AND broker-healthy ⇒ ok. Wired AND (NOT activatable OR broker unhealthy) ⇒
|
||||||
|
* a LOUD, actionable error — this module never silently passes that state.
|
||||||
|
*
|
||||||
|
* Every dependency (settings read, activation probe, broker-health check) is
|
||||||
|
* injectable so tests can drive every branch without ever touching a real
|
||||||
|
* `~/.claude/settings.json` or a real broker.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { leaseEnforcementActivatable, type ActivationProbeDeps } from './lease-activation-probe.js';
|
||||||
|
import {
|
||||||
|
checkBrokerSupervisorHealth,
|
||||||
|
resolveBrokerSupervisorPaths,
|
||||||
|
} from '../lease-broker/broker-supervisor.js';
|
||||||
|
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||||
|
|
||||||
|
/** Markers identifying the two enforcement-hook halves wired via the
|
||||||
|
* framework reseed. Either marker's presence in `settings.json` means
|
||||||
|
* enforcement is wired — a host can be bricked with just one half present. */
|
||||||
|
const ENFORCEMENT_HOOK_MARKERS = ['mutator-gate.py', 'receipt-observer-client.py'] as const;
|
||||||
|
|
||||||
|
export interface EnforcementHooksWiredResult {
|
||||||
|
readonly wired: boolean;
|
||||||
|
readonly matchedMarkers: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether the Claude Code enforcement hooks (mutator-gate /
|
||||||
|
* receipt-observer) are wired into an already-parsed `settings.json`.
|
||||||
|
* Pure/testable — takes parsed JSON, never touches the filesystem itself.
|
||||||
|
*/
|
||||||
|
export function detectEnforcementHooksWired(settings: unknown): EnforcementHooksWiredResult {
|
||||||
|
const serialized = JSON.stringify(settings ?? {});
|
||||||
|
const matchedMarkers = ENFORCEMENT_HOOK_MARKERS.filter((marker) => serialized.includes(marker));
|
||||||
|
return { wired: matchedMarkers.length > 0, matchedMarkers };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaseDoctorCheckDeps {
|
||||||
|
/**
|
||||||
|
* Read raw `settings.json` text; return `null` if the file is absent.
|
||||||
|
* Defaults to reading the real `~/.claude/settings.json`. ALWAYS inject a
|
||||||
|
* fake in tests — never point this at a real host's settings file.
|
||||||
|
*/
|
||||||
|
readSettingsRaw?: () => string | null;
|
||||||
|
/** Defaults to {@link leaseEnforcementActivatable} (C1). Inject for tests. */
|
||||||
|
isActivatable?: (deps?: ActivationProbeDeps) => boolean;
|
||||||
|
/**
|
||||||
|
* Defaults to a real broker-supervisor health check (C3) rooted at
|
||||||
|
* `mosaicHome`. Inject for tests — never point this at a real broker.
|
||||||
|
*/
|
||||||
|
isBrokerHealthy?: () => Promise<boolean>;
|
||||||
|
/** Mosaic home used to resolve default broker-supervisor paths. Defaults to
|
||||||
|
* `$MOSAIC_HOME` or `~/.config/mosaic`. */
|
||||||
|
mosaicHome?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LeaseDoctorCheckStatus = 'ok' | 'error';
|
||||||
|
|
||||||
|
export interface LeaseDoctorCheckResult {
|
||||||
|
readonly status: LeaseDoctorCheckStatus;
|
||||||
|
readonly wired: boolean;
|
||||||
|
/** `null` when hooks are not wired (activation/broker were never probed). */
|
||||||
|
readonly activatable: boolean | null;
|
||||||
|
/** `null` when hooks are not wired (activation/broker were never probed). */
|
||||||
|
readonly brokerHealthy: boolean | null;
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultReadSettingsRaw(): string | null {
|
||||||
|
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
||||||
|
try {
|
||||||
|
return readFileSync(settingsPath, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if (isEnoent(error)) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnoent(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultMosaicHome(): string {
|
||||||
|
return process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultIsBrokerHealthy(mosaicHome: string): Promise<boolean> {
|
||||||
|
// `frameworkRoot` only feeds SOURCE paths (unit/wrapper/daemon file
|
||||||
|
// locations for `applyBrokerSupervisor`); the health check only reads
|
||||||
|
// TARGET paths (`unitTargetPath`, `socketPath`), both derived from
|
||||||
|
// `mosaicHome`/`homeDir`/`env` alone. Passing `mosaicHome` again here is
|
||||||
|
// therefore safe and never resolves or touches a framework checkout.
|
||||||
|
const paths = resolveBrokerSupervisorPaths({ mosaicHome, frameworkRoot: mosaicHome });
|
||||||
|
return (await checkBrokerSupervisorHealth(paths)).healthy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surface the #869 fail-closed brick scenario as a LOUD `mosaic doctor`
|
||||||
|
* error. See module docstring for the full decision table.
|
||||||
|
*/
|
||||||
|
export async function runLeaseEnforcementDoctorCheck(
|
||||||
|
deps: LeaseDoctorCheckDeps = {},
|
||||||
|
): Promise<LeaseDoctorCheckResult> {
|
||||||
|
const readSettingsRaw = deps.readSettingsRaw ?? defaultReadSettingsRaw;
|
||||||
|
const mosaicHome = deps.mosaicHome ?? defaultMosaicHome();
|
||||||
|
const isActivatable = deps.isActivatable ?? leaseEnforcementActivatable;
|
||||||
|
const isBrokerHealthy = deps.isBrokerHealthy ?? (() => defaultIsBrokerHealthy(mosaicHome));
|
||||||
|
|
||||||
|
const raw = readSettingsRaw();
|
||||||
|
if (raw === null) {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
wired: false,
|
||||||
|
activatable: null,
|
||||||
|
brokerHealthy: null,
|
||||||
|
message: 'Claude Code settings.json not found — lease-enforcement hooks not wired.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
// Malformed settings.json is a different failure class than this card
|
||||||
|
// owns (C2 guards install-time writes); report ok rather than
|
||||||
|
// misattributing a parse error to the #869 activation gap.
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
wired: false,
|
||||||
|
activatable: null,
|
||||||
|
brokerHealthy: null,
|
||||||
|
message:
|
||||||
|
'Claude Code settings.json could not be parsed — skipping lease-enforcement wiring check.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { wired, matchedMarkers } = detectEnforcementHooksWired(parsed);
|
||||||
|
if (!wired) {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
wired: false,
|
||||||
|
activatable: null,
|
||||||
|
brokerHealthy: null,
|
||||||
|
message:
|
||||||
|
'Lease-enforcement hooks not wired in ~/.claude/settings.json — nothing to activate.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const activatable = isActivatable();
|
||||||
|
const brokerHealthy = await isBrokerHealthy();
|
||||||
|
|
||||||
|
if (activatable && brokerHealthy) {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
wired: true,
|
||||||
|
activatable,
|
||||||
|
brokerHealthy,
|
||||||
|
message: `Lease-enforcement hooks wired (${matchedMarkers.join(', ')}) — activation capability present and broker healthy.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasons: string[] = [];
|
||||||
|
if (!activatable) reasons.push('activation absent (leaseEnforcementActivatable() is false)');
|
||||||
|
if (!brokerHealthy) {
|
||||||
|
reasons.push('broker not healthy (checkBrokerSupervisorHealth() reports unhealthy)');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
wired: true,
|
||||||
|
activatable,
|
||||||
|
brokerHealthy,
|
||||||
|
message:
|
||||||
|
`Lease-enforcement hooks (${matchedMarkers.join(', ')}) are wired in ~/.claude/settings.json, but ${reasons.join(' and ')}. ` +
|
||||||
|
'Every gated tool call will fail closed and BRICK this agent (see #869). ' +
|
||||||
|
'Remediate by activating the lease-broker supervisor (systemd unit + socket) or by removing the enforcement hooks from ~/.claude/settings.json.',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -47,6 +47,22 @@ const piLifecyclePath = join(frameworkRoot, 'runtime/pi/lease-lifecycle.ts');
|
|||||||
const prdyInitPath = join(frameworkRoot, 'tools/prdy/prdy-init.sh');
|
const prdyInitPath = join(frameworkRoot, 'tools/prdy/prdy-init.sh');
|
||||||
const prdyUpdatePath = join(frameworkRoot, 'tools/prdy/prdy-update.sh');
|
const prdyUpdatePath = join(frameworkRoot, 'tools/prdy/prdy-update.sh');
|
||||||
const remediationHandlerPath = join(frameworkRoot, 'tools/qa/remediation-hook-handler.sh');
|
const remediationHandlerPath = join(frameworkRoot, 'tools/qa/remediation-hook-handler.sh');
|
||||||
|
|
||||||
|
// C4 (#869 Point-1): launch-runtime.py now asserts, before anything else,
|
||||||
|
// that the CLI's advertised lease-activation capability (normally read via
|
||||||
|
// the hidden `mosaic __lease-capability` subcommand) matches what
|
||||||
|
// enforcement expects — see framework/tools/lease-broker/
|
||||||
|
// activation_version_gate.py. This suite drives launch-runtime.py directly
|
||||||
|
// as a subprocess (never through the real `mosaic` CLI), so — exactly like
|
||||||
|
// the fake broker (daemon.py) and fake `claude` binaries already used
|
||||||
|
// below — it must supply a fake activation-capability probe rather than
|
||||||
|
// depend on a real `mosaic` binary being on PATH. `MOSAIC_LEASE_VERSION_PROBE_COMMAND`
|
||||||
|
// is launch-runtime.py's injection point for that fake; this literal
|
||||||
|
// {name, version} pair must be kept in sync with
|
||||||
|
// `EXPECTED_ACTIVATION_CAPABILITY` (activation_version_gate.py) and
|
||||||
|
// `LEASE_ACTIVATION_CAPABILITY` (lease-activation-probe.ts) — all three
|
||||||
|
// currently agree on v1.
|
||||||
|
const leaseCapabilityProbeStub = `python3 -c "import json; print(json.dumps({'name': 'lease-runtime-activation', 'version': 1}))"`;
|
||||||
const children: ChildProcess[] = [];
|
const children: ChildProcess[] = [];
|
||||||
const temporaryRoots: string[] = [];
|
const temporaryRoots: string[] = [];
|
||||||
|
|
||||||
@@ -184,6 +200,7 @@ raise SystemExit(0 if len(session_id) == 64 and denied else 1)
|
|||||||
MOSAIC_PRDY_RUNTIME: 'claude',
|
MOSAIC_PRDY_RUNTIME: 'claude',
|
||||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||||
MOSAIC_RUNTIME_GENERATION: '1',
|
MOSAIC_RUNTIME_GENERATION: '1',
|
||||||
|
MOSAIC_LEASE_VERSION_PROBE_COMMAND: leaseCapabilityProbeStub,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -743,6 +760,7 @@ describe('whole mutator-class lease gate', () => {
|
|||||||
...process.env,
|
...process.env,
|
||||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||||
MOSAIC_RUNTIME_GENERATION: '1',
|
MOSAIC_RUNTIME_GENERATION: '1',
|
||||||
|
MOSAIC_LEASE_VERSION_PROBE_COMMAND: leaseCapabilityProbeStub,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -761,6 +779,7 @@ describe('whole mutator-class lease gate', () => {
|
|||||||
...process.env,
|
...process.env,
|
||||||
MOSAIC_LEASE_BROKER_SOCKET: join(tmpdir(), 'missing-mosaic-broker.sock'),
|
MOSAIC_LEASE_BROKER_SOCKET: join(tmpdir(), 'missing-mosaic-broker.sock'),
|
||||||
MOSAIC_RUNTIME_GENERATION: '1',
|
MOSAIC_RUNTIME_GENERATION: '1',
|
||||||
|
MOSAIC_LEASE_VERSION_PROBE_COMMAND: leaseCapabilityProbeStub,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -878,6 +897,7 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and observers_prese
|
|||||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||||
MOSAIC_RUNTIME_GENERATION: '1',
|
MOSAIC_RUNTIME_GENERATION: '1',
|
||||||
|
MOSAIC_LEASE_VERSION_PROBE_COMMAND: leaseCapabilityProbeStub,
|
||||||
},
|
},
|
||||||
proxyGate: () =>
|
proxyGate: () =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ LAUNCHER = load_tool("lease_runtime_launcher", "launch-runtime.py")
|
|||||||
GATE = load_tool("lease_mutator_gate", "mutator-gate.py")
|
GATE = load_tool("lease_mutator_gate", "mutator-gate.py")
|
||||||
|
|
||||||
|
|
||||||
|
def matching_activation_probe(*_args: object, **_kwargs: object) -> dict[str, object]:
|
||||||
|
"""Fake activation-capability probe matching what enforcement expects
|
||||||
|
(C4, #869 Point-1). Injected into `LAUNCHER.main()` calls below that are
|
||||||
|
exercising OTHER branches (registration, exec, generation init, ...) so
|
||||||
|
the new version-coupling gate — which runs before those — never blocks
|
||||||
|
on host state (no real `mosaic` CLI on PATH in a test sandbox). The
|
||||||
|
version-coupling gate's OWN behavior (match/mismatch/absent) is covered
|
||||||
|
by its dedicated red-first tests in `version_coupling_unittest.py`."""
|
||||||
|
|
||||||
|
return dict(LAUNCHER.EXPECTED_ACTIVATION_CAPABILITY)
|
||||||
|
|
||||||
|
|
||||||
class FakeSocket:
|
class FakeSocket:
|
||||||
def __init__(self, *chunks: bytes):
|
def __init__(self, *chunks: bytes):
|
||||||
self.chunks = list(chunks)
|
self.chunks = list(chunks)
|
||||||
@@ -95,6 +107,7 @@ class LaunchRuntimeTest(unittest.TestCase):
|
|||||||
request=request,
|
request=request,
|
||||||
execute=execute,
|
execute=execute,
|
||||||
initialize_generation=initialize_generation,
|
initialize_generation=initialize_generation,
|
||||||
|
probe_activation_capability=matching_activation_probe,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
self.assertEqual(result, 0)
|
||||||
@@ -127,6 +140,7 @@ class LaunchRuntimeTest(unittest.TestCase):
|
|||||||
request=lambda *_args: {"ok": True, "session_id": "e" * 64},
|
request=lambda *_args: {"ok": True, "session_id": "e" * 64},
|
||||||
execute=lambda *args: executed.append(args),
|
execute=lambda *args: executed.append(args),
|
||||||
initialize_generation=lambda *_args: None,
|
initialize_generation=lambda *_args: None,
|
||||||
|
probe_activation_capability=matching_activation_probe,
|
||||||
)
|
)
|
||||||
self.assertEqual(result, 0)
|
self.assertEqual(result, 0)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -153,6 +167,7 @@ class LaunchRuntimeTest(unittest.TestCase):
|
|||||||
request=lambda *_args: {"ok": True, "session_id": "f" * 64},
|
request=lambda *_args: {"ok": True, "session_id": "f" * 64},
|
||||||
execute=lambda *args: executed.append(args),
|
execute=lambda *args: executed.append(args),
|
||||||
initialize_generation=lambda *_args: None,
|
initialize_generation=lambda *_args: None,
|
||||||
|
probe_activation_capability=matching_activation_probe,
|
||||||
)
|
)
|
||||||
self.assertEqual(result, 0)
|
self.assertEqual(result, 0)
|
||||||
self.assertEqual(executed[0][0:2], ("pi", ["pi", "--print", "hello"]))
|
self.assertEqual(executed[0][0:2], ("pi", ["pi", "--print", "hello"]))
|
||||||
@@ -188,6 +203,7 @@ class LaunchRuntimeTest(unittest.TestCase):
|
|||||||
environ=environment,
|
environ=environment,
|
||||||
request=lambda *_args, value=reply: value,
|
request=lambda *_args, value=reply: value,
|
||||||
execute=lambda *args: executed.append(args),
|
execute=lambda *args: executed.append(args),
|
||||||
|
probe_activation_capability=matching_activation_probe,
|
||||||
)
|
)
|
||||||
self.assertEqual(result, 1)
|
self.assertEqual(result, 1)
|
||||||
self.assertEqual(executed, [])
|
self.assertEqual(executed, [])
|
||||||
@@ -203,6 +219,7 @@ class LaunchRuntimeTest(unittest.TestCase):
|
|||||||
initialize_generation=lambda *_args: (_ for _ in ()).throw(
|
initialize_generation=lambda *_args: (_ for _ in ()).throw(
|
||||||
OSError("unsafe state")
|
OSError("unsafe state")
|
||||||
),
|
),
|
||||||
|
probe_activation_capability=matching_activation_probe,
|
||||||
),
|
),
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
@@ -220,6 +237,7 @@ class LaunchRuntimeTest(unittest.TestCase):
|
|||||||
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/x"},
|
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/x"},
|
||||||
request=request,
|
request=request,
|
||||||
execute=lambda *_args: self.fail("must not execute"),
|
execute=lambda *_args: self.fail("must not execute"),
|
||||||
|
probe_activation_capability=matching_activation_probe,
|
||||||
),
|
),
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
@@ -233,6 +251,7 @@ class LaunchRuntimeTest(unittest.TestCase):
|
|||||||
request=lambda *_args: {"ok": True, "session_id": "c" * 64},
|
request=lambda *_args: {"ok": True, "session_id": "c" * 64},
|
||||||
execute=lambda *_args: (_ for _ in ()).throw(OSError("missing")),
|
execute=lambda *_args: (_ for _ in ()).throw(OSError("missing")),
|
||||||
initialize_generation=lambda *_args: None,
|
initialize_generation=lambda *_args: None,
|
||||||
|
probe_activation_capability=matching_activation_probe,
|
||||||
),
|
),
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|||||||
287
packages/mosaic/src/mutator-gate/version_coupling_unittest.py
Normal file
287
packages/mosaic/src/mutator-gate/version_coupling_unittest.py
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Red-first tests for issue #869 Point-1 C4 — the enforcement/activation
|
||||||
|
version-coupling assertion at the `launch-runtime.py` seam.
|
||||||
|
|
||||||
|
Root cause under test (#828 restated): the lease broker's ENFORCEMENT half
|
||||||
|
(this toolkit) and its ACTIVATION half (`execLeaseGatedRuntime()` in
|
||||||
|
`launch.ts`, chained through `launch-runtime.py`) shipped on different
|
||||||
|
channels and drifted. C1 (`lease-activation-probe.ts`) gave the activation
|
||||||
|
half a versioned, machine-checkable identity
|
||||||
|
(`LEASE_ACTIVATION_CAPABILITY`, printed via the hidden CLI subcommand
|
||||||
|
`mosaic __lease-capability`). C4 (this module + `activation_version_gate.py`)
|
||||||
|
is the assertion that actually USES that identity: enforcement must refuse
|
||||||
|
to proceed — loudly, with an actionable remediation message, never a
|
||||||
|
silent pass — unless the activation capability it observes exactly matches
|
||||||
|
what enforcement expects.
|
||||||
|
|
||||||
|
Every case here drives the seam with injected fakes/stubs (a fake
|
||||||
|
`probe_activation_capability` callable at the `launch-runtime.py` level, or
|
||||||
|
a fake `run` transport at the `activation_version_gate` level) — never a
|
||||||
|
real broker, a real installed CLI, or a real `mosaic` binary on PATH.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stderr
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS_DIR = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
||||||
|
if str(TOOLS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(TOOLS_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def load_tool(module_name: str, filename: str):
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, TOOLS_DIR / filename)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise RuntimeError(f"unable to load {filename}")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
# Loaded under distinct module names from runtime_tools_unittest.py's own
|
||||||
|
# LAUNCHER/GATE loads — importlib.util.module_from_spec() gives each load a
|
||||||
|
# fresh module object regardless of name collisions, but distinct names keep
|
||||||
|
# tracebacks/debugging unambiguous when both files run in the same process.
|
||||||
|
LAUNCHER = load_tool("lease_runtime_launcher_version_coupling", "launch-runtime.py")
|
||||||
|
VERSION_GATE = load_tool("lease_activation_version_gate_test", "activation_version_gate.py")
|
||||||
|
|
||||||
|
|
||||||
|
def matching_capability() -> dict[str, object]:
|
||||||
|
return dict(VERSION_GATE.EXPECTED_ACTIVATION_CAPABILITY)
|
||||||
|
|
||||||
|
|
||||||
|
class AssertActivationCapabilityMatchesTest(unittest.TestCase):
|
||||||
|
"""Unit-level coverage of `activation_version_gate.py`'s own assertion,
|
||||||
|
isolated from the launch-runtime.py seam it is wired into below."""
|
||||||
|
|
||||||
|
def test_matching_capability_passes_silently(self) -> None:
|
||||||
|
VERSION_GATE.assert_activation_capability_matches(matching_capability())
|
||||||
|
# No exception is the assertion; nothing further to check.
|
||||||
|
|
||||||
|
def test_absent_capability_fails_closed_not_silent_pass(self) -> None:
|
||||||
|
with self.assertRaises(VERSION_GATE.VersionCouplingError) as raised:
|
||||||
|
VERSION_GATE.assert_activation_capability_matches(None)
|
||||||
|
message = str(raised.exception)
|
||||||
|
self.assertIn("#869", message)
|
||||||
|
self.assertIn("upgrade", message.lower())
|
||||||
|
|
||||||
|
def test_version_mismatch_message_is_actionable(self) -> None:
|
||||||
|
expected = {"name": "lease-runtime-activation", "version": 1}
|
||||||
|
mismatched = {"name": "lease-runtime-activation", "version": 2}
|
||||||
|
with self.assertRaises(VERSION_GATE.VersionCouplingError) as raised:
|
||||||
|
VERSION_GATE.assert_activation_capability_matches(mismatched, expected)
|
||||||
|
message = str(raised.exception)
|
||||||
|
self.assertIn("v2", message)
|
||||||
|
self.assertIn("v1", message)
|
||||||
|
self.assertIn("#869", message)
|
||||||
|
self.assertIn("upgrade", message.lower())
|
||||||
|
self.assertIn("version skew", message.lower())
|
||||||
|
|
||||||
|
def test_name_mismatch_fails_loud(self) -> None:
|
||||||
|
expected = {"name": "lease-runtime-activation", "version": 1}
|
||||||
|
mismatched = {"name": "some-other-capability", "version": 1}
|
||||||
|
with self.assertRaises(VERSION_GATE.VersionCouplingError) as raised:
|
||||||
|
VERSION_GATE.assert_activation_capability_matches(mismatched, expected)
|
||||||
|
message = str(raised.exception)
|
||||||
|
self.assertIn("some-other-capability", message)
|
||||||
|
self.assertIn("lease-runtime-activation", message)
|
||||||
|
self.assertIn("#869", message)
|
||||||
|
|
||||||
|
def test_reversed_drift_newer_activation_than_enforcement_expects_also_fails(self) -> None:
|
||||||
|
# A build/deploy where ACTIVATION shipped ahead of ENFORCEMENT is
|
||||||
|
# exactly as much version skew as the reverse (#828's actual shape
|
||||||
|
# was enforcement ahead of activation) — the assertion must not special
|
||||||
|
# case direction.
|
||||||
|
expected = {"name": "lease-runtime-activation", "version": 1}
|
||||||
|
newer_activation = {"name": "lease-runtime-activation", "version": 2}
|
||||||
|
with self.assertRaises(VERSION_GATE.VersionCouplingError):
|
||||||
|
VERSION_GATE.assert_activation_capability_matches(newer_activation, expected)
|
||||||
|
|
||||||
|
|
||||||
|
class ProbeActivationCapabilityTest(unittest.TestCase):
|
||||||
|
"""Coverage of the probe's command resolution and fail-closed transport
|
||||||
|
handling — never spawns a real `mosaic` process."""
|
||||||
|
|
||||||
|
def test_returns_none_when_mosaic_is_not_resolvable_on_path(self) -> None:
|
||||||
|
result = VERSION_GATE.default_probe_activation_capability(
|
||||||
|
{"PATH": "/nonexistent-bin-dir-for-869-c4-test"}
|
||||||
|
)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_override_command_is_parsed_and_the_probe_subcommand_is_not_double_appended(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
captured: list[list[str]] = []
|
||||||
|
|
||||||
|
class FakeCompleted:
|
||||||
|
returncode = 0
|
||||||
|
stdout = '{"name": "lease-runtime-activation", "version": 1}'
|
||||||
|
|
||||||
|
def fake_run(argv: list[str], **_kwargs: object) -> FakeCompleted:
|
||||||
|
captured.append(argv)
|
||||||
|
return FakeCompleted()
|
||||||
|
|
||||||
|
result = VERSION_GATE.default_probe_activation_capability(
|
||||||
|
{VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: "/fake/mosaic __lease-capability"},
|
||||||
|
run=fake_run,
|
||||||
|
)
|
||||||
|
self.assertEqual(result, {"name": "lease-runtime-activation", "version": 1})
|
||||||
|
self.assertEqual(captured, [["/fake/mosaic", "__lease-capability"]])
|
||||||
|
|
||||||
|
def test_fails_closed_on_nonzero_exit_malformed_json_and_missing_fields(self) -> None:
|
||||||
|
class NonZeroExit:
|
||||||
|
returncode = 1
|
||||||
|
stdout = '{"name": "lease-runtime-activation", "version": 1}'
|
||||||
|
|
||||||
|
class MalformedOutput:
|
||||||
|
returncode = 0
|
||||||
|
stdout = "not-json"
|
||||||
|
|
||||||
|
class MissingVersion:
|
||||||
|
returncode = 0
|
||||||
|
stdout = '{"name": "lease-runtime-activation"}'
|
||||||
|
|
||||||
|
class WrongShapeVersion:
|
||||||
|
returncode = 0
|
||||||
|
stdout = '{"name": "lease-runtime-activation", "version": "1"}'
|
||||||
|
|
||||||
|
class BooleanVersion:
|
||||||
|
# bool is a subclass of int in Python; must not be accepted as
|
||||||
|
# a version number.
|
||||||
|
returncode = 0
|
||||||
|
stdout = '{"name": "lease-runtime-activation", "version": true}'
|
||||||
|
|
||||||
|
for fake in (
|
||||||
|
NonZeroExit(),
|
||||||
|
MalformedOutput(),
|
||||||
|
MissingVersion(),
|
||||||
|
WrongShapeVersion(),
|
||||||
|
BooleanVersion(),
|
||||||
|
):
|
||||||
|
with self.subTest(stdout=fake.stdout, returncode=fake.returncode):
|
||||||
|
result = VERSION_GATE.default_probe_activation_capability(
|
||||||
|
{VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: "/fake/mosaic"},
|
||||||
|
run=lambda *_a, fake=fake, **_kw: fake,
|
||||||
|
)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_fails_closed_on_timeout_and_transport_error(self) -> None:
|
||||||
|
def timeout_run(*_args: object, **_kwargs: object) -> None:
|
||||||
|
raise subprocess.TimeoutExpired(cmd="mosaic", timeout=2.0)
|
||||||
|
|
||||||
|
def oserror_run(*_args: object, **_kwargs: object) -> None:
|
||||||
|
raise OSError("no such file or directory")
|
||||||
|
|
||||||
|
for run_fake in (timeout_run, oserror_run):
|
||||||
|
with self.subTest(run=run_fake.__name__):
|
||||||
|
result = VERSION_GATE.default_probe_activation_capability(
|
||||||
|
{VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: "/fake/mosaic"},
|
||||||
|
run=run_fake,
|
||||||
|
)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
|
||||||
|
class LaunchRuntimeVersionCouplingSeamTest(unittest.TestCase):
|
||||||
|
"""End-to-end (still fully faked) coverage of the seam as wired into
|
||||||
|
`launch-runtime.py`'s `main()` — the strongest natural enforcement point
|
||||||
|
per the C4 card, run before any broker registration."""
|
||||||
|
|
||||||
|
def _run(self, *, probe):
|
||||||
|
calls: dict[str, object] = {}
|
||||||
|
|
||||||
|
def request(_path: Path, payload: dict[str, object]) -> dict[str, object]:
|
||||||
|
calls["registered"] = True
|
||||||
|
calls["request"] = payload
|
||||||
|
return {"ok": True, "session_id": "a" * 64}
|
||||||
|
|
||||||
|
def execute(command: str, argv: list[str], environment: dict[str, str]) -> None:
|
||||||
|
calls["executed"] = (command, argv, environment)
|
||||||
|
|
||||||
|
def initialize_generation(_path: Path, _generation: int) -> None:
|
||||||
|
calls["generation_initialized"] = True
|
||||||
|
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with redirect_stderr(stderr):
|
||||||
|
result = LAUNCHER.main(
|
||||||
|
["--runtime", "claude", "--", "claude", "--print", "hello"],
|
||||||
|
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/run/test/broker.sock"},
|
||||||
|
request=request,
|
||||||
|
execute=execute,
|
||||||
|
initialize_generation=initialize_generation,
|
||||||
|
probe_activation_capability=probe,
|
||||||
|
)
|
||||||
|
return result, stderr.getvalue(), calls
|
||||||
|
|
||||||
|
def test_matching_activation_version_passes_and_the_gate_proceeds(self) -> None:
|
||||||
|
result, stderr_text, calls = self._run(probe=lambda *_a, **_kw: matching_capability())
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertEqual(stderr_text, "")
|
||||||
|
self.assertTrue(calls.get("registered"))
|
||||||
|
self.assertIn("executed", calls)
|
||||||
|
|
||||||
|
def test_version_mismatch_fails_loud_denies_and_never_registers_or_execs(self) -> None:
|
||||||
|
expected = LAUNCHER.EXPECTED_ACTIVATION_CAPABILITY
|
||||||
|
mismatched = {"name": expected["name"], "version": expected["version"] + 1}
|
||||||
|
result, stderr_text, calls = self._run(probe=lambda *_a, **_kw: mismatched)
|
||||||
|
|
||||||
|
self.assertEqual(result, LAUNCHER.EXIT_VERSION_SKEW)
|
||||||
|
self.assertNotEqual(result, 0)
|
||||||
|
self.assertIn("#869", stderr_text)
|
||||||
|
self.assertIn(f"v{mismatched['version']}", stderr_text)
|
||||||
|
self.assertIn(f"v{expected['version']}", stderr_text)
|
||||||
|
self.assertIn("upgrade", stderr_text.lower())
|
||||||
|
# Never reaches broker registration or exec — the version gate is a
|
||||||
|
# hard stop, not advisory.
|
||||||
|
self.assertNotIn("registered", calls)
|
||||||
|
self.assertNotIn("executed", calls)
|
||||||
|
|
||||||
|
def test_name_mismatch_fails_loud(self) -> None:
|
||||||
|
expected = LAUNCHER.EXPECTED_ACTIVATION_CAPABILITY
|
||||||
|
mismatched = {"name": "some-other-capability", "version": expected["version"]}
|
||||||
|
result, stderr_text, calls = self._run(probe=lambda *_a, **_kw: mismatched)
|
||||||
|
|
||||||
|
self.assertEqual(result, LAUNCHER.EXIT_VERSION_SKEW)
|
||||||
|
self.assertIn("#869", stderr_text)
|
||||||
|
self.assertIn("some-other-capability", stderr_text)
|
||||||
|
self.assertNotIn("registered", calls)
|
||||||
|
self.assertNotIn("executed", calls)
|
||||||
|
|
||||||
|
def test_absent_activation_capability_fails_closed_not_a_silent_pass(self) -> None:
|
||||||
|
result, stderr_text, calls = self._run(probe=lambda *_a, **_kw: None)
|
||||||
|
|
||||||
|
self.assertEqual(result, LAUNCHER.EXIT_VERSION_SKEW)
|
||||||
|
self.assertNotEqual(result, 0)
|
||||||
|
self.assertIn("#869", stderr_text)
|
||||||
|
self.assertNotIn("registered", calls)
|
||||||
|
self.assertNotIn("executed", calls)
|
||||||
|
|
||||||
|
def test_version_gate_runs_before_and_independently_of_broker_registration(self) -> None:
|
||||||
|
def request_must_not_be_called(*_args: object, **_kwargs: object) -> dict[str, object]:
|
||||||
|
self.fail("broker must not be contacted when activation version is mismatched")
|
||||||
|
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with redirect_stderr(stderr):
|
||||||
|
result = LAUNCHER.main(
|
||||||
|
["--runtime", "claude", "--", "claude"],
|
||||||
|
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/run/test/broker.sock"},
|
||||||
|
request=request_must_not_be_called,
|
||||||
|
probe_activation_capability=lambda *_a, **_kw: None,
|
||||||
|
)
|
||||||
|
self.assertEqual(result, LAUNCHER.EXIT_VERSION_SKEW)
|
||||||
|
|
||||||
|
def test_dedicated_exit_code_never_collides_with_usage_or_registration_codes(self) -> None:
|
||||||
|
# Distinctness guard: a version-skew denial must never be mistaken
|
||||||
|
# for the pre-existing usage error (64) or registration/exec
|
||||||
|
# fail-closed code (1) this script already owns.
|
||||||
|
self.assertNotIn(LAUNCHER.EXIT_VERSION_SKEW, (0, 1, 64))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
ENFORCEMENT_HOOK_MARKERS,
|
||||||
|
FAIL_LOUD_MESSAGE,
|
||||||
|
settingsHasEnforcementHooks,
|
||||||
|
} from '../commands/install-ordering-guard.js';
|
||||||
|
import {
|
||||||
|
runUpdatePathSettingsGuard,
|
||||||
|
runUpdateReseedFlow,
|
||||||
|
type FrameworkReseedResult,
|
||||||
|
} from './update-checker.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Red-first tests for issue #882 (b) — the `mosaic update --sync-only`
|
||||||
|
* install-ordering-guard bypass (Mos-ruled "Option C").
|
||||||
|
*
|
||||||
|
* Root cause under test: `runFrameworkReseed()` runs the package's
|
||||||
|
* install.sh with MOSAIC_SYNC_ONLY=1, which exits after the file-system
|
||||||
|
* phase, BEFORE the "Post-install tasks" step that would otherwise run
|
||||||
|
* `mosaic-link-runtime-assets` — the only place the #869 Point-1 C2
|
||||||
|
* install-ordering guard evaluated whether the lease-enforcement hooks
|
||||||
|
* (PreToolUse mutator-gate.py / Stop receipt-observer-client.py) may be
|
||||||
|
* wired into `~/.claude/settings.json`. A plain `mosaic update` therefore
|
||||||
|
* never re-evaluated that decision. These tests prove the post-reseed step
|
||||||
|
* added to close that gap (`runUpdatePathSettingsGuard`, wired into the
|
||||||
|
* `mosaic update` reseed flow via `runUpdateReseedFlow`) reuses the EXACT
|
||||||
|
* C2 guard — no forked logic — and is skipped only when `--no-reseed` means
|
||||||
|
* there was nothing to re-seed/re-link in the first place.
|
||||||
|
*
|
||||||
|
* All fixtures use temp directories — this suite never reads or writes the
|
||||||
|
* real `~/.claude/settings.json` or `~/.config/mosaic`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FIXTURE_SETTINGS = {
|
||||||
|
model: 'opus',
|
||||||
|
hooks: {
|
||||||
|
PreToolUse: [
|
||||||
|
{
|
||||||
|
matcher: '.*',
|
||||||
|
hooks: [
|
||||||
|
{
|
||||||
|
type: 'command',
|
||||||
|
command: 'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude',
|
||||||
|
timeout: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Stop: [
|
||||||
|
{
|
||||||
|
hooks: [
|
||||||
|
{
|
||||||
|
type: 'command',
|
||||||
|
command:
|
||||||
|
'python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude',
|
||||||
|
timeout: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function fixtureJson(): string {
|
||||||
|
return JSON.stringify(FIXTURE_SETTINGS, null, 2) + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runUpdatePathSettingsGuard', () => {
|
||||||
|
let root: string;
|
||||||
|
let mosaicHome: string;
|
||||||
|
let claudeHome: string;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeTemplate(): void {
|
||||||
|
root = mkdtempSync(join(tmpdir(), 'mosaic-update-settings-guard-'));
|
||||||
|
mosaicHome = join(root, 'mosaic-home');
|
||||||
|
claudeHome = join(root, 'claude-home');
|
||||||
|
mkdirSync(join(mosaicHome, 'runtime', 'claude'), { recursive: true });
|
||||||
|
writeFileSync(join(mosaicHome, 'runtime', 'claude', 'settings.json'), fixtureJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not run when there is no settings.json template to re-link', () => {
|
||||||
|
root = mkdtempSync(join(tmpdir(), 'mosaic-update-settings-guard-'));
|
||||||
|
mosaicHome = join(root, 'mosaic-home');
|
||||||
|
claudeHome = join(root, 'claude-home');
|
||||||
|
// Deliberately no runtime/claude/settings.json under mosaicHome.
|
||||||
|
|
||||||
|
const outcome = runUpdatePathSettingsGuard(mosaicHome, claudeHome);
|
||||||
|
|
||||||
|
expect(outcome.ran).toBe(false);
|
||||||
|
expect(outcome.result).toBeUndefined();
|
||||||
|
expect(existsSync(join(claudeHome, 'settings.json'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activatable=false (default, no opt-out): strips enforcement hooks and fails loud, exactly as install-time', () => {
|
||||||
|
makeTemplate();
|
||||||
|
|
||||||
|
const outcome = runUpdatePathSettingsGuard(
|
||||||
|
mosaicHome,
|
||||||
|
claudeHome,
|
||||||
|
{},
|
||||||
|
{ activatable: () => false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(outcome.ran).toBe(true);
|
||||||
|
expect(outcome.result?.exitCode).toBe(1);
|
||||||
|
expect(outcome.result?.wired).toBe(false);
|
||||||
|
expect(outcome.result?.logs).toHaveLength(1);
|
||||||
|
expect(outcome.result?.logs[0]?.level).toBe('error');
|
||||||
|
expect(outcome.result?.logs[0]?.message).toBe(FAIL_LOUD_MESSAGE);
|
||||||
|
|
||||||
|
const written = JSON.parse(readFileSync(join(claudeHome, 'settings.json'), 'utf-8')) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(settingsHasEnforcementHooks(written)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activatable=true: wires hooks normally, no strip, no logs', () => {
|
||||||
|
makeTemplate();
|
||||||
|
|
||||||
|
const outcome = runUpdatePathSettingsGuard(
|
||||||
|
mosaicHome,
|
||||||
|
claudeHome,
|
||||||
|
{},
|
||||||
|
{ activatable: () => true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(outcome.ran).toBe(true);
|
||||||
|
expect(outcome.result?.exitCode).toBe(0);
|
||||||
|
expect(outcome.result?.wired).toBe(true);
|
||||||
|
expect(outcome.result?.logs).toHaveLength(0);
|
||||||
|
|
||||||
|
const written = JSON.parse(readFileSync(join(claudeHome, 'settings.json'), 'utf-8')) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(settingsHasEnforcementHooks(written)).toBe(true);
|
||||||
|
expect(written).toEqual(FIXTURE_SETTINGS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activatable=false + --allow-inactive-enforcement: wires hooks anyway with a loud warning', () => {
|
||||||
|
makeTemplate();
|
||||||
|
|
||||||
|
const outcome = runUpdatePathSettingsGuard(
|
||||||
|
mosaicHome,
|
||||||
|
claudeHome,
|
||||||
|
{ allowInactiveEnforcement: true },
|
||||||
|
{ activatable: () => false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(outcome.ran).toBe(true);
|
||||||
|
expect(outcome.result?.exitCode).toBe(0);
|
||||||
|
expect(outcome.result?.wired).toBe(true);
|
||||||
|
expect(outcome.result?.logs).toHaveLength(1);
|
||||||
|
expect(outcome.result?.logs[0]?.level).toBe('warn');
|
||||||
|
expect(outcome.result?.logs[0]?.message).toMatch(/WITHOUT confirmed activation/);
|
||||||
|
|
||||||
|
const written = JSON.parse(readFileSync(join(claudeHome, 'settings.json'), 'utf-8')) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(settingsHasEnforcementHooks(written)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never touches the real home directory settings path used by this test file', () => {
|
||||||
|
// Sanity guard for the suite itself.
|
||||||
|
makeTemplate();
|
||||||
|
expect(mosaicHome).toContain('mosaic-update-settings-guard-');
|
||||||
|
expect(claudeHome).toContain('mosaic-update-settings-guard-');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runUpdateReseedFlow (the `mosaic update` post-reseed guard wiring, #882 (b))', () => {
|
||||||
|
const okReseed: FrameworkReseedResult = { ok: true };
|
||||||
|
|
||||||
|
it('--no-reseed: the reseed is never attempted and the settings guard is never invoked', () => {
|
||||||
|
const doReseed = vi.fn(() => okReseed);
|
||||||
|
const doGuard = vi.fn(() => ({ ran: true }));
|
||||||
|
const doRefresh = vi.fn(() => ({ refreshed: [], ok: true }));
|
||||||
|
const doReadRoster = vi.fn(() => []);
|
||||||
|
const log = vi.fn();
|
||||||
|
const warnLog = vi.fn();
|
||||||
|
const errorLog = vi.fn();
|
||||||
|
|
||||||
|
const result = runUpdateReseedFlow(
|
||||||
|
'should never be printed',
|
||||||
|
{ reseed: false },
|
||||||
|
{
|
||||||
|
runFrameworkReseed: doReseed,
|
||||||
|
runUpdatePathSettingsGuard: doGuard,
|
||||||
|
refreshActiveFleetUnits: doRefresh,
|
||||||
|
readRosterAgentNames: doReadRoster,
|
||||||
|
log,
|
||||||
|
warnLog,
|
||||||
|
errorLog,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.attempted).toBe(false);
|
||||||
|
expect(doReseed).not.toHaveBeenCalled();
|
||||||
|
expect(doGuard).not.toHaveBeenCalled();
|
||||||
|
expect(log).not.toHaveBeenCalled();
|
||||||
|
expect(errorLog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reseed ran + activatable=false: the guard fires (hooks stripped) and the fail-loud message is surfaced, not swallowed', () => {
|
||||||
|
const doReseed = vi.fn(() => okReseed);
|
||||||
|
const doGuard = vi.fn(() => ({
|
||||||
|
ran: true,
|
||||||
|
result: {
|
||||||
|
json: '{}',
|
||||||
|
wired: false,
|
||||||
|
exitCode: 1 as const,
|
||||||
|
logs: [{ level: 'error' as const, message: FAIL_LOUD_MESSAGE }],
|
||||||
|
destWritten: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const doRefresh = vi.fn(() => ({ refreshed: [], ok: true }));
|
||||||
|
const doReadRoster = vi.fn(() => []);
|
||||||
|
const log = vi.fn();
|
||||||
|
const warnLog = vi.fn();
|
||||||
|
const errorLog = vi.fn();
|
||||||
|
|
||||||
|
const result = runUpdateReseedFlow(
|
||||||
|
'Re-seeding…',
|
||||||
|
{ reseed: true },
|
||||||
|
{
|
||||||
|
runFrameworkReseed: doReseed,
|
||||||
|
runUpdatePathSettingsGuard: doGuard,
|
||||||
|
refreshActiveFleetUnits: doRefresh,
|
||||||
|
readRosterAgentNames: doReadRoster,
|
||||||
|
log,
|
||||||
|
warnLog,
|
||||||
|
errorLog,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.attempted).toBe(true);
|
||||||
|
expect(doReseed).toHaveBeenCalledTimes(1);
|
||||||
|
expect(doGuard).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.settingsGuard?.result?.exitCode).toBe(1);
|
||||||
|
// The guard's fail-loud message must reach the operator (stderr), never swallowed.
|
||||||
|
expect(errorLog).toHaveBeenCalledWith(FAIL_LOUD_MESSAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reseed ran + activatable=true: the guard wires hooks with no error output', () => {
|
||||||
|
const doReseed = vi.fn(() => okReseed);
|
||||||
|
const doGuard = vi.fn(() => ({
|
||||||
|
ran: true,
|
||||||
|
result: {
|
||||||
|
json: '{}',
|
||||||
|
wired: true,
|
||||||
|
exitCode: 0 as const,
|
||||||
|
logs: [],
|
||||||
|
destWritten: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const doRefresh = vi.fn(() => ({ refreshed: [], ok: true }));
|
||||||
|
const doReadRoster = vi.fn(() => []);
|
||||||
|
const log = vi.fn();
|
||||||
|
const warnLog = vi.fn();
|
||||||
|
const errorLog = vi.fn();
|
||||||
|
|
||||||
|
const result = runUpdateReseedFlow(
|
||||||
|
'Re-seeding…',
|
||||||
|
{ reseed: true },
|
||||||
|
{
|
||||||
|
runFrameworkReseed: doReseed,
|
||||||
|
runUpdatePathSettingsGuard: doGuard,
|
||||||
|
refreshActiveFleetUnits: doRefresh,
|
||||||
|
readRosterAgentNames: doReadRoster,
|
||||||
|
log,
|
||||||
|
warnLog,
|
||||||
|
errorLog,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.attempted).toBe(true);
|
||||||
|
expect(result.settingsGuard?.result?.exitCode).toBe(0);
|
||||||
|
expect(errorLog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('threads --allow-inactive-enforcement through to the settings guard', () => {
|
||||||
|
const doReseed = vi.fn(() => okReseed);
|
||||||
|
const doGuard = vi.fn(() => ({
|
||||||
|
ran: true,
|
||||||
|
result: {
|
||||||
|
json: '{}',
|
||||||
|
wired: true,
|
||||||
|
exitCode: 0 as const,
|
||||||
|
logs: [{ level: 'warn' as const, message: 'opt-out warning' }],
|
||||||
|
destWritten: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const doRefresh = vi.fn(() => ({ refreshed: [], ok: true }));
|
||||||
|
const doReadRoster = vi.fn(() => []);
|
||||||
|
const warnLog = vi.fn();
|
||||||
|
|
||||||
|
runUpdateReseedFlow(
|
||||||
|
'Re-seeding…',
|
||||||
|
{ reseed: true, allowInactiveEnforcement: true },
|
||||||
|
{
|
||||||
|
runFrameworkReseed: doReseed,
|
||||||
|
runUpdatePathSettingsGuard: doGuard,
|
||||||
|
refreshActiveFleetUnits: doRefresh,
|
||||||
|
readRosterAgentNames: doReadRoster,
|
||||||
|
log: vi.fn(),
|
||||||
|
warnLog,
|
||||||
|
errorLog: vi.fn(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(doGuard).toHaveBeenCalledWith(undefined, undefined, {
|
||||||
|
allowInactiveEnforcement: true,
|
||||||
|
});
|
||||||
|
expect(warnLog).toHaveBeenCalledWith('opt-out warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reseed failure: the settings guard is not invoked (nothing was re-seeded to re-link)', () => {
|
||||||
|
const doReseed = vi.fn(
|
||||||
|
() => ({ ok: false, reason: 'installer not found' }) as FrameworkReseedResult,
|
||||||
|
);
|
||||||
|
const doGuard = vi.fn(() => ({ ran: true }));
|
||||||
|
const doRefresh = vi.fn(() => ({ refreshed: [], ok: true }));
|
||||||
|
const doReadRoster = vi.fn(() => []);
|
||||||
|
const errorLog = vi.fn();
|
||||||
|
|
||||||
|
const result = runUpdateReseedFlow(
|
||||||
|
'Re-seeding…',
|
||||||
|
{ reseed: true },
|
||||||
|
{
|
||||||
|
runFrameworkReseed: doReseed,
|
||||||
|
runUpdatePathSettingsGuard: doGuard,
|
||||||
|
refreshActiveFleetUnits: doRefresh,
|
||||||
|
readRosterAgentNames: doReadRoster,
|
||||||
|
log: vi.fn(),
|
||||||
|
warnLog: vi.fn(),
|
||||||
|
errorLog,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.attempted).toBe(true);
|
||||||
|
expect(result.settingsGuard).toBeUndefined();
|
||||||
|
expect(doGuard).not.toHaveBeenCalled();
|
||||||
|
expect(errorLog).toHaveBeenCalledWith(expect.stringContaining('Framework re-seed skipped'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('end-to-end (real runUpdatePathSettingsGuard, real temp files): reseed ok + activatable=false strips hooks in the live settings.json path', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'mosaic-update-reseed-flow-e2e-'));
|
||||||
|
try {
|
||||||
|
const mosaicHome = join(root, 'mosaic-home');
|
||||||
|
const claudeHome = join(root, 'claude-home');
|
||||||
|
mkdirSync(join(mosaicHome, 'runtime', 'claude'), { recursive: true });
|
||||||
|
writeFileSync(join(mosaicHome, 'runtime', 'claude', 'settings.json'), fixtureJson());
|
||||||
|
// Pre-existing (stale, install-time) settings.json still carrying the
|
||||||
|
// enforcement hooks — this is the exact state #882 (b) left behind.
|
||||||
|
mkdirSync(claudeHome, { recursive: true });
|
||||||
|
writeFileSync(join(claudeHome, 'settings.json'), fixtureJson());
|
||||||
|
|
||||||
|
const errorLog = vi.fn();
|
||||||
|
const result = runUpdateReseedFlow(
|
||||||
|
'Re-seeding…',
|
||||||
|
{ reseed: true },
|
||||||
|
{
|
||||||
|
runFrameworkReseed: () => okReseed,
|
||||||
|
runUpdatePathSettingsGuard: (mh, ch, options, deps) =>
|
||||||
|
// Exercise the REAL function (imported above), pointed at temp dirs,
|
||||||
|
// with the activation probe faked to prove this is not a live-host test.
|
||||||
|
runUpdatePathSettingsGuardWithFakeActivation(
|
||||||
|
mh ?? mosaicHome,
|
||||||
|
ch ?? claudeHome,
|
||||||
|
options,
|
||||||
|
deps,
|
||||||
|
),
|
||||||
|
refreshActiveFleetUnits: () => ({ refreshed: [], ok: true }),
|
||||||
|
readRosterAgentNames: () => [],
|
||||||
|
log: vi.fn(),
|
||||||
|
warnLog: vi.fn(),
|
||||||
|
errorLog,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.settingsGuard?.result?.exitCode).toBe(1);
|
||||||
|
const written = JSON.parse(
|
||||||
|
readFileSync(join(claudeHome, 'settings.json'), 'utf-8'),
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
expect(settingsHasEnforcementHooks(written)).toBe(false);
|
||||||
|
expect(errorLog).toHaveBeenCalledWith(FAIL_LOUD_MESSAGE);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function runUpdatePathSettingsGuardWithFakeActivation(
|
||||||
|
mosaicHome: string,
|
||||||
|
claudeHome: string,
|
||||||
|
options: Parameters<typeof runUpdatePathSettingsGuard>[2],
|
||||||
|
_deps: Parameters<typeof runUpdatePathSettingsGuard>[3],
|
||||||
|
): ReturnType<typeof runUpdatePathSettingsGuard> {
|
||||||
|
return runUpdatePathSettingsGuard(mosaicHome, claudeHome, options, { activatable: () => false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanity check: the enforcement markers this suite exercises must match the
|
||||||
|
* ones the C2 guard (`install-ordering-guard.ts`) actually looks for, so a
|
||||||
|
* drift in either module's marker strings would fail this suite loudly
|
||||||
|
* rather than silently passing on the wrong hooks.
|
||||||
|
*/
|
||||||
|
describe('marker parity with the C2 guard', () => {
|
||||||
|
it('the fixture uses the same marker commands the guard matches on', () => {
|
||||||
|
const preToolUse = FIXTURE_SETTINGS.hooks.PreToolUse[0]?.hooks[0]?.command ?? '';
|
||||||
|
const stop = FIXTURE_SETTINGS.hooks.Stop[0]?.hooks[0]?.command ?? '';
|
||||||
|
expect(preToolUse).toContain(ENFORCEMENT_HOOK_MARKERS.preToolUse);
|
||||||
|
expect(stop).toContain(ENFORCEMENT_HOOK_MARKERS.stop);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,6 +44,12 @@ import {
|
|||||||
readRegularFileSecure,
|
readRegularFileSecure,
|
||||||
} from '../fleet/secure-file.js';
|
} from '../fleet/secure-file.js';
|
||||||
import { getDefaultSkillPaths, syncClaudeSkills, type SkillSyncResult } from '../commands/skill.js';
|
import { getDefaultSkillPaths, syncClaudeSkills, type SkillSyncResult } from '../commands/skill.js';
|
||||||
|
import {
|
||||||
|
runInstallOrderingGuard,
|
||||||
|
type InstallOrderingGuardDeps,
|
||||||
|
type InstallOrderingGuardOptions,
|
||||||
|
type RunInstallOrderingGuardResult,
|
||||||
|
} from '../commands/install-ordering-guard.js';
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -908,6 +914,175 @@ export function runFrameworkReseed(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Post-reseed install-ordering guard (#882, Point-2 precondition) ────────
|
||||||
|
//
|
||||||
|
// Root cause (restated): `runFrameworkReseed` above runs the package's
|
||||||
|
// install.sh with MOSAIC_SYNC_ONLY=1, which — by design (see install.sh) —
|
||||||
|
// exits after the file-system phase, BEFORE the "Post-install tasks" step
|
||||||
|
// that runs `mosaic-link-runtime-assets`. That script is where the #869
|
||||||
|
// Point-1 C2 install-ordering guard (`runInstallOrderingGuard`,
|
||||||
|
// `packages/mosaic/src/commands/install-ordering-guard.ts`) decides whether
|
||||||
|
// the lease-enforcement hooks (PreToolUse mutator-gate.py / Stop
|
||||||
|
// receipt-observer-client.py) get wired into `~/.claude/settings.json`. A
|
||||||
|
// plain `mosaic update` reseed therefore never re-evaluated that wiring
|
||||||
|
// decision against current activation state — the bypass this closes.
|
||||||
|
//
|
||||||
|
// `runUpdatePathSettingsGuard` re-applies the EXACT SAME guard (no forked
|
||||||
|
// logic) against the MANAGED settings.json template the reseed just
|
||||||
|
// refreshed (`<mosaicHome>/runtime/claude/settings.json`) and the live
|
||||||
|
// `<claudeHome>/settings.json` — mirroring `copy_claude_settings_guarded`'s
|
||||||
|
// src/dest pair in `mosaic-link-runtime-assets`.
|
||||||
|
|
||||||
|
export interface UpdatePathSettingsGuardResult {
|
||||||
|
/** False when there is no settings.json template on disk to re-link (e.g. a
|
||||||
|
* framework layout that predates runtime/claude/settings.json) — nothing to
|
||||||
|
* guard, so the guard did not run. */
|
||||||
|
ran: boolean;
|
||||||
|
result?: RunInstallOrderingGuardResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runUpdatePathSettingsGuard(
|
||||||
|
mosaicHome = join(homedir(), '.config', 'mosaic'),
|
||||||
|
claudeHome = process.env['CLAUDE_HOME'] ?? join(homedir(), '.claude'),
|
||||||
|
options: InstallOrderingGuardOptions = {},
|
||||||
|
deps: InstallOrderingGuardDeps = {},
|
||||||
|
): UpdatePathSettingsGuardResult {
|
||||||
|
const src = join(mosaicHome, 'runtime', 'claude', 'settings.json');
|
||||||
|
if (!existsSync(src)) {
|
||||||
|
return { ran: false };
|
||||||
|
}
|
||||||
|
const dest = join(claudeHome, 'settings.json');
|
||||||
|
return { ran: true, result: runInstallOrderingGuard(src, dest, options, deps) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── update-reseed flow (extracted for testability; called from cli.ts) ────
|
||||||
|
//
|
||||||
|
// Everything `mosaic update`'s `.action()` does once it has decided a reseed
|
||||||
|
// should happen (both call sites already gate on `opts.reseed !== false`
|
||||||
|
// before invoking this). Extracted out of cli.ts so the post-reseed guard
|
||||||
|
// wiring (#882 (b)) — and the `--no-reseed` short-circuit — are directly unit
|
||||||
|
// testable with injected fakes, matching the existing update-checker
|
||||||
|
// conventions (see update-checker.reseed.spec.ts).
|
||||||
|
|
||||||
|
export interface UpdateReseedFlowOptions {
|
||||||
|
/** Mirrors the CLI's `--no-reseed` flag (commander sets `reseed: false`
|
||||||
|
* when passed). `false` is a pure no-op: nothing is reseeded and the
|
||||||
|
* post-reseed settings guard is not invoked either — there is nothing to
|
||||||
|
* re-link. */
|
||||||
|
reseed?: boolean;
|
||||||
|
relaunch?: boolean;
|
||||||
|
/** Threads `--allow-inactive-enforcement` to the post-reseed settings
|
||||||
|
* guard, identically to the install path (see install-ordering-guard.ts).
|
||||||
|
* Never sourced from an environment variable — explicit per-invocation
|
||||||
|
* opt-out only. */
|
||||||
|
allowInactiveEnforcement?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateReseedFlowDeps {
|
||||||
|
runFrameworkReseed?: typeof runFrameworkReseed;
|
||||||
|
runUpdatePathSettingsGuard?: typeof runUpdatePathSettingsGuard;
|
||||||
|
refreshActiveFleetUnits?: typeof refreshActiveFleetUnits;
|
||||||
|
readRosterAgentNames?: typeof readRosterAgentNames;
|
||||||
|
execSync?: typeof execSync;
|
||||||
|
log?: (message: string) => void;
|
||||||
|
warnLog?: (message: string) => void;
|
||||||
|
errorLog?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateReseedFlowResult {
|
||||||
|
/** Whether a reseed was actually attempted (false only for `--no-reseed`). */
|
||||||
|
attempted: boolean;
|
||||||
|
reseed?: FrameworkReseedResult;
|
||||||
|
settingsGuard?: UpdatePathSettingsGuardResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runUpdateReseedFlow(
|
||||||
|
reason: string,
|
||||||
|
options: UpdateReseedFlowOptions = {},
|
||||||
|
deps: UpdateReseedFlowDeps = {},
|
||||||
|
): UpdateReseedFlowResult {
|
||||||
|
if (options.reseed === false) {
|
||||||
|
// Nothing to re-seed, and therefore nothing to re-link/guard either.
|
||||||
|
return { attempted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = deps.log ?? console.log;
|
||||||
|
const warnLog = deps.warnLog ?? console.warn;
|
||||||
|
const errorLog = deps.errorLog ?? console.error;
|
||||||
|
const doReseed = deps.runFrameworkReseed ?? runFrameworkReseed;
|
||||||
|
const doGuard = deps.runUpdatePathSettingsGuard ?? runUpdatePathSettingsGuard;
|
||||||
|
const doRefresh = deps.refreshActiveFleetUnits ?? refreshActiveFleetUnits;
|
||||||
|
const doReadRoster = deps.readRosterAgentNames ?? readRosterAgentNames;
|
||||||
|
const exec = deps.execSync ?? execSync;
|
||||||
|
|
||||||
|
log(reason);
|
||||||
|
const reseed = doReseed();
|
||||||
|
if (!reseed.ok) {
|
||||||
|
errorLog(
|
||||||
|
`\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
|
||||||
|
' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
|
||||||
|
'(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
|
||||||
|
);
|
||||||
|
return { attempted: true, reseed };
|
||||||
|
}
|
||||||
|
log('✔ Framework re-seeded.');
|
||||||
|
if (reseed.skillSyncError) {
|
||||||
|
errorLog(` ⚠ Claude skill reconciliation skipped: ${reseed.skillSyncError}`);
|
||||||
|
}
|
||||||
|
const skillConflicts = reseed.skillSync?.conflicts ?? [];
|
||||||
|
const skillChanges =
|
||||||
|
(reseed.skillSync?.registered.length ?? 0) + (reseed.skillSync?.repaired.length ?? 0);
|
||||||
|
if (skillChanges > 0) {
|
||||||
|
log(`✔ Registered ${skillChanges.toString()} Mosaic skill(s) with Claude Code.`);
|
||||||
|
}
|
||||||
|
for (const conflict of skillConflicts) {
|
||||||
|
errorLog(` ⚠ Skill registration skipped for ${conflict.name}: ${conflict.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #882 (b): re-apply the install-ordering guard (C2) to the MANAGED
|
||||||
|
// settings.json the reseed just refreshed. install.sh's sync-only mode
|
||||||
|
// never reaches the post-install step that would otherwise do this, so
|
||||||
|
// `mosaic update` must do it itself — closing the bypass for every update
|
||||||
|
// path. Never swallow the guard's fail-loud/opt-out output on this path.
|
||||||
|
const settingsGuard = doGuard(undefined, undefined, {
|
||||||
|
allowInactiveEnforcement: options.allowInactiveEnforcement === true,
|
||||||
|
});
|
||||||
|
if (settingsGuard.ran && settingsGuard.result) {
|
||||||
|
for (const line of settingsGuard.result.logs) {
|
||||||
|
(line.level === 'error' ? errorLog : warnLog)(line.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
|
||||||
|
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
|
||||||
|
const units = doRefresh();
|
||||||
|
if (units.refreshed.length > 0) {
|
||||||
|
log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`);
|
||||||
|
}
|
||||||
|
const agents = doReadRoster();
|
||||||
|
if (agents.length > 0) {
|
||||||
|
if (options.relaunch) {
|
||||||
|
log(`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`);
|
||||||
|
for (const restart of buildRelaunchCommands(agents)) {
|
||||||
|
try {
|
||||||
|
exec(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
|
||||||
|
} catch {
|
||||||
|
errorLog(` ⚠ failed to restart agent — run: ${restart.join(' ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log('✔ Agents relaunched.');
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
`\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` +
|
||||||
|
'Restart them to activate the update:\n mosaic update --relaunch ' +
|
||||||
|
'(or: mosaic fleet restart <agent>)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { attempted: true, reseed, settingsGuard };
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Framework drift detection (#642) ────────────────────────────────────────
|
// ─── Framework drift detection (#642) ────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// `mosaic update` only re-seeds the framework when the @mosaicstack/mosaic
|
// `mosaic update` only re-seeds the framework when the @mosaicstack/mosaic
|
||||||
|
|||||||
@@ -13,22 +13,37 @@ import {
|
|||||||
type SkillSyncResult as ClaudeSkillSyncResult,
|
type SkillSyncResult as ClaudeSkillSyncResult,
|
||||||
} from '../commands/skill.js';
|
} from '../commands/skill.js';
|
||||||
|
|
||||||
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
|
/**
|
||||||
|
* Link runtime assets. Returns a warning string when the install-ordering
|
||||||
|
* guard (#869 Point-1 C2) reported a degraded outcome — i.e. the
|
||||||
|
* lease-enforcement hooks were NOT wired into ~/.claude/settings.json because
|
||||||
|
* this host could not confirm it can activate them — so the caller can
|
||||||
|
* surface it via `p.warn(...)` instead of it being swallowed by `stdio:
|
||||||
|
* 'pipe'`. Non-fatal either way: the wizard always continues.
|
||||||
|
*/
|
||||||
|
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): string | undefined {
|
||||||
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
||||||
if (existsSync(script)) {
|
if (!existsSync(script)) return undefined;
|
||||||
try {
|
try {
|
||||||
spawnSync('bash', [script], {
|
const result = spawnSync('bash', [script], {
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
|
encoding: 'utf-8',
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
...(skipClaudeHooks ? { MOSAIC_SKIP_CLAUDE_HOOKS: '1' } : {}),
|
...(skipClaudeHooks ? { MOSAIC_SKIP_CLAUDE_HOOKS: '1' } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const stderr = (result.stderr ?? '').trim();
|
||||||
|
return (
|
||||||
|
stderr || 'Runtime asset linking reported a non-zero exit (see mosaic doctor for details).'
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Non-fatal: wizard continues
|
// Non-fatal: wizard continues
|
||||||
}
|
}
|
||||||
}
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SyncSkillsResult {
|
interface SyncSkillsResult {
|
||||||
@@ -201,7 +216,7 @@ export async function finalizeStage(
|
|||||||
// copied into ~/.claude/ while still linking the other runtime files.
|
// copied into ~/.claude/ while still linking the other runtime files.
|
||||||
spin.update('Linking runtime assets...');
|
spin.update('Linking runtime assets...');
|
||||||
const skipClaudeHooks = state.hooks?.accepted === false;
|
const skipClaudeHooks = state.hooks?.accepted === false;
|
||||||
linkRuntimeAssets(state.mosaicHome, skipClaudeHooks);
|
const linkWarning = linkRuntimeAssets(state.mosaicHome, skipClaudeHooks);
|
||||||
|
|
||||||
// 4. Sync skills (only installs the user-selected subset)
|
// 4. Sync skills (only installs the user-selected subset)
|
||||||
let skillsResult: SyncSkillsResult = { success: true, installedCount: 0 };
|
let skillsResult: SyncSkillsResult = { success: true, installedCount: 0 };
|
||||||
@@ -236,6 +251,10 @@ export async function finalizeStage(
|
|||||||
|
|
||||||
spin.stop('Installation complete');
|
spin.stop('Installation complete');
|
||||||
|
|
||||||
|
// Surface the install-ordering guard's outcome (#869 Point-1 C2) — never
|
||||||
|
// silent, even though the wizard continues either way.
|
||||||
|
if (linkWarning) p.warn(linkWarning);
|
||||||
|
|
||||||
// Report skill install failure clearly (non-fatal but user should know)
|
// Report skill install failure clearly (non-fatal but user should know)
|
||||||
if (!skillsResult.success && skillsResult.failureReason) {
|
if (!skillsResult.success && skillsResult.failureReason) {
|
||||||
p.warn(skillsResult.failureReason);
|
p.warn(skillsResult.failureReason);
|
||||||
|
|||||||
37
pnpm-lock.yaml
generated
37
pnpm-lock.yaml
generated
@@ -372,6 +372,21 @@ importers:
|
|||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||||
|
|
||||||
|
packages/comms:
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.0.0
|
||||||
|
version: 22.19.15
|
||||||
|
'@vitest/coverage-v8':
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.1.9(vitest@2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1))
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.8.0
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||||
|
|
||||||
packages/config:
|
packages/config:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mosaicstack/memory':
|
'@mosaicstack/memory':
|
||||||
@@ -796,6 +811,28 @@ importers:
|
|||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||||
|
|
||||||
|
tools/matrix-presence-harness:
|
||||||
|
dependencies:
|
||||||
|
'@mosaicstack/appservice':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/appservice
|
||||||
|
'@mosaicstack/comms':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/comms
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.0.0
|
||||||
|
version: 22.19.15
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.19.0
|
||||||
|
version: 4.21.0
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.8.0
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@agentclientprotocol/sdk@0.17.0':
|
'@agentclientprotocol/sdk@0.17.0':
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ packages:
|
|||||||
- 'apps/*'
|
- 'apps/*'
|
||||||
- 'packages/*'
|
- 'packages/*'
|
||||||
- 'plugins/*'
|
- 'plugins/*'
|
||||||
|
- 'tools/matrix-presence-harness'
|
||||||
|
|
||||||
ignoredBuiltDependencies:
|
ignoredBuiltDependencies:
|
||||||
- '@nestjs/core'
|
- '@nestjs/core'
|
||||||
|
|||||||
43
tools/matrix-presence-harness/README.md
Normal file
43
tools/matrix-presence-harness/README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# tools/matrix-presence-harness — RFC-001 P1 validation (A1–A5)
|
||||||
|
|
||||||
|
> **DEV-ONLY. LOCAL SANDBOX.** Drives the local `infra/matrix` Synapse only.
|
||||||
|
> Never point it at live infra.
|
||||||
|
|
||||||
|
The dev validation harness for the presence slice. It is the **minimal P1
|
||||||
|
provisioner** + an end-to-end demo that proves the acceptance criteria against
|
||||||
|
a real Synapse.
|
||||||
|
|
||||||
|
## Pieces
|
||||||
|
|
||||||
|
- `provision.ts` — the **minimal provisioner**: registers ≥3 agent MXIDs, creates
|
||||||
|
the fleet presence room, joins the agents. Reuses the existing tested
|
||||||
|
`@mosaicstack/appservice` intent library (register / createRoom / join). It is
|
||||||
|
deliberately **not** the P2 appservice (no auto-enroll / taxonomy / token minting).
|
||||||
|
- `agent-proc.ts` — one presence agent as its **own OS process** (via
|
||||||
|
`@mosaicstack/comms`): joins the fleet room and heartbeats `mosaic.presence`.
|
||||||
|
Standalone so the harness can `SIGKILL` it for a true hard-kill (A3).
|
||||||
|
- `validate.ts` — provisions, spawns the agents, then asserts:
|
||||||
|
- **A2** ≥3 agents show **online** in the fleet room.
|
||||||
|
- **A3** a `SIGKILL`'d agent flips to **offline within `dark_threshold`**,
|
||||||
|
deterministically (heartbeat-age based, not native-presence timeout), while
|
||||||
|
survivors stay online.
|
||||||
|
- **A4** prints the human-readable fleet liveness board.
|
||||||
|
- `run.sh` — wires dev secrets + env and runs `validate.ts` over the self-signed
|
||||||
|
TLS endpoint (A1). Exits non-zero on any failed assertion.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
../../infra/matrix/dev-up.sh # bring Synapse up first
|
||||||
|
./run.sh # provision + validate (A2/A3/A4)
|
||||||
|
```
|
||||||
|
|
||||||
|
Tunables (env): `HEARTBEAT_INTERVAL_MS` (1000), `MISS_TOLERANCE` (2),
|
||||||
|
`DARK_THRESHOLD_MS` (6000), `AGENT_SLUGS` (`alpha,bravo,charlie`),
|
||||||
|
`VICTIM_SLUG` (`charlie`), `MATRIX_CS_URL` (defaults to the TLS endpoint).
|
||||||
|
|
||||||
|
## Note on `NODE_TLS_REJECT_UNAUTHORIZED=0`
|
||||||
|
|
||||||
|
`run.sh` sets this **only** because the dev homeserver uses a self-signed cert.
|
||||||
|
It is a dev convenience for exercising the SDK over TLS and must never be used
|
||||||
|
against a real CA-issued endpoint.
|
||||||
70
tools/matrix-presence-harness/agent-proc.ts
Normal file
70
tools/matrix-presence-harness/agent-proc.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* tools/matrix-presence-harness/agent-proc.ts
|
||||||
|
*
|
||||||
|
* *** DEV harness — LOCAL SANDBOX ONLY. ***
|
||||||
|
*
|
||||||
|
* Runs ONE presence agent as its own OS process: joins the fleet presence
|
||||||
|
* room and heartbeats `mosaic.presence` via @mosaicstack/comms (RFC-001 P1).
|
||||||
|
* Kept as a standalone process so the validation harness can `kill -9` it to
|
||||||
|
* prove A3 (hard-kill -> offline within dark_threshold) against a real crash,
|
||||||
|
* not a graceful shutdown.
|
||||||
|
*
|
||||||
|
* Auth in DEV is Application-Service masquerade: the process presents the
|
||||||
|
* as_token and acts as its own @agent-<slug> MXID. (Per-agent minted tokens
|
||||||
|
* are P2, RFC-001 §2.2/§8.)
|
||||||
|
*
|
||||||
|
* ESM / NodeNext: .js import extensions.
|
||||||
|
*/
|
||||||
|
import { MinimalMatrixClient, PresenceAgent, type LivenessPolicy } from '@mosaicstack/comms';
|
||||||
|
|
||||||
|
const env = (k: string, fallback?: string): string => {
|
||||||
|
const v = process.env[k] ?? fallback;
|
||||||
|
if (v === undefined) throw new Error(`missing env ${k}`);
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const homeserverUrl = env('MATRIX_CS_URL');
|
||||||
|
const asToken = env('MOSAIC_AS_TOKEN');
|
||||||
|
const serverName = env('MATRIX_SERVER_NAME');
|
||||||
|
const slug = env('AGENT_SLUG');
|
||||||
|
const roomId = env('FLEET_ROOM_ID');
|
||||||
|
const intervalMs = Number(env('HEARTBEAT_INTERVAL_MS', '1000'));
|
||||||
|
|
||||||
|
const mxid = `@agent-${slug}:${serverName}`;
|
||||||
|
const policy: LivenessPolicy = {
|
||||||
|
heartbeatIntervalMs: intervalMs,
|
||||||
|
missTolerance: Number(env('MISS_TOLERANCE', '2')),
|
||||||
|
darkThresholdMs: Number(env('DARK_THRESHOLD_MS', '6000')),
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new MinimalMatrixClient({
|
||||||
|
homeserverUrl,
|
||||||
|
accessToken: asToken,
|
||||||
|
actAsUserId: mxid,
|
||||||
|
});
|
||||||
|
const agent = new PresenceAgent({
|
||||||
|
client,
|
||||||
|
agent: { mxid, slug, harness: 'claude-code' },
|
||||||
|
roomId,
|
||||||
|
intervalMs,
|
||||||
|
policy,
|
||||||
|
onError: (err) => console.error(`[${slug}] heartbeat error:`, (err as Error).message),
|
||||||
|
});
|
||||||
|
|
||||||
|
await agent.connect();
|
||||||
|
agent.start();
|
||||||
|
console.log(`[${slug}] pid=${process.pid} mxid=${mxid} heartbeating every ${intervalMs}ms`);
|
||||||
|
|
||||||
|
// Graceful stop only on SIGTERM; the A3 test uses SIGKILL (no cleanup runs).
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
void agent.stop().finally(() => process.exit(0));
|
||||||
|
});
|
||||||
|
// Keep the event loop alive indefinitely.
|
||||||
|
setInterval(() => {}, 1 << 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('agent-proc fatal:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
24
tools/matrix-presence-harness/package.json
Normal file
24
tools/matrix-presence-harness/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@mosaicstack/matrix-presence-harness",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"description": "DEV-ONLY validation harness for RFC-001 P1 (Matrix presence). Not shipped.",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --noEmit",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run --passWithNoTests",
|
||||||
|
"validate": "./run.sh"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mosaicstack/appservice": "workspace:*",
|
||||||
|
"@mosaicstack/comms": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.8.0",
|
||||||
|
"vitest": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
89
tools/matrix-presence-harness/provision.ts
Normal file
89
tools/matrix-presence-harness/provision.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* tools/matrix-presence-harness/provision.ts
|
||||||
|
*
|
||||||
|
* *** DEV harness — LOCAL SANDBOX ONLY. ***
|
||||||
|
*
|
||||||
|
* The MINIMAL P1 provisioner (RFC-001 §10 P1): its ONLY job is to register a
|
||||||
|
* few agent MXIDs, create the fleet presence room, and join the agents. It is
|
||||||
|
* deliberately NOT the P2 appservice (no auto-enroll, no room taxonomy, no
|
||||||
|
* token minting, no introductions).
|
||||||
|
*
|
||||||
|
* It reuses the existing, tested `@mosaicstack/appservice` core library
|
||||||
|
* (AppserviceIntent: register / createRoom / join over the AS API) rather than
|
||||||
|
* reinventing Matrix plumbing.
|
||||||
|
*
|
||||||
|
* ESM / NodeNext: .js import extensions.
|
||||||
|
*/
|
||||||
|
import { AppserviceIntent, type AppserviceConfig } from '@mosaicstack/appservice';
|
||||||
|
|
||||||
|
export interface ProvisionOptions {
|
||||||
|
homeserverUrl: string;
|
||||||
|
serverName: string;
|
||||||
|
asToken: string;
|
||||||
|
hsToken: string;
|
||||||
|
agentSlugs: string[];
|
||||||
|
fleetAlias?: string; // localpart, default "mosaic-fleet"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProvisionResult {
|
||||||
|
roomId: string;
|
||||||
|
senderUserId: string;
|
||||||
|
agents: { slug: string; mxid: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register agents, create the fleet presence room, join everyone. Idempotent. */
|
||||||
|
export async function provision(opts: ProvisionOptions): Promise<ProvisionResult> {
|
||||||
|
const cfg: AppserviceConfig = {
|
||||||
|
homeserverUrl: opts.homeserverUrl,
|
||||||
|
domain: opts.serverName,
|
||||||
|
asToken: opts.asToken,
|
||||||
|
hsToken: opts.hsToken,
|
||||||
|
};
|
||||||
|
const intent = new AppserviceIntent(cfg);
|
||||||
|
const fleetAlias = opts.fleetAlias ?? 'mosaic-fleet';
|
||||||
|
|
||||||
|
// 1. Register the virtual agent MXIDs (bypasses enable_registration:false).
|
||||||
|
const agents = [];
|
||||||
|
for (const slug of opts.agentSlugs) {
|
||||||
|
const mxid = await intent.ensureRegistered(slug);
|
||||||
|
await intent.setDisplayName(slug, `agent ${slug} (DEV)`);
|
||||||
|
agents.push({ slug, mxid });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create the single fleet presence room, inviting all agents (RFC-001 §4.6).
|
||||||
|
// Idempotent: if the alias already exists, reuse that room instead.
|
||||||
|
let roomId: string;
|
||||||
|
try {
|
||||||
|
const created = await intent.createRoom({
|
||||||
|
name: 'Fleet Presence (DEV)',
|
||||||
|
alias: fleetAlias,
|
||||||
|
topic: 'RFC-001 P1 — mosaic.presence heartbeats. Who is alive?',
|
||||||
|
invite: agents.map((a) => a.mxid),
|
||||||
|
});
|
||||||
|
roomId = created.roomId;
|
||||||
|
} catch (err) {
|
||||||
|
// Alias already taken (re-run): resolve the existing room id.
|
||||||
|
const aliasFq = `#${fleetAlias}:${opts.serverName}`;
|
||||||
|
roomId = await resolveAlias(opts, aliasFq);
|
||||||
|
void err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Join every agent into the fleet room (invite + join; idempotent).
|
||||||
|
for (const { slug } of agents) {
|
||||||
|
await intent.ensureJoined(roomId, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { roomId, senderUserId: intent.senderUserId, agents };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAlias(
|
||||||
|
opts: Pick<ProvisionOptions, 'homeserverUrl' | 'asToken'>,
|
||||||
|
aliasFq: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const url = `${opts.homeserverUrl.replace(/\/$/, '')}/_matrix/client/v3/directory/room/${encodeURIComponent(aliasFq)}`;
|
||||||
|
const res = await fetch(url, { headers: { Authorization: `Bearer ${opts.asToken}` } });
|
||||||
|
if (!res.ok) throw new Error(`resolveAlias ${aliasFq} -> ${res.status}`);
|
||||||
|
const data = (await res.json()) as { room_id?: string };
|
||||||
|
if (!data.room_id) throw new Error(`resolveAlias ${aliasFq} returned no room_id`);
|
||||||
|
return data.room_id;
|
||||||
|
}
|
||||||
47
tools/matrix-presence-harness/run.sh
Executable file
47
tools/matrix-presence-harness/run.sh
Executable file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================================
|
||||||
|
# run.sh — RFC-001 P1 presence validation harness (A1–A5) *** DEV-ONLY ***
|
||||||
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# Assumes the dev Synapse is already up (infra/matrix/dev-up.sh). Provisions 3
|
||||||
|
# agents, heartbeats them, proves A2/A3/A4 against the LOCAL dev homeserver,
|
||||||
|
# exercising @mosaicstack/comms over the self-signed TLS endpoint (A1).
|
||||||
|
#
|
||||||
|
# NODE_TLS_REJECT_UNAUTHORIZED=0 is set ONLY because the dev cert is
|
||||||
|
# self-signed. This is a DEV convenience and must never be used in prod.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
set -euo pipefail
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO="$(cd "${HERE}/../.." && pwd)"
|
||||||
|
DATA="${REPO}/infra/matrix/.data"
|
||||||
|
|
||||||
|
if [[ ! -f "${DATA}/dev-secrets.env" ]]; then
|
||||||
|
echo "run.sh: ${DATA}/dev-secrets.env not found — run infra/matrix/dev-up.sh first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
set -a; source "${DATA}/dev-secrets.env"; set +a
|
||||||
|
|
||||||
|
export MATRIX_SERVER_NAME="${MATRIX_SERVER_NAME:-matrix.localhost}"
|
||||||
|
export MATRIX_TLS_PORT="${MATRIX_TLS_PORT:-18448}"
|
||||||
|
# Exercise the SDK over the self-signed TLS listener (A1). Use the plain HTTP
|
||||||
|
# port instead by exporting MATRIX_CS_URL=http://127.0.0.1:18008 before running.
|
||||||
|
export MATRIX_CS_URL="${MATRIX_CS_URL:-https://127.0.0.1:${MATRIX_TLS_PORT}}"
|
||||||
|
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
|
|
||||||
|
export HEARTBEAT_INTERVAL_MS="${HEARTBEAT_INTERVAL_MS:-1000}"
|
||||||
|
export MISS_TOLERANCE="${MISS_TOLERANCE:-2}"
|
||||||
|
export DARK_THRESHOLD_MS="${DARK_THRESHOLD_MS:-6000}"
|
||||||
|
export AGENT_SLUGS="${AGENT_SLUGS:-alpha,bravo,charlie}"
|
||||||
|
export VICTIM_SLUG="${VICTIM_SLUG:-charlie}"
|
||||||
|
|
||||||
|
TSX_CLI="$(ls -d "${REPO}"/node_modules/.pnpm/tsx@*/node_modules/tsx/dist/cli.mjs 2>/dev/null | head -1)"
|
||||||
|
if [[ -z "${TSX_CLI}" ]]; then
|
||||||
|
echo "run.sh: tsx not found under node_modules — run pnpm install first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export TSX_CLI
|
||||||
|
|
||||||
|
echo "[run] CS=${MATRIX_CS_URL} server_name=${MATRIX_SERVER_NAME} dark_threshold=${DARK_THRESHOLD_MS}ms"
|
||||||
|
cd "${REPO}"
|
||||||
|
exec node "${TSX_CLI}" "${HERE}/validate.ts"
|
||||||
9
tools/matrix-presence-harness/tsconfig.json
Normal file
9
tools/matrix-presence-harness/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
210
tools/matrix-presence-harness/validate.ts
Normal file
210
tools/matrix-presence-harness/validate.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* tools/matrix-presence-harness/validate.ts
|
||||||
|
*
|
||||||
|
* *** DEV harness — LOCAL SANDBOX ONLY. Never point at live infra. ***
|
||||||
|
*
|
||||||
|
* End-to-end proof of RFC-001 P1 acceptance criteria against the local dev
|
||||||
|
* Synapse (infra/matrix):
|
||||||
|
*
|
||||||
|
* A2 provision >=3 agents, they heartbeat, all show ONLINE in the fleet
|
||||||
|
* presence room.
|
||||||
|
* A3 hard-kill (SIGKILL) one agent's process; assert it flips to OFFLINE
|
||||||
|
* within dark_threshold, DETERMINISTICALLY (heartbeat-age based, not
|
||||||
|
* native-presence-timeout), while the survivors stay ONLINE.
|
||||||
|
* A4 dump the human-readable fleet liveness board.
|
||||||
|
*
|
||||||
|
* Exits 0 on success, 1 on any failed assertion.
|
||||||
|
* ESM / NodeNext: .js import extensions.
|
||||||
|
*/
|
||||||
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FleetLivenessReader,
|
||||||
|
MinimalMatrixClient,
|
||||||
|
type AgentLiveness,
|
||||||
|
type LivenessPolicy,
|
||||||
|
} from '@mosaicstack/comms';
|
||||||
|
|
||||||
|
import { provision } from './provision.js';
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const env = (k: string, fallback?: string): string => {
|
||||||
|
const v = process.env[k] ?? fallback;
|
||||||
|
if (v === undefined) throw new Error(`missing env ${k}`);
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const board = (rows: AgentLiveness[]): Record<string, string> =>
|
||||||
|
Object.fromEntries(rows.map((r) => [r.slug, r.status]));
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const homeserverUrl = env('MATRIX_CS_URL');
|
||||||
|
const asToken = env('MOSAIC_AS_TOKEN');
|
||||||
|
const hsToken = env('MOSAIC_HS_TOKEN', 'unused-in-p1');
|
||||||
|
const serverName = env('MATRIX_SERVER_NAME');
|
||||||
|
const intervalMs = Number(env('HEARTBEAT_INTERVAL_MS', '1000'));
|
||||||
|
const missTolerance = Number(env('MISS_TOLERANCE', '2'));
|
||||||
|
const darkThresholdMs = Number(env('DARK_THRESHOLD_MS', '6000'));
|
||||||
|
const slugs = env('AGENT_SLUGS', 'alpha,bravo,charlie').split(',');
|
||||||
|
const victim = env('VICTIM_SLUG', 'charlie');
|
||||||
|
|
||||||
|
const policy: LivenessPolicy = {
|
||||||
|
heartbeatIntervalMs: intervalMs,
|
||||||
|
missTolerance,
|
||||||
|
darkThresholdMs,
|
||||||
|
};
|
||||||
|
const failures: string[] = [];
|
||||||
|
const assert = (ok: boolean, msg: string): void => {
|
||||||
|
console.log(` ${ok ? 'PASS' : 'FAIL'} ${msg}`);
|
||||||
|
if (!ok) failures.push(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Provision (register agents, create fleet room, join) ──────────────────
|
||||||
|
console.log(`\n[validate] provisioning ${slugs.length} agents + fleet presence room...`);
|
||||||
|
const { roomId, senderUserId } = await provision({
|
||||||
|
homeserverUrl,
|
||||||
|
serverName,
|
||||||
|
asToken,
|
||||||
|
hsToken,
|
||||||
|
agentSlugs: slugs,
|
||||||
|
});
|
||||||
|
console.log(`[validate] fleet room ${roomId} (sender ${senderUserId})`);
|
||||||
|
|
||||||
|
// Reader masquerades as the AS sender (a room member) to read heartbeats.
|
||||||
|
const reader = new FleetLivenessReader({
|
||||||
|
client: new MinimalMatrixClient({
|
||||||
|
homeserverUrl,
|
||||||
|
accessToken: asToken,
|
||||||
|
actAsUserId: senderUserId,
|
||||||
|
}),
|
||||||
|
roomId,
|
||||||
|
policy,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Spawn each agent as its own OS process ────────────────────────────────
|
||||||
|
const procs = new Map<string, ChildProcess>();
|
||||||
|
for (const slug of slugs) {
|
||||||
|
// `--import tsx` runs agent-proc.ts IN-PROCESS (no tsx grandchild), so the
|
||||||
|
// spawned pid IS the agent — a SIGKILL to it is a true hard-kill (A3).
|
||||||
|
// cwd=HERE so the `tsx` specifier resolves against the harness node_modules.
|
||||||
|
const child = spawn('node', ['--import', 'tsx', resolve(HERE, 'agent-proc.ts')], {
|
||||||
|
cwd: HERE,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
AGENT_SLUG: slug,
|
||||||
|
FLEET_ROOM_ID: roomId,
|
||||||
|
MATRIX_CS_URL: homeserverUrl,
|
||||||
|
MOSAIC_AS_TOKEN: asToken,
|
||||||
|
MATRIX_SERVER_NAME: serverName,
|
||||||
|
HEARTBEAT_INTERVAL_MS: String(intervalMs),
|
||||||
|
MISS_TOLERANCE: String(missTolerance),
|
||||||
|
DARK_THRESHOLD_MS: String(darkThresholdMs),
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'inherit', 'inherit'],
|
||||||
|
});
|
||||||
|
procs.set(slug, child);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hardKill = (pid: number): void => {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGKILL'); // pid is the in-process agent — a true kill
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cleanup = (): void => {
|
||||||
|
for (const [, c] of procs) {
|
||||||
|
if (c.pid) hardKill(c.pid);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
process.on('exit', cleanup);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── A2: let a few beats flow, assert all online ─────────────────────────
|
||||||
|
console.log(`\n[validate] A2 — waiting for heartbeats (${intervalMs}ms interval)...`);
|
||||||
|
await sleep(intervalMs * 4 + 1000);
|
||||||
|
const a2 = await reader.read();
|
||||||
|
console.log('[validate] board:', board(a2));
|
||||||
|
for (const slug of slugs) {
|
||||||
|
assert(a2.find((r) => r.slug === slug)?.status === 'online', `A2 ${slug} is online`);
|
||||||
|
}
|
||||||
|
assert(a2.length >= 3, 'A2 >=3 agents present in fleet room');
|
||||||
|
|
||||||
|
// ── A4: human-readable board ────────────────────────────────────────────
|
||||||
|
console.log('\n[validate] A4 — fleet liveness board (human view):');
|
||||||
|
console.log(await reader.formatBoard());
|
||||||
|
|
||||||
|
// ── A3: hard-kill the victim, measure flip-to-offline latency ───────────
|
||||||
|
const victimProc = procs.get(victim);
|
||||||
|
if (!victimProc?.pid) throw new Error(`no pid for victim ${victim}`);
|
||||||
|
console.log(
|
||||||
|
`\n[validate] A3 — SIGKILL ${victim} (pid ${victimProc.pid}); dark_threshold=${darkThresholdMs}ms`,
|
||||||
|
);
|
||||||
|
const killTs = Date.now();
|
||||||
|
hardKill(victimProc.pid);
|
||||||
|
|
||||||
|
let victimOffline: AgentLiveness | undefined;
|
||||||
|
let detectMs = 0;
|
||||||
|
const deadline = killTs + darkThresholdMs + 6000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await sleep(400);
|
||||||
|
const rows = await reader.read();
|
||||||
|
const v = rows.find((r) => r.slug === victim);
|
||||||
|
const elapsed = Date.now() - killTs;
|
||||||
|
if (v) {
|
||||||
|
console.log(
|
||||||
|
` t+${String(elapsed).padStart(5)}ms ${victim}=${v.status} (age=${v.ageMs}ms) ` +
|
||||||
|
`survivors=${slugs
|
||||||
|
.filter((s) => s !== victim)
|
||||||
|
.map((s) => `${s}:${rows.find((r) => r.slug === s)?.status}`)
|
||||||
|
.join(',')}`,
|
||||||
|
);
|
||||||
|
if (v.status === 'offline') {
|
||||||
|
victimOffline = v;
|
||||||
|
detectMs = elapsed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(!!victimOffline, `A3 ${victim} flipped to offline after kill`);
|
||||||
|
if (victimOffline) {
|
||||||
|
// Deterministic: the flip is driven by heartbeat age reaching dark_threshold.
|
||||||
|
assert(
|
||||||
|
victimOffline.ageMs >= darkThresholdMs,
|
||||||
|
`A3 flip is heartbeat-deterministic (age ${victimOffline.ageMs}ms >= dark_threshold ${darkThresholdMs}ms)`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
detectMs <= darkThresholdMs + 2000,
|
||||||
|
`A3 detected within dark_threshold + poll slack (detected at t+${detectMs}ms)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Survivors unaffected.
|
||||||
|
const finalRows = await reader.read();
|
||||||
|
for (const slug of slugs.filter((s) => s !== victim)) {
|
||||||
|
assert(
|
||||||
|
finalRows.find((r) => r.slug === slug)?.status === 'online',
|
||||||
|
`A3 survivor ${slug} still online`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n[validate] final board after kill:');
|
||||||
|
console.log(await reader.formatBoard());
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n[validate] ${failures.length === 0 ? 'ALL PASSED ✅' : `FAILED ❌ (${failures.length})`}`,
|
||||||
|
);
|
||||||
|
process.exit(failures.length === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('[validate] fatal:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user