Compare commits
134
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d84e4ee03 | ||
|
|
4aaf41dd1a | ||
|
|
d46a2d675a | ||
|
|
48bb19310d | ||
|
|
caebf9ef70 | ||
|
|
b2e005f2b4 | ||
|
|
87daa12976 | ||
|
|
90cf286a09 | ||
|
|
0aef432052 | ||
|
|
41a16cc916 | ||
|
|
e16c08aa9f | ||
|
|
a34e92cf39 | ||
|
|
a4861c221f | ||
|
|
46d68e1ff4 | ||
|
|
c3496334a5 | ||
|
|
6f29d00149 | ||
|
|
068d0f9b1c | ||
|
|
13cd673d50 | ||
|
|
620cc608e0 | ||
|
|
91e692e3e7 | ||
|
|
24bbd40dc7 | ||
|
|
4df478cdd1 | ||
|
|
b8844e1ff0 | ||
|
|
906ad8dc30 | ||
|
|
5916aeefd6 | ||
|
|
58b971aba3 | ||
|
|
f4fd5967fc | ||
|
|
f65e9ea656 | ||
|
|
f58b3699a6 | ||
|
|
01e966f36d | ||
|
|
524146055d | ||
|
|
06e0d40352 | ||
|
|
166ee8c90f | ||
|
|
826a8b3b26 | ||
|
|
a4280b9c98 | ||
|
|
4fb44f6345 | ||
|
|
089615f63b | ||
|
|
76eef39a29 | ||
|
|
47f8689231 | ||
|
|
8d1d6e5e76 | ||
|
|
6a7fce34bb | ||
|
|
a6b5f6a01a | ||
|
|
539b475a92 | ||
|
|
3e47fc076f | ||
|
|
8710d0f6d7 | ||
|
|
b981b4ec10 | ||
|
|
9e81ffd7fc | ||
|
|
9becaf877f | ||
|
|
17087efe15 | ||
|
|
0ea41e848b | ||
|
|
347c1d57c1 | ||
|
|
13e6ce5e5c | ||
|
|
90265ef550 | ||
|
|
937a276208 | ||
|
|
712c770b7a | ||
|
|
d967a4a926 | ||
|
|
c585ac3326 | ||
|
|
e2ec927b1c | ||
|
|
2378665eaf | ||
|
|
003cdaa1a6 | ||
|
|
320f5bfb6f | ||
|
|
5df47e735e | ||
|
|
dd1391fd76 | ||
|
|
10d957d095 | ||
|
|
dc45eb7c30 | ||
|
|
28f022d9c0 | ||
|
|
2726fab5e0 | ||
|
|
ab6e8e80dc | ||
|
|
1933c6cb1d | ||
|
|
48a0c86093 | ||
|
|
79c8647fd9 | ||
|
|
2483dada33 | ||
|
|
4c117afe03 | ||
|
|
2698ddb7b5 | ||
|
|
fabde1c834 | ||
|
|
3c7890f17f | ||
|
|
529c177830 | ||
|
|
a32ce4c8f9 | ||
|
|
d351caad36 | ||
|
|
76b86a246e | ||
|
|
4422231bdb | ||
|
|
8504216964 | ||
|
|
7edc9b3121 | ||
|
|
2f50c0876b | ||
|
|
db90da347e | ||
|
|
48fd1df28a | ||
|
|
b79336a8c1 | ||
|
|
4e5af23214 | ||
|
|
880c28b191 | ||
|
|
7bc2dfb6c8 | ||
|
|
b0d78d8632 | ||
|
|
344d86a635 | ||
|
|
acd7d380f6 | ||
|
|
3b70c66c07 | ||
|
|
11d2818453 | ||
|
|
aa999daf1b | ||
|
|
77c9a82614 | ||
|
|
627cf2bb38 | ||
|
|
0582a8912b | ||
|
|
2509eb7646 | ||
|
|
07553ead33 | ||
|
|
e522b22fa4 | ||
|
|
e4d7d4502d | ||
|
|
8dfcf1903e | ||
|
|
abd2791f59 | ||
|
|
8ec67a1126 | ||
|
|
d801d6c4c8 | ||
|
|
d3bf52898b | ||
|
|
3f77229e88 | ||
|
|
686c881fe4 | ||
|
|
fe7a468c9d | ||
|
|
cabf02e7b9 | ||
|
|
9ddc6fbda8 | ||
|
|
31607a4af6 | ||
|
|
32a0ffba13 | ||
|
|
8536454257 | ||
|
|
4f29cc604d | ||
|
|
3be443c96d | ||
|
|
59f5f51ffd | ||
|
|
9745bc3f29 | ||
|
|
adad486b6f | ||
|
|
c1aecfabe9 | ||
|
|
499090508e | ||
|
|
c593a15ef8 | ||
|
|
193331544d | ||
|
|
495f73bfdb | ||
|
|
b96cc7982a | ||
|
|
0883fb91ec | ||
|
|
56787fabf1 | ||
|
|
940ae3cc41 | ||
|
|
c25a551c28 | ||
|
|
94d6538061 | ||
|
|
a3c1ab923c | ||
|
|
838701bde2 |
@@ -8,6 +8,7 @@ coverage
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
.pnpm-store
|
||||
__pycache__/
|
||||
docs/reports/
|
||||
|
||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
pnpm typecheck && pnpm lint && pnpm format:check
|
||||
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
|
||||
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
||||
# instead of repopulating a fresh one.
|
||||
store-dir=/root/.local/share/pnpm/store
|
||||
# HOME resolves to /root in the ci-base image, preserving its warmed-store path.
|
||||
# Non-root checkouts use their own HOME. Override without editing this file via
|
||||
# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting).
|
||||
store-dir=${HOME}/.local/share/pnpm/store
|
||||
|
||||
@@ -4,6 +4,15 @@ pnpm-lock.yaml
|
||||
**/node_modules
|
||||
**/drizzle
|
||||
**/.next
|
||||
# Python build/test artifacts — same category as node_modules/dist/.next above.
|
||||
# Prettier must never scan generated trees; without these a local venv poisons
|
||||
# `pnpm format:check` with thousands of third-party files.
|
||||
**/venv
|
||||
**/__pycache__
|
||||
**/.mypy_cache
|
||||
**/.pytest_cache
|
||||
**/htmlcov
|
||||
.claude/
|
||||
docs/tess/TASKS.md
|
||||
docs/scratchpads/
|
||||
packages/mosaic/src/fleet/testdata/documentation-publication-v1/inline-migration-v1.json
|
||||
|
||||
@@ -41,6 +41,32 @@ steps:
|
||||
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
||||
# Test-membership guard (#1017): also first link of test:framework-shell.
|
||||
# Invoked from BOTH surfaces it audits (F2, PR #1018) — the guard is link
|
||||
# [0] of the pnpm chain, so severing that chain would silence it together
|
||||
# with everything it guards; this direct line keeps one instrument running.
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
||||
|
||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||
# survives a keep-mode reseed byte-identical (with rsync present AND absent —
|
||||
# keep mode is a single cp-based path that must not depend on rsync), and that a
|
||||
# corrupt/empty/missing manifest aborts fail-closed leaving operator files
|
||||
# untouched (B2/B3). The rollback gate proves a mid-sync failure is rolled back
|
||||
# from the pre-update snapshot (B1). The durable-snapshot gate (#791 PR2) proves
|
||||
# the retained, operator-scoped pre-update backup is taken before any mutation
|
||||
# (0700/0600, secret never logged, retention-pruned) and that the post-sync
|
||||
# verify net restores any operator file a manifest bug lets the sync touch. The
|
||||
# migration matrix pins the v2→v3 contract-file semantics. Pure bash, no
|
||||
# node_modules — runs early alongside sanitization.
|
||||
upgrade-guard:
|
||||
image: *node_image
|
||||
commands:
|
||||
- apk add --no-cache bash rsync
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
@@ -50,6 +76,7 @@ steps:
|
||||
depends_on:
|
||||
- install
|
||||
- sanitization
|
||||
- upgrade-guard
|
||||
|
||||
# lint, format, and test are independent — run in parallel after typecheck
|
||||
lint:
|
||||
@@ -76,6 +103,12 @@ steps:
|
||||
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
# openssl (#912) is the wake HMAC signer: the digest H1/H2, beacon B12,
|
||||
# and install I8 legs hard-require it in CI. It is baked into ci-base via
|
||||
# Dockerfile.ci, but ci-base only rebuilds on push-to-main/tag — this
|
||||
# `apk add` guarantees openssl is present on PR pipelines too (and is a
|
||||
# fast no-op once the rebuilt image already ships it).
|
||||
- apk add --no-cache openssl
|
||||
# postgresql-client (pg_isready) is baked into ci-base.
|
||||
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
||||
- |
|
||||
|
||||
+104
-5
@@ -1,5 +1,5 @@
|
||||
# Build, publish npm packages, and push Docker images
|
||||
# Runs only on main branch push/tag
|
||||
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
||||
|
||||
variables:
|
||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||
@@ -23,9 +23,21 @@ variables:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
- &main_image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
exclude:
|
||||
- 'packages/mosaic/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
|
||||
when:
|
||||
- branch: [main]
|
||||
- branch: [main, next]
|
||||
event: [push, manual, tag]
|
||||
|
||||
steps:
|
||||
@@ -103,6 +115,84 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
publish-next-npm:
|
||||
image: *node_image
|
||||
# Durable @next integration-line publish. Runs only on next; never writes
|
||||
# the latest dist-tag and never commits the computed prerelease versions.
|
||||
when:
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
environment:
|
||||
NPM_TOKEN:
|
||||
from_secret: gitea_token
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- |
|
||||
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
|
||||
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$CI_PIPELINE_NUMBER" ]; then
|
||||
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
||||
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
|
||||
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||
const roots = ['apps', 'packages', 'plugins'];
|
||||
const updated = [];
|
||||
|
||||
function walk(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const packagePath = path.join(fullPath, 'package.json');
|
||||
if (fs.existsSync(packagePath)) updatePackage(packagePath);
|
||||
walk(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updatePackage(packagePath) {
|
||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
||||
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
||||
if (!stableMatch) {
|
||||
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
||||
}
|
||||
const [, major, minor, patch] = stableMatch;
|
||||
const oldVersion = manifest.version;
|
||||
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
||||
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
||||
}
|
||||
|
||||
for (const root of roots) walk(root);
|
||||
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
||||
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
||||
for (const line of updated) console.log('[publish-next] ' + line);
|
||||
NODE
|
||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
||||
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
||||
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
|
||||
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
|
||||
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
# TODO: Uncomment when ready to publish to npmjs.org
|
||||
# publish-npmjs:
|
||||
# image: *node_image
|
||||
@@ -134,8 +224,17 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next gateway publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||
@@ -146,7 +245,7 @@ steps:
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
when: *main_image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
@@ -172,7 +271,7 @@ steps:
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
when: *main_image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
|
||||
@@ -26,13 +26,14 @@ pnpm test # Vitest (all packages)
|
||||
pnpm build # Build all packages
|
||||
|
||||
# Database
|
||||
pnpm --filter @mosaicstack/db db:push # Push schema to PG (dev)
|
||||
pnpm --filter @mosaicstack/db db:generate # Generate migrations
|
||||
pnpm --filter @mosaicstack/db db:migrate # Run migrations
|
||||
pnpm --filter @mosaicstack/db db:generate # Offline migration artifact generation only
|
||||
# PostgreSQL execution is held until KBN-101-00/-03/-05 land. Do not invoke a runner,
|
||||
# init SQL, or Compose PostgreSQL service from this checkout.
|
||||
|
||||
# Dev
|
||||
docker compose up -d # Start PG, Valkey, OTEL, Jaeger
|
||||
pnpm --filter @mosaicstack/gateway exec tsx src/main.ts # Start gateway
|
||||
# Dev: local PGlite data-layer work needs no PostgreSQL. Optional local queue service only:
|
||||
docker compose up -d valkey
|
||||
# Do not start Gateway/Web or root pnpm dev as a local PGlite route: the current unguarded dotenv
|
||||
# loader can inherit a daemon PostgreSQL DSN. KBN-101-02 must make that state fail closed first.
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
+7
-4
@@ -22,10 +22,13 @@
|
||||
FROM node:24-alpine
|
||||
|
||||
# Native toolchain required to compile node-gyp deps on musl, plus the
|
||||
# postgresql-client used by the test step's pg_isready readiness probe. `bash`
|
||||
# is baked here too — the sanitization step in ci.yml otherwise does a per-run
|
||||
# `apk add bash`.
|
||||
RUN apk add --no-cache python3 make g++ postgresql-client bash
|
||||
# postgresql-client used by the test step's pg_isready readiness probe. `bash`,
|
||||
# `git`, and `jq` are baked here too — framework shell tests and the shipped
|
||||
# Codex review wrappers require them without per-run installation in ci.yml.
|
||||
# `openssl` (#912) is the non-circular HMAC signer for the wake trust layer:
|
||||
# the digest H1/H2, beacon B12, and install I8 legs hard-require it in CI so the
|
||||
# §4 G6 evidence comes from an actually-run HMAC leg, not a skipped one.
|
||||
RUN apk add --no-cache python3 make g++ postgresql-client bash git jq openssl
|
||||
|
||||
# Pin pnpm to the repo's packageManager version via corepack.
|
||||
RUN corepack enable && corepack prepare [email protected] --activate
|
||||
|
||||
@@ -30,6 +30,16 @@ This installs both components:
|
||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||
|
||||
### Install lanes
|
||||
|
||||
| Lane | Command | Use when | Source |
|
||||
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
|
||||
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
|
||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
|
||||
|
||||
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
|
||||
|
||||
After install, the wizard runs automatically or you can invoke it manually:
|
||||
|
||||
```bash
|
||||
@@ -97,7 +107,10 @@ mosaic config path # Print config file path
|
||||
```bash
|
||||
mosaic doctor # Health audit — detect drift and missing files
|
||||
mosaic sync # Sync skills from canonical source
|
||||
mosaic update # Check for and install CLI updates
|
||||
mosaic skill list # Audit Claude skill registrations and conflicts
|
||||
mosaic skill register <name> # Register one canonical skill with Claude Code
|
||||
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
||||
mosaic update # Update CLI/framework and auto-register canonical skills
|
||||
mosaic wizard # Full guided setup wizard
|
||||
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
|
||||
mosaic coord init # Initialize a new orchestration mission
|
||||
@@ -157,7 +170,12 @@ mosaic storage status
|
||||
mosaic storage tier
|
||||
mosaic storage export
|
||||
mosaic storage import
|
||||
mosaic storage migrate
|
||||
# Schema migration is unavailable in this release. The current storage wrapper shells
|
||||
# directly to `pnpm --filter @mosaicstack/db db:migrate`; it is legacy N-1,
|
||||
# uncertified, and MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation.
|
||||
# Future schema migration is non-operative: external bootstrap → TLS/roles → runner
|
||||
# --run → runner --verify → readiness. Tier copy uses only the separately held secure
|
||||
# migrate-tier route.
|
||||
```
|
||||
|
||||
### Telemetry
|
||||
@@ -192,33 +210,50 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
|
||||
git clone [email protected]:mosaicstack/stack.git
|
||||
cd stack
|
||||
|
||||
# Start infrastructure (Postgres, Valkey, Jaeger)
|
||||
docker compose up -d
|
||||
|
||||
# Install dependencies
|
||||
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
||||
# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without
|
||||
# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed.
|
||||
pnpm install
|
||||
|
||||
# Run migrations
|
||||
pnpm --filter @mosaicstack/db run db:migrate
|
||||
# Verify dependencies and generated state before running source-quality gates.
|
||||
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
|
||||
# The web build certifies its exact standalone symlink manifest; added, removed,
|
||||
# retargeted, or manifest-only-tampered generated links also exit 43. This detects
|
||||
# accidental, independent, stale, and foreign-residue mutation—the class exposed by
|
||||
# a five-month-stale .next that produced 19 phantom TS2307 errors.
|
||||
# It does NOT defend against a same-UID actor that can rewrite both manifest and
|
||||
# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side
|
||||
# trust anchor outside worktree authority.
|
||||
pnpm preflight
|
||||
|
||||
# Start all services in dev mode
|
||||
pnpm dev
|
||||
# Optional local queue service only. This does not start PostgreSQL.
|
||||
docker compose up -d valkey
|
||||
|
||||
# The current Gateway/Web local process is held; see docs/guides/dev-guide.md.
|
||||
# Do not start it until KBN-101-02 makes inherited dotenv/DSN state fail closed.
|
||||
```
|
||||
|
||||
### Infrastructure
|
||||
### Held future procedure
|
||||
|
||||
Docker Compose provides:
|
||||
The checked-in Compose PostgreSQL service mounts legacy initialization SQL and is **not** a
|
||||
current PostgreSQL, standalone, or federated developer route. Do not start it with Compose,
|
||||
invoke initialization SQL, or treat the planned migrator as currently executable.
|
||||
|
||||
| Service | Port | Purpose |
|
||||
| --------------------- | --------- | ---------------------- |
|
||||
| PostgreSQL (pgvector) | 5433 | Primary database |
|
||||
| Valkey | 6380 | Task queue + caching |
|
||||
| Jaeger | 16686 | Distributed tracing UI |
|
||||
| OTEL Collector | 4317/4318 | Telemetry ingestion |
|
||||
**Held future activation procedure — non-operative and no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05
|
||||
land:** external bootstrap → TLS/roles → `mosaic-db-migrator --run` →
|
||||
`mosaic-db-migrator --verify` → Gateway/Compose readiness. The future deployment artifacts—not
|
||||
this README—will provide the reviewed commands and secret-consumer interface.
|
||||
|
||||
For local data-layer work, PGlite needs no PostgreSQL service. The optional Compose command above
|
||||
starts only Valkey; OTEL Collector and Jaeger may likewise be started individually if needed,
|
||||
without starting PostgreSQL. A Gateway/Web local process is not currently a safe PGlite route:
|
||||
its unguarded dotenv loader may inherit a daemon PostgreSQL DSN. Do not use root `pnpm dev` or a
|
||||
Gateway start command until KBN-101-02 makes that state fail closed.
|
||||
|
||||
### Quality Gates
|
||||
|
||||
```bash
|
||||
pnpm preflight # Checkout/dependency/generated-state validation
|
||||
pnpm typecheck # TypeScript type checking (all packages)
|
||||
pnpm lint # ESLint (all packages)
|
||||
pnpm test # Vitest (all packages)
|
||||
@@ -231,7 +266,7 @@ pnpm format # Prettier auto-fix
|
||||
Woodpecker CI runs on every push:
|
||||
|
||||
- `pnpm install --frozen-lockfile`
|
||||
- Database migration against a fresh Postgres
|
||||
- **Legacy N-1 CI status only — active, uncertified, and non-authorizing as an operator route:** the checked-in job currently invokes `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` against an isolated disposable PostgreSQL CI database. It performs direct DDL in that CI database, is not approved ordinary behavior or an operator route, and remains a known exception pending KBN-101-06 removal/replacement by the certified runner-backed CI path.
|
||||
- `pnpm test` (Turbo-orchestrated across all packages)
|
||||
|
||||
npm packages are published to the Gitea package registry on main merges.
|
||||
@@ -336,11 +371,15 @@ The CLI also performs a background update check on every invocation (cached for
|
||||
bash tools/install.sh --check # Version check only
|
||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||
bash tools/install.sh --next # Prerelease lane: source build from next
|
||||
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||
```
|
||||
|
||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
||||
|
||||
## Contributing
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Federation M3 single-gateway integration tests (FED-M3-10).
|
||||
*
|
||||
* Covers MILESTONES.md M3 acceptance:
|
||||
* - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
|
||||
* - #7: max_rows_per_query caps list results.
|
||||
*
|
||||
* Strategy:
|
||||
* - Real PostgreSQL via @mosaicstack/db.
|
||||
* - Mocked TLS context/Fastify request shim for FederationAuthGuard.
|
||||
* - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
|
||||
*
|
||||
* Run:
|
||||
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
|
||||
* src/__tests__/integration/federation-m3-list.integration.test.ts
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import * as crypto from 'node:crypto';
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
and,
|
||||
createDb,
|
||||
eq,
|
||||
federationGrants,
|
||||
federationPeers,
|
||||
inArray,
|
||||
missionTasks,
|
||||
missions,
|
||||
projects,
|
||||
tasks,
|
||||
teamMembers,
|
||||
teams,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
users,
|
||||
} from '@mosaicstack/db';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { GrantsService } from '../../federation/grants.service.js';
|
||||
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
|
||||
import { FederationScopeService } from '../../federation/server/scope.service.js';
|
||||
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
|
||||
import { ListController } from '../../federation/server/verbs/list.controller.js';
|
||||
import {
|
||||
makeMosaicIssuedCert,
|
||||
makeSelfSignedCert,
|
||||
} from '../../federation/__tests__/helpers/test-cert.js';
|
||||
|
||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
|
||||
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();
|
||||
|
||||
interface TestIds {
|
||||
readonly subjectUserId: string;
|
||||
readonly otherUserId: string;
|
||||
readonly peerId: string;
|
||||
readonly revokedPeerId: string;
|
||||
readonly activeGrantId: string;
|
||||
readonly revokedGrantId: string;
|
||||
readonly subjectProjectId: string;
|
||||
readonly subjectMissionId: string;
|
||||
readonly otherProjectId: string;
|
||||
readonly teamId: string;
|
||||
readonly unauthorizedTeamId: string;
|
||||
readonly teamProjectId: string;
|
||||
readonly taskIds: readonly string[];
|
||||
readonly excludedTaskIds: readonly string[];
|
||||
readonly subjectNoteId: string;
|
||||
readonly otherUserNoteId: string;
|
||||
}
|
||||
|
||||
function pemToDer(pem: string): Buffer {
|
||||
return Buffer.from(
|
||||
pem
|
||||
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
||||
.replace(/-----END CERTIFICATE-----/, '')
|
||||
.replace(/\s+/g, ''),
|
||||
'base64',
|
||||
);
|
||||
}
|
||||
|
||||
function makeFederationRequest(certPem: string): FastifyRequest {
|
||||
return {
|
||||
raw: {
|
||||
socket: {
|
||||
getPeerCertificate: () => ({
|
||||
raw: pemToDer(certPem),
|
||||
serialNumber: CERT_SERIAL_HEX,
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as FastifyRequest;
|
||||
}
|
||||
|
||||
function makeGuardContext(request: FastifyRequest): {
|
||||
readonly context: ExecutionContext;
|
||||
readonly sent: { statusCode?: number; payload?: unknown };
|
||||
} {
|
||||
const sent: { statusCode?: number; payload?: unknown } = {};
|
||||
const reply = {
|
||||
status: (statusCode: number) => {
|
||||
sent.statusCode = statusCode;
|
||||
return {
|
||||
header: () => ({
|
||||
send: (payload: unknown) => {
|
||||
sent.payload = payload;
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
} as unknown as FastifyReply;
|
||||
|
||||
const context = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
getResponse: () => reply,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
return { context, sent };
|
||||
}
|
||||
|
||||
async function insertUser(db: Db, id: string, label: string): Promise<void> {
|
||||
await db.insert(users).values({
|
||||
id,
|
||||
name: `${RUN_ID}-${label}`,
|
||||
email: `${RUN_ID}-${label}@federation-test.invalid`,
|
||||
emailVerified: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function seedFixtures(db: Db): Promise<TestIds> {
|
||||
const subjectUserId = `${RUN_ID}-subject`;
|
||||
const otherUserId = `${RUN_ID}-other`;
|
||||
const peerId = crypto.randomUUID();
|
||||
const revokedPeerId = crypto.randomUUID();
|
||||
const activeGrantId = crypto.randomUUID();
|
||||
const revokedGrantId = crypto.randomUUID();
|
||||
const subjectProjectId = crypto.randomUUID();
|
||||
const subjectMissionId = crypto.randomUUID();
|
||||
const otherProjectId = crypto.randomUUID();
|
||||
const teamId = crypto.randomUUID();
|
||||
const unauthorizedTeamId = crypto.randomUUID();
|
||||
const teamProjectId = crypto.randomUUID();
|
||||
const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
|
||||
const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
|
||||
const subjectNoteId = crypto.randomUUID();
|
||||
const otherUserNoteId = crypto.randomUUID();
|
||||
|
||||
await insertUser(db, subjectUserId, 'subject');
|
||||
await insertUser(db, otherUserId, 'other');
|
||||
|
||||
await db.insert(teams).values([
|
||||
{
|
||||
id: teamId,
|
||||
name: `${RUN_ID} allowed team`,
|
||||
slug: `${RUN_ID}-allowed-team`,
|
||||
ownerId: subjectUserId,
|
||||
managerId: subjectUserId,
|
||||
},
|
||||
{
|
||||
id: unauthorizedTeamId,
|
||||
name: `${RUN_ID} unauthorized team`,
|
||||
slug: `${RUN_ID}-unauthorized-team`,
|
||||
ownerId: otherUserId,
|
||||
managerId: otherUserId,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(teamMembers).values([
|
||||
{ teamId, userId: subjectUserId, role: 'member' },
|
||||
{ teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
|
||||
]);
|
||||
|
||||
await db.insert(projects).values([
|
||||
{
|
||||
id: subjectProjectId,
|
||||
name: `${RUN_ID} subject personal project`,
|
||||
ownerType: 'user',
|
||||
ownerId: subjectUserId,
|
||||
},
|
||||
{
|
||||
id: otherProjectId,
|
||||
name: `${RUN_ID} other personal project`,
|
||||
ownerType: 'user',
|
||||
ownerId: otherUserId,
|
||||
},
|
||||
{
|
||||
id: teamProjectId,
|
||||
name: `${RUN_ID} unauthorized team project`,
|
||||
ownerType: 'team',
|
||||
teamId: unauthorizedTeamId,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(missions).values({
|
||||
id: subjectMissionId,
|
||||
name: `${RUN_ID} subject mission`,
|
||||
projectId: subjectProjectId,
|
||||
userId: subjectUserId,
|
||||
});
|
||||
|
||||
await db.insert(tasks).values([
|
||||
{
|
||||
id: taskIds[0],
|
||||
title: `${RUN_ID} visible task 1`,
|
||||
missionId: subjectMissionId,
|
||||
createdAt: new Date('2026-06-25T03:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T03:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: taskIds[1],
|
||||
title: `${RUN_ID} visible task 2`,
|
||||
projectId: subjectProjectId,
|
||||
createdAt: new Date('2026-06-25T02:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T02:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: taskIds[2],
|
||||
title: `${RUN_ID} visible task 3`,
|
||||
projectId: subjectProjectId,
|
||||
createdAt: new Date('2026-06-25T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T01:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: excludedTaskIds[0],
|
||||
title: `${RUN_ID} other user task`,
|
||||
projectId: otherProjectId,
|
||||
createdAt: new Date('2026-06-25T04:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T04:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: excludedTaskIds[1],
|
||||
title: `${RUN_ID} unauthorized team task`,
|
||||
projectId: teamProjectId,
|
||||
createdAt: new Date('2026-06-25T05:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T05:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(missionTasks).values([
|
||||
{
|
||||
id: subjectNoteId,
|
||||
missionId: subjectMissionId,
|
||||
userId: subjectUserId,
|
||||
notes: `${RUN_ID} subject visible note`,
|
||||
createdAt: new Date('2026-06-25T03:30:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T03:30:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: otherUserNoteId,
|
||||
missionId: subjectMissionId,
|
||||
userId: otherUserId,
|
||||
notes: `${RUN_ID} other user note on subject mission`,
|
||||
createdAt: new Date('2026-06-25T04:30:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T04:30:00.000Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(federationPeers).values([
|
||||
{
|
||||
id: peerId,
|
||||
commonName: `${RUN_ID}-active-peer`,
|
||||
displayName: `${RUN_ID} Active Peer`,
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: CERT_SERIAL_HEX,
|
||||
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||
state: 'active',
|
||||
},
|
||||
{
|
||||
id: revokedPeerId,
|
||||
commonName: `${RUN_ID}-revoked-peer`,
|
||||
displayName: `${RUN_ID} Revoked Peer`,
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
|
||||
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||
state: 'active',
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(federationGrants).values([
|
||||
{
|
||||
id: activeGrantId,
|
||||
peerId,
|
||||
subjectUserId,
|
||||
status: 'active',
|
||||
scope: {
|
||||
resources: ['tasks', 'notes'],
|
||||
excluded_resources: [],
|
||||
filters: {
|
||||
tasks: { include_personal: true, include_teams: [] },
|
||||
notes: { include_personal: true, include_teams: [] },
|
||||
},
|
||||
max_rows_per_query: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: revokedGrantId,
|
||||
peerId,
|
||||
subjectUserId,
|
||||
status: 'revoked',
|
||||
revokedAt: new Date(),
|
||||
revokedReason: `${RUN_ID} revoked grant fixture`,
|
||||
scope: {
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 2,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
subjectUserId,
|
||||
otherUserId,
|
||||
peerId,
|
||||
revokedPeerId,
|
||||
activeGrantId,
|
||||
revokedGrantId,
|
||||
subjectProjectId,
|
||||
subjectMissionId,
|
||||
otherProjectId,
|
||||
teamId,
|
||||
unauthorizedTeamId,
|
||||
teamProjectId,
|
||||
taskIds,
|
||||
excludedTaskIds,
|
||||
subjectNoteId,
|
||||
otherUserNoteId,
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
|
||||
if (!ids) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(missionTasks)
|
||||
.where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(tasks)
|
||||
.where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(missions)
|
||||
.where(eq(missions.id, ids.subjectMissionId))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(projects)
|
||||
.where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(teamMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(teamMembers.userId, ids.subjectUserId),
|
||||
inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(teams)
|
||||
.where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(federationGrants)
|
||||
.where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(federationPeers)
|
||||
.where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(users)
|
||||
.where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
|
||||
let handle: DbHandle;
|
||||
let db: Db;
|
||||
let moduleRef: TestingModule;
|
||||
let guard: FederationAuthGuard;
|
||||
let listController: ListController;
|
||||
let ids: TestIds | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
handle = createDb(PG_URL);
|
||||
db = handle.db;
|
||||
ids = await seedFixtures(db);
|
||||
|
||||
moduleRef = await Test.createTestingModule({
|
||||
controllers: [ListController],
|
||||
providers: [
|
||||
{ provide: DB, useValue: db },
|
||||
GrantsService,
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = moduleRef.get(FederationAuthGuard);
|
||||
listController = moduleRef.get(ListController);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||
await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||
await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||
});
|
||||
|
||||
it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
|
||||
const malformedOidCert = await makeSelfSignedCert();
|
||||
const request = makeFederationRequest(malformedOidCert);
|
||||
const { context, sent } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(false);
|
||||
expect(sent.statusCode).toBe(401);
|
||||
expect(sent.payload).toMatchObject({
|
||||
error: {
|
||||
code: 'unauthorized',
|
||||
message: expect.stringContaining('missing required OID'),
|
||||
},
|
||||
});
|
||||
expect(request.federationContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const revokedCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.revokedGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(revokedCert);
|
||||
const { context, sent } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(false);
|
||||
expect(sent.statusCode).toBe(403);
|
||||
expect(sent.payload).toMatchObject({
|
||||
error: {
|
||||
code: 'forbidden',
|
||||
message: 'Federation access denied',
|
||||
},
|
||||
});
|
||||
expect(request.federationContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const activeCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.activeGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(activeCert);
|
||||
const { context } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
|
||||
const response = await listController.list('tasks', request, { limit: 100 });
|
||||
const returnedIds = response.items.map((item) => item['id']);
|
||||
|
||||
expect(response.items).toHaveLength(2);
|
||||
expect(response._truncated).toBe(true);
|
||||
expect(response.nextCursor).toEqual(expect.any(String));
|
||||
expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
|
||||
expect(returnedIds).not.toContain(ids!.taskIds[2]);
|
||||
for (const excludedId of ids!.excludedTaskIds) {
|
||||
expect(returnedIds).not.toContain(excludedId);
|
||||
}
|
||||
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes another user mission task notes on the same authorized mission', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const activeCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.activeGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(activeCert);
|
||||
const { context } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
|
||||
const response = await listController.list('notes', request, { limit: 10 });
|
||||
const returnedIds = response.items.map((item) => item['id']);
|
||||
|
||||
expect(returnedIds).toEqual([ids!.subjectNoteId]);
|
||||
expect(returnedIds).not.toContain(ids!.otherUserNoteId);
|
||||
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed for unsupported list resources', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const activeCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.activeGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(activeCert);
|
||||
const { context } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
|
||||
await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Requested federation resource is not supported',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
|
||||
import { sql, type Db } from '@mosaicstack/db';
|
||||
import { createQueue } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { ProviderService } from '../agent/provider.service.js';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { AdminGuard } from './admin.guard.js';
|
||||
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
||||
|
||||
@@ -14,6 +16,9 @@ export class AdminHealthController {
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -55,6 +60,14 @@ export class AdminHealthController {
|
||||
}
|
||||
|
||||
private async checkCache(): Promise<ServiceStatusDto> {
|
||||
// On Local tier there is no Redis. The cache is intentionally absent, which
|
||||
// is a healthy state for this tier — report 'ok' rather than opening a new
|
||||
// ioredis connection on every admin health check (which would spam
|
||||
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
|
||||
// signals "no cache backend to measure" for this tier.
|
||||
if (this.mosaicConfig?.queue?.type === 'local') {
|
||||
return { status: 'ok', latencyMs: 0 };
|
||||
}
|
||||
const start = Date.now();
|
||||
const handle = createQueue();
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||
@@ -12,6 +13,7 @@ const mockRegistry = {
|
||||
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
||||
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||
{ name: 'mcp', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||
],
|
||||
skills: [],
|
||||
})),
|
||||
@@ -72,17 +74,24 @@ const mockChatGateway = {
|
||||
broadcastSessionInfo: vi.fn(),
|
||||
};
|
||||
|
||||
function buildService(): CommandExecutorService {
|
||||
function buildService(
|
||||
redis: typeof mockRedis | null = mockRedis,
|
||||
mcpClient: {
|
||||
reconnectServer: ReturnType<typeof vi.fn>;
|
||||
getServerStatuses: ReturnType<typeof vi.fn>;
|
||||
getToolDefinitions: ReturnType<typeof vi.fn>;
|
||||
} | null = null,
|
||||
): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
mockRegistry as never,
|
||||
mockAgentService as never,
|
||||
mockSystemOverride as never,
|
||||
mockSessionGC as never,
|
||||
mockRedis as never,
|
||||
redis as never,
|
||||
mockBrain as never,
|
||||
null,
|
||||
mockChatGateway as never,
|
||||
null,
|
||||
mcpClient as never,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,6 +140,22 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
expect(ttl).toBe(300);
|
||||
});
|
||||
|
||||
it('/provider login remains available without Redis on the local tier', async () => {
|
||||
const localService = buildService(null);
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'provider',
|
||||
args: 'login anthropic',
|
||||
conversationId,
|
||||
};
|
||||
|
||||
const result = await localService.execute(payload, userScope);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).not.toContain('token=');
|
||||
expect(result.data).toEqual({ provider: 'anthropic' });
|
||||
expect(mockRedis.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// /provider with no args — returns usage
|
||||
it('/provider with no args returns usage message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||
@@ -242,4 +267,124 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
expect(result.command).toBe('tools');
|
||||
expect(result.message).toContain('tools');
|
||||
});
|
||||
|
||||
// Top-level catch sanitization (P3-4 re-review finding #1): a rejected
|
||||
// Redis `set` inside /provider login is the only reachable path into the
|
||||
// top-level catch in `execute()`. The raw exception must be logged
|
||||
// server-side but never handed back to the socket client.
|
||||
it('sanitizes the top-level command catch, logging the raw exception but never returning it to the client', async () => {
|
||||
const distinctiveRawFailure = 'ECONNREFUSED distinctive-raw-redis-failure-token-9f31';
|
||||
const rawError = new Error(distinctiveRawFailure);
|
||||
const failingRedis = {
|
||||
set: vi.fn().mockRejectedValue(rawError),
|
||||
get: vi.fn(),
|
||||
del: vi.fn(),
|
||||
};
|
||||
const failingService = buildService(failingRedis as unknown as typeof mockRedis);
|
||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'provider',
|
||||
args: 'login anthropic',
|
||||
conversationId,
|
||||
};
|
||||
const result = await failingService.execute(payload, userScope);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.command).toBe('provider');
|
||||
expect(result.message).toBe('Command failed due to an internal error.');
|
||||
expect(result.message).not.toContain(distinctiveRawFailure);
|
||||
expect(result.message).not.toContain('ECONNREFUSED');
|
||||
|
||||
// The real exception is still logged server-side, as the raw Error
|
||||
// object itself (not stringified/interpolated into the log message).
|
||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(rawError));
|
||||
expect(loggedRawError).toBe(true);
|
||||
|
||||
loggerErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
// Inner catch sanitization (P3-5 operator ruling): every catch in
|
||||
// command-executor.service.ts that returns a SlashCommandResultPayload
|
||||
// must sanitize the client-facing message the same way the top-level
|
||||
// catch does, while still logging the raw exception server-side.
|
||||
it('/agent new sanitizes agent-creation failures, logging the raw exception but never returning it to the client', async () => {
|
||||
const marker = new Error('distinctive-agent-create-failure-token-A17f');
|
||||
mockBrain.agents.create.mockRejectedValueOnce(marker);
|
||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'agent',
|
||||
args: 'new my-new-agent',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userScope);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.command).toBe('agent');
|
||||
expect(result.message).toBe('Failed to create agent due to an internal error.');
|
||||
expect(result.message).not.toContain('distinctive-agent-create-failure-token-A17f');
|
||||
|
||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
||||
expect(loggedRawError).toBe(true);
|
||||
|
||||
loggerErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('/agent <name> switch sanitizes agent-lookup failures, logging the raw exception but never returning it to the client', async () => {
|
||||
const marker = new Error('distinctive-agent-switch-failure-token-B29c');
|
||||
mockBrain.agents.findByName.mockRejectedValueOnce(marker);
|
||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'agent',
|
||||
args: 'some-other-agent',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userScope);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.command).toBe('agent');
|
||||
expect(result.message).toBe('Failed to switch agent due to an internal error.');
|
||||
expect(result.message).not.toContain('distinctive-agent-switch-failure-token-B29c');
|
||||
|
||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
||||
expect(loggedRawError).toBe(true);
|
||||
|
||||
loggerErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('/mcp reconnect sanitizes MCP client failures, logging the raw exception but never returning it to the client', async () => {
|
||||
const marker = new Error('distinctive-mcp-reconnect-failure-token-C33e');
|
||||
const mockMcpClient = {
|
||||
reconnectServer: vi.fn().mockRejectedValue(marker),
|
||||
getServerStatuses: vi.fn(() => []),
|
||||
getToolDefinitions: vi.fn(() => []),
|
||||
};
|
||||
const mcpService = buildService(mockRedis, mockMcpClient);
|
||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'mcp',
|
||||
args: 'reconnect my-server',
|
||||
conversationId,
|
||||
};
|
||||
const result = await mcpService.execute(payload, userScope);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.command).toBe('mcp');
|
||||
expect(result.message).toBe(
|
||||
'Failed to reconnect MCP server "my-server" due to an internal error.',
|
||||
);
|
||||
expect(result.message).not.toContain('distinctive-mcp-reconnect-failure-token-C33e');
|
||||
|
||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
||||
expect(loggedRawError).toBe(true);
|
||||
|
||||
loggerErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,10 @@ export class CommandExecutorService {
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
||||
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
|
||||
@Optional()
|
||||
@Inject(COMMANDS_REDIS)
|
||||
private readonly redis: QueueHandle['redis'] | null,
|
||||
@Inject(BRAIN) private readonly brain: Brain,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ReloadService))
|
||||
@@ -156,8 +159,13 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Command /${command} failed: ${err}`);
|
||||
return { command, conversationId, success: false, message: String(err) };
|
||||
this.logger.error(`Command /${command} failed`, err);
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: false,
|
||||
message: 'Command failed due to an internal error.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,11 +341,11 @@ export class CommandExecutorService {
|
||||
data: { agentId: newAgent.id, agentName: newAgent.name },
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to create agent: ${err}`);
|
||||
this.logger.error(`Failed to create agent "${namePart}" for user ${userId}`, err);
|
||||
return {
|
||||
command: 'agent',
|
||||
success: false,
|
||||
message: `Failed to create agent: ${String(err)}`,
|
||||
message: 'Failed to create agent due to an internal error.',
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
@@ -388,11 +396,11 @@ export class CommandExecutorService {
|
||||
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to switch agent "${agentName}": ${err}`);
|
||||
this.logger.error(`Failed to switch agent "${agentName}"`, err);
|
||||
return {
|
||||
command: 'agent',
|
||||
success: false,
|
||||
message: `Failed to switch agent: ${String(err)}`,
|
||||
message: 'Failed to switch agent due to an internal error.',
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
@@ -443,14 +451,16 @@ export class CommandExecutorService {
|
||||
byte.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
const key = `mosaic:auth:poll:${tokenHash}`;
|
||||
// Persist only a short-lived token digest. The raw token is delivered only by
|
||||
// the authenticated dashboard flow, never in chat output or command metadata.
|
||||
await this.redis.set(
|
||||
key,
|
||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||
'EX',
|
||||
300,
|
||||
);
|
||||
if (this.redis) {
|
||||
// Persist only a short-lived token digest. The raw token is delivered only by
|
||||
// the authenticated dashboard flow, never in chat output or command metadata.
|
||||
await this.redis.set(
|
||||
key,
|
||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||
'EX',
|
||||
300,
|
||||
);
|
||||
}
|
||||
return {
|
||||
command: 'provider',
|
||||
success: true,
|
||||
@@ -603,11 +613,12 @@ export class CommandExecutorService {
|
||||
message: `MCP server "${serverName}" reconnected successfully.`,
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to reconnect MCP server "${serverName}"`, err);
|
||||
return {
|
||||
command: 'mcp',
|
||||
conversationId,
|
||||
success: false,
|
||||
message: `Failed to reconnect MCP server "${serverName}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
message: `Failed to reconnect MCP server "${serverName}" due to an internal error.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { ChatModule } from '../chat/chat.module.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
import { ReloadModule } from '../reload/reload.module.js';
|
||||
@@ -16,13 +18,17 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
providers: [
|
||||
{
|
||||
provide: COMMANDS_QUEUE_HANDLE,
|
||||
useFactory: (): QueueHandle => {
|
||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||
// On Local tier there is no Redis — skip the ioredis connection.
|
||||
// CommandExecutorService falls back to no-cache for /provider login on local.
|
||||
if (config?.queue?.type === 'local') return null;
|
||||
return createQueue();
|
||||
},
|
||||
inject: [MOSAIC_CONFIG],
|
||||
},
|
||||
{
|
||||
provide: COMMANDS_REDIS,
|
||||
useFactory: (handle: QueueHandle) => handle.redis,
|
||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||
inject: [COMMANDS_QUEUE_HANDLE],
|
||||
},
|
||||
CommandRegistryService,
|
||||
@@ -38,9 +44,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
],
|
||||
})
|
||||
export class CommandsModule implements OnApplicationShutdown {
|
||||
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(COMMANDS_QUEUE_HANDLE)
|
||||
private readonly handle: QueueHandle | null,
|
||||
) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle.close().catch(() => {});
|
||||
await this.handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { EnrollmentController } from './enrollment.controller.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
import { FederationController } from './federation.controller.js';
|
||||
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
||||
import { GetController } from './server/verbs/get.controller.js';
|
||||
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
|
||||
import { GrantsService } from './grants.service.js';
|
||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
||||
@@ -12,7 +14,13 @@ import { ListController } from './server/verbs/list.controller.js';
|
||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
||||
controllers: [
|
||||
EnrollmentController,
|
||||
FederationController,
|
||||
CapabilitiesController,
|
||||
ListController,
|
||||
GetController,
|
||||
],
|
||||
providers: [
|
||||
AdminGuard,
|
||||
CaService,
|
||||
@@ -23,6 +31,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
FederationGetQueryService,
|
||||
],
|
||||
exports: [
|
||||
CaService,
|
||||
@@ -33,6 +42,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
FederationGetQueryService,
|
||||
],
|
||||
})
|
||||
export class FederationModule {}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createPgliteDb,
|
||||
missionTasks,
|
||||
missions,
|
||||
projects,
|
||||
runPgliteMigrations,
|
||||
teams,
|
||||
users,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import type { FederationScopeQueryFilter } from '../../scope.service.js';
|
||||
import { FederationGetQueryService } from '../get-query.service.js';
|
||||
|
||||
const CREDENTIAL_FILTER: FederationScopeQueryFilter = {
|
||||
resource: 'credentials',
|
||||
subjectUserId: 'user-1',
|
||||
includePersonal: true,
|
||||
teamIds: [],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 25,
|
||||
};
|
||||
|
||||
const SUBJECT_USER_ID = 'fed-m3-06-subject';
|
||||
const OTHER_USER_ID = 'fed-m3-06-other';
|
||||
const TEAM_ID = '06000000-0000-4000-8000-000000000001';
|
||||
const UNAUTHORIZED_TEAM_ID = '06000000-0000-4000-8000-000000000002';
|
||||
const PERSONAL_PROJECT_ID = '06000000-0000-4000-8000-000000000101';
|
||||
const TEAM_PROJECT_ID = '06000000-0000-4000-8000-000000000102';
|
||||
const UNAUTHORIZED_PROJECT_ID = '06000000-0000-4000-8000-000000000103';
|
||||
const PERSONAL_MISSION_ID = '06000000-0000-4000-8000-000000000201';
|
||||
const TEAM_MISSION_ID = '06000000-0000-4000-8000-000000000202';
|
||||
const UNAUTHORIZED_MISSION_ID = '06000000-0000-4000-8000-000000000203';
|
||||
const SUBJECT_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000301';
|
||||
const OTHER_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000302';
|
||||
const SUBJECT_PERSONAL_NOTE_ID = '06000000-0000-4000-8000-000000000303';
|
||||
const SUBJECT_UNAUTHORIZED_NOTE_ID = '06000000-0000-4000-8000-000000000304';
|
||||
|
||||
let dbHandle: DbHandle | undefined;
|
||||
|
||||
function makeService() {
|
||||
return new FederationGetQueryService({} as Db);
|
||||
}
|
||||
|
||||
function makeDbService() {
|
||||
if (!dbHandle) {
|
||||
throw new Error('test DB not initialized');
|
||||
}
|
||||
return new FederationGetQueryService(dbHandle.db);
|
||||
}
|
||||
|
||||
async function seedNotesFixture() {
|
||||
if (!dbHandle) {
|
||||
throw new Error('test DB not initialized');
|
||||
}
|
||||
|
||||
await dbHandle.db.insert(users).values([
|
||||
{
|
||||
id: SUBJECT_USER_ID,
|
||||
name: 'Federation Subject',
|
||||
email: `${SUBJECT_USER_ID}@example.test`,
|
||||
emailVerified: false,
|
||||
},
|
||||
{
|
||||
id: OTHER_USER_ID,
|
||||
name: 'Federation Other',
|
||||
email: `${OTHER_USER_ID}@example.test`,
|
||||
emailVerified: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(teams).values([
|
||||
{
|
||||
id: TEAM_ID,
|
||||
name: 'FED-M3-06 Team',
|
||||
slug: 'fed-m3-06-team',
|
||||
ownerId: SUBJECT_USER_ID,
|
||||
managerId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_TEAM_ID,
|
||||
name: 'FED-M3-06 Unauthorized Team',
|
||||
slug: 'fed-m3-06-unauthorized-team',
|
||||
ownerId: OTHER_USER_ID,
|
||||
managerId: OTHER_USER_ID,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(projects).values([
|
||||
{
|
||||
id: PERSONAL_PROJECT_ID,
|
||||
name: 'FED-M3-06 Personal Project',
|
||||
ownerId: SUBJECT_USER_ID,
|
||||
ownerType: 'user',
|
||||
},
|
||||
{
|
||||
id: TEAM_PROJECT_ID,
|
||||
name: 'FED-M3-06 Team Project',
|
||||
teamId: TEAM_ID,
|
||||
ownerType: 'team',
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_PROJECT_ID,
|
||||
name: 'FED-M3-06 Unauthorized Project',
|
||||
teamId: UNAUTHORIZED_TEAM_ID,
|
||||
ownerType: 'team',
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(missions).values([
|
||||
{
|
||||
id: PERSONAL_MISSION_ID,
|
||||
name: 'FED-M3-06 Personal Mission',
|
||||
projectId: PERSONAL_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: TEAM_MISSION_ID,
|
||||
name: 'FED-M3-06 Team Mission',
|
||||
projectId: TEAM_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_MISSION_ID,
|
||||
name: 'FED-M3-06 Unauthorized Mission',
|
||||
projectId: UNAUTHORIZED_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(missionTasks).values([
|
||||
{
|
||||
id: SUBJECT_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note on team mission',
|
||||
createdAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: OTHER_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
userId: OTHER_USER_ID,
|
||||
notes: 'other user note on team mission',
|
||||
createdAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
||||
missionId: PERSONAL_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note on personal mission',
|
||||
createdAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
||||
missionId: UNAUTHORIZED_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note outside grant-visible missions',
|
||||
createdAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
describe('FederationGetQueryService', () => {
|
||||
beforeAll(async () => {
|
||||
dbHandle = createPgliteDb(`memory://fed-m3-06-get-${Date.now()}`);
|
||||
await runPgliteMigrations(dbHandle);
|
||||
await seedNotesFixture();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await dbHandle?.close();
|
||||
dbHandle = undefined;
|
||||
});
|
||||
|
||||
it('denies sensitive resources in native RBAC for M3 get reads', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'credentials',
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: 'credentials federation get access is not implemented in M3',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows personal memory reads without requiring team lookup', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'memory',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses subject team membership as the native RBAC upper bound for task and note reads', async () => {
|
||||
const service = makeService();
|
||||
const listSubjectTeamIds = vi.fn().mockResolvedValue(['team-1', 'team-2']);
|
||||
(
|
||||
service as unknown as {
|
||||
listSubjectTeamIds: (subjectUserId: string) => Promise<string[]>;
|
||||
}
|
||||
).listSubjectTeamIds = listSubjectTeamIds;
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'tasks',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
||||
});
|
||||
expect(listSubjectTeamIds).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('does not query storage for sensitive get resources even if scope allowed them', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(service.get({ filter: CREDENTIAL_FILTER, id: 'cred-1' })).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'credentials federation get is not implemented',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed for unsupported resources instead of returning undefined', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
...CREDENTIAL_FILTER,
|
||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||
},
|
||||
id: 'row-1',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Unsupported federation get resource: unknown-resource',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not leak another user mission task note through team-scoped get reads', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: OTHER_TEAM_NOTE_ID,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Note is outside the federated scope',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not return subject notes from missions outside the grant-visible project set', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: true,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Note is outside the federated scope',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a subject note only when subject ownership and authorized mission intersect', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: SUBJECT_TEAM_NOTE_ID,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: 'found',
|
||||
item: {
|
||||
id: SUBJECT_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
content: 'subject note on team mission',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not return subject personal notes when includePersonal is false', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Note is outside the federated scope',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'reflect-metadata';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
||||
import type {
|
||||
FederationScopeEvaluationResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from '../../scope.service.js';
|
||||
import { GetController } from '../get.controller.js';
|
||||
import type { FederationGetQueryResult } from '../get-query.service.js';
|
||||
|
||||
const FEDERATION_CONTEXT = {
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
scope: { resources: ['tasks'], max_rows_per_query: 25 },
|
||||
};
|
||||
|
||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
||||
resource: 'tasks',
|
||||
subjectUserId: 'user-1',
|
||||
includePersonal: true,
|
||||
teamIds: ['team-1'],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 25,
|
||||
};
|
||||
|
||||
function makeRequest(): FastifyRequest {
|
||||
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
|
||||
}
|
||||
|
||||
function allowedScope(
|
||||
filter: FederationScopeQueryFilter = TASK_FILTER,
|
||||
): FederationScopeEvaluationResult {
|
||||
return { allowed: true, filter };
|
||||
}
|
||||
|
||||
function makeController(opts?: {
|
||||
scopeResult?: FederationScopeEvaluationResult;
|
||||
queryResult?: FederationGetQueryResult;
|
||||
}) {
|
||||
const scope = {
|
||||
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
|
||||
};
|
||||
const query = {
|
||||
evaluateReadAccess: vi.fn(),
|
||||
get: vi.fn().mockResolvedValue(
|
||||
opts?.queryResult ?? {
|
||||
status: 'found',
|
||||
item: {
|
||||
id: 'task-1',
|
||||
title: 'Federated task',
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||
},
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
controller: new GetController(scope as never, query as never),
|
||||
scope,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GetController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('declares POST /api/federation/v1/get/:resource/:id protected only by FederationAuthGuard', () => {
|
||||
expect(Reflect.getMetadata('path', GetController)).toBe('api/federation/v1/get');
|
||||
expect(Reflect.getMetadata('path', GetController.prototype.get)).toBe(':resource/:id');
|
||||
expect(Reflect.getMetadata('method', GetController.prototype.get)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata('__guards__', GetController)).toEqual([FederationAuthGuard]);
|
||||
});
|
||||
|
||||
it('runs AuthGuard context through ScopeService and returns one local-source tagged row', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
const response = await controller.get('tasks', 'task-1', makeRequest());
|
||||
|
||||
expect(scope.evaluateAccess).toHaveBeenCalledWith({
|
||||
context: FEDERATION_CONTEXT,
|
||||
resource: 'tasks',
|
||||
requestedLimit: 1,
|
||||
nativeRbac: query,
|
||||
});
|
||||
expect(query.get).toHaveBeenCalledWith({ filter: TASK_FILTER, id: 'task-1' });
|
||||
expect(response).toEqual({
|
||||
item: {
|
||||
id: 'task-1',
|
||||
title: 'Federated task',
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||
_source: 'local',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a federation error envelope when auth guard context is missing', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
await expect(
|
||||
controller.get('tasks', 'task-1', {} as unknown as FastifyRequest),
|
||||
).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'unauthorized',
|
||||
message: 'Federation context missing',
|
||||
},
|
||||
},
|
||||
status: 401,
|
||||
});
|
||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||
expect(query.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a federation error envelope when scope evaluation denies access', async () => {
|
||||
const { controller, query } = makeController({
|
||||
scopeResult: {
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'resource_excluded',
|
||||
stage: 'resource_exclusion',
|
||||
statusCode: 403,
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'credentials',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(controller.get('credentials', 'cred-1', makeRequest())).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
expect(query.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when the scoped query layer cannot find the resource id', async () => {
|
||||
const { controller } = makeController({ queryResult: { status: 'not_found' } });
|
||||
|
||||
await expect(controller.get('tasks', 'missing-task', makeRequest())).rejects.toMatchObject({
|
||||
response: { error: { code: 'not_found' } },
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 403 when the resource exists outside the RBAC/scope intersection', async () => {
|
||||
const { controller } = makeController({
|
||||
queryResult: { status: 'denied', reason: 'Task is outside the federated scope' },
|
||||
});
|
||||
|
||||
await expect(controller.get('tasks', 'task-2', makeRequest())).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Task is outside the federated scope',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when the query layer denies an unsupported resource', async () => {
|
||||
const unsupportedFilter: FederationScopeQueryFilter = {
|
||||
...TASK_FILTER,
|
||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||
};
|
||||
const { controller } = makeController({
|
||||
scopeResult: allowedScope(unsupportedFilter),
|
||||
queryResult: {
|
||||
status: 'denied',
|
||||
reason: 'Unsupported federation get resource: unknown-resource',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(controller.get('unknown-resource', 'row-1', makeRequest())).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Unsupported federation get resource: unknown-resource',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects empty ids before evaluating scope', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
await expect(controller.get('tasks', ' ', makeRequest())).rejects.toMatchObject({
|
||||
response: { error: { code: 'invalid_request' } },
|
||||
status: 400,
|
||||
});
|
||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||
expect(query.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Federation get query layer (FED-M3-06).
|
||||
*
|
||||
* Read-only DB adapter used by GetController after FederationAuthGuard and
|
||||
* FederationScopeService have established the subject user, allowed resource,
|
||||
* native-RBAC intersection, and row cap. Audit writes are intentionally
|
||||
* deferred to M4.
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
insights,
|
||||
or,
|
||||
missionTasks,
|
||||
missions,
|
||||
preferences,
|
||||
projects,
|
||||
tasks,
|
||||
teamMembers,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../../../database/database.module.js';
|
||||
import type {
|
||||
FederationNativeRbacEvaluator,
|
||||
FederationNativeRbacRequest,
|
||||
FederationNativeRbacResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from '../scope.service.js';
|
||||
|
||||
export interface FederationGetQueryRequest {
|
||||
readonly filter: FederationScopeQueryFilter;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export interface FederationGetQueryFoundResult<T extends object = Record<string, unknown>> {
|
||||
readonly status: 'found';
|
||||
readonly item: T;
|
||||
}
|
||||
|
||||
export interface FederationGetQueryNotFoundResult {
|
||||
readonly status: 'not_found';
|
||||
}
|
||||
|
||||
export interface FederationGetQueryDeniedResult {
|
||||
readonly status: 'denied';
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
export type FederationGetQueryResult<T extends object = Record<string, unknown>> =
|
||||
| FederationGetQueryFoundResult<T>
|
||||
| FederationGetQueryNotFoundResult
|
||||
| FederationGetQueryDeniedResult;
|
||||
|
||||
type RowObject = Record<string, unknown>;
|
||||
|
||||
function firstRow<T>(rows: T[]): T | undefined {
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
function rowBelongsToAccessibleProjectOrMission(
|
||||
row: { projectId?: string | null; missionId?: string | null },
|
||||
projectIds: readonly string[],
|
||||
missionIds: readonly string[],
|
||||
): boolean {
|
||||
return (
|
||||
(typeof row.projectId === 'string' && projectIds.includes(row.projectId)) ||
|
||||
(typeof row.missionId === 'string' && missionIds.includes(row.missionId))
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FederationGetQueryService implements FederationNativeRbacEvaluator {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async evaluateReadAccess(
|
||||
request: FederationNativeRbacRequest,
|
||||
): Promise<FederationNativeRbacResult> {
|
||||
if (request.resource === 'credentials' || request.resource === 'api_keys') {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `${request.resource} federation get access is not implemented in M3`,
|
||||
details: { resource: request.resource },
|
||||
};
|
||||
}
|
||||
|
||||
if (request.resource === 'memory') {
|
||||
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
|
||||
}
|
||||
|
||||
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
|
||||
return { allowed: true, access: { includePersonal: true, teamIds } };
|
||||
}
|
||||
|
||||
async get<T extends RowObject = RowObject>(
|
||||
request: FederationGetQueryRequest,
|
||||
): Promise<FederationGetQueryResult<T>> {
|
||||
return this.getByResource(request.filter, request.id) as Promise<FederationGetQueryResult<T>>;
|
||||
}
|
||||
|
||||
private async getByResource(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
switch (filter.resource) {
|
||||
case 'tasks':
|
||||
return this.getTask(filter, id);
|
||||
case 'notes':
|
||||
return this.getNote(filter, id);
|
||||
case 'memory':
|
||||
return this.getMemory(filter, id);
|
||||
case 'credentials':
|
||||
case 'api_keys':
|
||||
return { status: 'denied', reason: `${filter.resource} federation get is not implemented` };
|
||||
default:
|
||||
return {
|
||||
status: 'denied',
|
||||
reason: `Unsupported federation get resource: ${String(filter.resource)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.userId, subjectUserId));
|
||||
|
||||
return rows.map((row) => row.teamId);
|
||||
}
|
||||
|
||||
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
|
||||
const clauses = [];
|
||||
if (filter.includePersonal) {
|
||||
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
|
||||
}
|
||||
if (filter.teamIds.length > 0) {
|
||||
// Project team ownership follows TeamsService.canAccessProject: team-owned
|
||||
// rows are authorized through projects.teamId, while ownerId remains the
|
||||
// user who created/bootstrapped the project.
|
||||
clauses.push(
|
||||
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
|
||||
);
|
||||
}
|
||||
|
||||
if (clauses.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
|
||||
if (projectIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({ id: missions.id })
|
||||
.from(missions)
|
||||
.where(inArray(missions.projectId, [...projectIds]));
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
private async getTask(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
const row = firstRow(
|
||||
await this.db
|
||||
.select({
|
||||
id: tasks.id,
|
||||
title: tasks.title,
|
||||
description: tasks.description,
|
||||
status: tasks.status,
|
||||
priority: tasks.priority,
|
||||
projectId: tasks.projectId,
|
||||
missionId: tasks.missionId,
|
||||
assignee: tasks.assignee,
|
||||
tags: tasks.tags,
|
||||
dueDate: tasks.dueDate,
|
||||
metadata: tasks.metadata,
|
||||
createdAt: tasks.createdAt,
|
||||
updatedAt: tasks.updatedAt,
|
||||
})
|
||||
.from(tasks)
|
||||
.where(eq(tasks.id, id))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||
const missionIds = await this.listMissionIds(projectIds);
|
||||
if (!rowBelongsToAccessibleProjectOrMission(row, projectIds, missionIds)) {
|
||||
return { status: 'denied', reason: 'Task is outside the federated scope' };
|
||||
}
|
||||
|
||||
return { status: 'found', item: row as RowObject };
|
||||
}
|
||||
|
||||
private async getNote(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
const row = firstRow(
|
||||
await this.db
|
||||
.select({
|
||||
id: missionTasks.id,
|
||||
missionId: missionTasks.missionId,
|
||||
taskId: missionTasks.taskId,
|
||||
userId: missionTasks.userId,
|
||||
status: missionTasks.status,
|
||||
content: missionTasks.notes,
|
||||
createdAt: missionTasks.createdAt,
|
||||
updatedAt: missionTasks.updatedAt,
|
||||
})
|
||||
.from(missionTasks)
|
||||
.where(eq(missionTasks.id, id))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
if (!row || row.content === null || row.content === '') {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||
const missionIds = await this.listMissionIds(projectIds);
|
||||
|
||||
// mission_tasks rows are user-scoped even when the mission belongs to a team.
|
||||
// Scope-visible missions must intersect with subject ownership; team scope
|
||||
// narrows mission IDs but never widens note reads to another user's rows.
|
||||
if (row.userId !== filter.subjectUserId || !missionIds.includes(row.missionId)) {
|
||||
return { status: 'denied', reason: 'Note is outside the federated scope' };
|
||||
}
|
||||
|
||||
const item = { ...row } as RowObject;
|
||||
delete item['userId'];
|
||||
return { status: 'found', item };
|
||||
}
|
||||
|
||||
private async getMemory(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
const [insightRow, preferenceRow] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
id: insights.id,
|
||||
userId: insights.userId,
|
||||
kind: insights.source,
|
||||
content: insights.content,
|
||||
category: insights.category,
|
||||
relevanceScore: insights.relevanceScore,
|
||||
metadata: insights.metadata,
|
||||
createdAt: insights.createdAt,
|
||||
updatedAt: insights.updatedAt,
|
||||
})
|
||||
.from(insights)
|
||||
.where(eq(insights.id, id))
|
||||
.limit(1)
|
||||
.then(firstRow),
|
||||
this.db
|
||||
.select({
|
||||
id: preferences.id,
|
||||
userId: preferences.userId,
|
||||
kind: preferences.category,
|
||||
key: preferences.key,
|
||||
value: preferences.value,
|
||||
source: preferences.source,
|
||||
mutable: preferences.mutable,
|
||||
createdAt: preferences.createdAt,
|
||||
updatedAt: preferences.updatedAt,
|
||||
})
|
||||
.from(preferences)
|
||||
.where(eq(preferences.id, id))
|
||||
.limit(1)
|
||||
.then(firstRow),
|
||||
]);
|
||||
|
||||
const candidates = [insightRow, preferenceRow].filter(
|
||||
(row): row is NonNullable<typeof row> => row !== undefined,
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
if (!filter.includePersonal) {
|
||||
return { status: 'denied', reason: 'Memory personal rows are outside the federated scope' };
|
||||
}
|
||||
|
||||
const accessible = candidates.find((row) => row.userId === filter.subjectUserId);
|
||||
if (!accessible) {
|
||||
return { status: 'denied', reason: 'Memory row belongs to another subject user' };
|
||||
}
|
||||
|
||||
const item = { ...accessible } as RowObject;
|
||||
delete item['userId'];
|
||||
return { status: 'found', item };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Federation get verb (FED-M3-06).
|
||||
*
|
||||
* POST /api/federation/v1/get/:resource/:id
|
||||
*
|
||||
* Pipeline: FederationAuthGuard attaches the active grant context, then
|
||||
* FederationScopeService enforces grant scope + native RBAC intersection, then
|
||||
* the read-only query layer fetches one local row and tags it with `_source`.
|
||||
* Read audit-log writes are deferred to M4; this controller does not persist
|
||||
* request or response bodies.
|
||||
*/
|
||||
|
||||
import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import {
|
||||
FederationInvalidRequestError,
|
||||
FederationNotFoundError,
|
||||
FederationScopeViolationError,
|
||||
FederationUnauthorizedError,
|
||||
SOURCE_LOCAL,
|
||||
type FederationGetResponse,
|
||||
type SourceTag,
|
||||
} from '@mosaicstack/types';
|
||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
||||
import '../federation-context.js';
|
||||
import { FederationScopeService } from '../scope.service.js';
|
||||
import { FederationGetQueryService } from './get-query.service.js';
|
||||
|
||||
type FederatedRow = Record<string, unknown> & SourceTag;
|
||||
|
||||
function scopeDenyToHttpException(deny: {
|
||||
readonly statusCode: 400 | 403;
|
||||
readonly message: string;
|
||||
}): HttpException {
|
||||
const ErrorClass =
|
||||
deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError;
|
||||
return new HttpException(new ErrorClass(deny.message, deny).toEnvelope(), deny.statusCode);
|
||||
}
|
||||
|
||||
@Controller('api/federation/v1/get')
|
||||
@UseGuards(FederationAuthGuard)
|
||||
export class GetController {
|
||||
constructor(
|
||||
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
|
||||
@Inject(FederationGetQueryService) private readonly query: FederationGetQueryService,
|
||||
) {}
|
||||
|
||||
@Post(':resource/:id')
|
||||
async get(
|
||||
@Param('resource') resource: string,
|
||||
@Param('id') id: string,
|
||||
@Req() request: FastifyRequest,
|
||||
): Promise<FederationGetResponse<FederatedRow>> {
|
||||
if (!request.federationContext) {
|
||||
throw new HttpException(
|
||||
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
|
||||
401,
|
||||
);
|
||||
}
|
||||
if (id.trim().length === 0) {
|
||||
throw new HttpException(
|
||||
new FederationInvalidRequestError('Federation get id must not be empty').toEnvelope(),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const scopeResult = await this.scope.evaluateAccess({
|
||||
context: request.federationContext,
|
||||
resource,
|
||||
requestedLimit: 1,
|
||||
nativeRbac: this.query,
|
||||
});
|
||||
|
||||
if (!scopeResult.allowed) {
|
||||
throw scopeDenyToHttpException(scopeResult.deny);
|
||||
}
|
||||
|
||||
const result = await this.query.get({ filter: scopeResult.filter, id });
|
||||
if (result.status === 'not_found') {
|
||||
throw new HttpException(
|
||||
new FederationNotFoundError('Requested federation resource was not found').toEnvelope(),
|
||||
404,
|
||||
);
|
||||
}
|
||||
if (result.status === 'denied') {
|
||||
throw new HttpException(
|
||||
new FederationScopeViolationError(result.reason, {
|
||||
resource,
|
||||
id,
|
||||
grantId: request.federationContext.grantId,
|
||||
peerId: request.federationContext.peerId,
|
||||
subjectUserId: request.federationContext.subjectUserId,
|
||||
}).toEnvelope(),
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
return { item: { ...result.item, _source: SOURCE_LOCAL } };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
|
||||
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { SessionGCService } from './session-gc.service.js';
|
||||
import { REDIS } from './gc.tokens.js';
|
||||
|
||||
@@ -9,13 +11,17 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||
providers: [
|
||||
{
|
||||
provide: GC_QUEUE_HANDLE,
|
||||
useFactory: (): QueueHandle => {
|
||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||
// On Local tier there is no Redis — skip the ioredis connection entirely.
|
||||
// The Valkey GC sweep is a no-op on Local (no session keys stored there).
|
||||
if (config?.queue?.type === 'local') return null;
|
||||
return createQueue();
|
||||
},
|
||||
inject: [MOSAIC_CONFIG],
|
||||
},
|
||||
{
|
||||
provide: REDIS,
|
||||
useFactory: (handle: QueueHandle) => handle.redis,
|
||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||
inject: [GC_QUEUE_HANDLE],
|
||||
},
|
||||
SessionGCService,
|
||||
@@ -23,9 +29,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||
exports: [SessionGCService],
|
||||
})
|
||||
export class GCModule implements OnApplicationShutdown {
|
||||
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(GC_QUEUE_HANDLE)
|
||||
private readonly handle: QueueHandle | null,
|
||||
) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle.close().catch(() => {});
|
||||
await this.handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,19 @@ describe('SessionGCService', () => {
|
||||
).resolves.toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it('collect() skips Valkey but still demotes only the requested session on local tier', async () => {
|
||||
const localService = new SessionGCService(null, mockLogService as unknown as LogService);
|
||||
|
||||
const result = await localService.collect('local-session');
|
||||
|
||||
expect(result.sessionId).toBe('local-session');
|
||||
expect(result.cleaned.valkeyKeys).toBeUndefined();
|
||||
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
|
||||
'local-session',
|
||||
expect.any(Date),
|
||||
);
|
||||
});
|
||||
|
||||
it('collect() returns sessionId in result', async () => {
|
||||
const result = await service.collect('test-session-id');
|
||||
expect(result.sessionId).toBe('test-session-id');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import type { QueueHandle } from '@mosaicstack/queue';
|
||||
import type { LogService } from '@mosaicstack/log';
|
||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||
@@ -21,7 +21,10 @@ function escapeRedisGlobLiteral(value: string): string {
|
||||
@Injectable()
|
||||
export class SessionGCService {
|
||||
constructor(
|
||||
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||
// Local tier has no Redis; lifecycle cleanup still demotes this session's logs.
|
||||
@Optional()
|
||||
@Inject(REDIS)
|
||||
private readonly redis: QueueHandle['redis'] | null,
|
||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||
) {}
|
||||
|
||||
@@ -29,8 +32,10 @@ export class SessionGCService {
|
||||
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
|
||||
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
||||
* duration, which can cause latency spikes under production key volumes.
|
||||
* Returns an empty population on the Local tier where Redis is disabled.
|
||||
*/
|
||||
private async scanKeys(pattern: string): Promise<string[]> {
|
||||
if (!this.redis) return [];
|
||||
const collected: string[] = [];
|
||||
let cursor = '0';
|
||||
do {
|
||||
@@ -47,12 +52,14 @@ export class SessionGCService {
|
||||
async collect(sessionId: string): Promise<GCResult> {
|
||||
const result: GCResult = { sessionId, cleaned: {} };
|
||||
|
||||
// 1. Valkey: delete all session-scoped keys
|
||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||
const valkeyKeys = await this.scanKeys(pattern);
|
||||
if (valkeyKeys.length > 0) {
|
||||
await this.redis.del(...valkeyKeys);
|
||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||
// 1. Valkey: delete all session-scoped keys (skipped on Local tier).
|
||||
if (this.redis) {
|
||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||
const valkeyKeys = await this.scanKeys(pattern);
|
||||
if (valkeyKeys.length > 0) {
|
||||
await this.redis.del(...valkeyKeys);
|
||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. PG: demote hot-tier agent logs for this session only.
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
|
||||
@Injectable()
|
||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CronService.name);
|
||||
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
|
||||
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
|
||||
|
||||
constructor(
|
||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||
@@ -26,6 +26,12 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// Local tier deliberately has no BullMQ consumers or repeatable jobs.
|
||||
if (!this.queueService.isEnabled()) {
|
||||
this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled');
|
||||
return;
|
||||
}
|
||||
|
||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||
|
||||
@@ -39,7 +45,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
||||
await this.summarization.runSummarization();
|
||||
});
|
||||
this.registeredWorkers.push(summarizationWorker);
|
||||
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
|
||||
|
||||
// M6-005: Tier management repeatable job
|
||||
await this.queueService.addRepeatableJob(
|
||||
@@ -51,7 +57,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
||||
await this.summarization.runTierManagement();
|
||||
});
|
||||
this.registeredWorkers.push(tierWorker);
|
||||
if (tierWorker) this.registeredWorkers.push(tierWorker);
|
||||
|
||||
// Retire any repeatable global GC schedule created by older deployments.
|
||||
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { McpClientService } from './mcp-client.service.js';
|
||||
|
||||
const MCP_LEAK_MARKER = 'MCP_LEAK_MARKER /srv/secret';
|
||||
|
||||
describe('McpClientService — failed connect error sanitization', () => {
|
||||
const originalMcpServers = process.env['MCP_SERVERS'];
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['MCP_SERVERS'] = JSON.stringify([
|
||||
{ name: 'leaky-server', url: 'http://localhost:9999/mcp' },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalMcpServers === undefined) {
|
||||
delete process.env['MCP_SERVERS'];
|
||||
} else {
|
||||
process.env['MCP_SERVERS'] = originalMcpServers;
|
||||
}
|
||||
});
|
||||
|
||||
it('stores a generic serverEntry.error while logging the raw exception server-side', async () => {
|
||||
vi.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error(MCP_LEAK_MARKER));
|
||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
const service = new McpClientService();
|
||||
await service.onModuleInit();
|
||||
|
||||
const statuses = service.getServerStatuses();
|
||||
expect(statuses).toHaveLength(1);
|
||||
expect(statuses[0]?.connected).toBe(false);
|
||||
expect(statuses[0]?.error).toBe('Connection failed (see server logs).');
|
||||
expect(statuses[0]?.error).not.toContain(MCP_LEAK_MARKER);
|
||||
|
||||
const loggedRawMarker = errorSpy.mock.calls.some((call) =>
|
||||
call.some((arg) => typeof arg === 'string' && arg.includes(MCP_LEAK_MARKER)),
|
||||
);
|
||||
expect(loggedRawMarker).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -189,7 +189,7 @@ export class McpClientService implements OnModuleInit, OnModuleDestroy {
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
serverEntry.error = message;
|
||||
serverEntry.error = 'Connection failed (see server logs).';
|
||||
serverEntry.connected = false;
|
||||
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { SystemOverrideService } from './system-override.service.js';
|
||||
|
||||
const localConfig = { queue: { type: 'local' } } as MosaicConfig;
|
||||
|
||||
describe('SystemOverrideService local tier', () => {
|
||||
it('keeps ephemeral overrides isolated by tenant and user scope', async () => {
|
||||
const service = new SystemOverrideService(localConfig);
|
||||
const firstScope = { tenantId: 'tenant-a', userId: 'user-a' };
|
||||
const secondScope = { tenantId: 'tenant-b', userId: 'user-b' };
|
||||
|
||||
await service.set('shared-session', 'first override', firstScope);
|
||||
await service.set('shared-session', 'second override', secondScope);
|
||||
|
||||
await expect(service.get('shared-session', firstScope)).resolves.toBe('first override');
|
||||
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
|
||||
|
||||
await service.clear('shared-session', firstScope);
|
||||
await expect(service.get('shared-session', firstScope)).resolves.toBeNull();
|
||||
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
|
||||
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
||||
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
||||
@@ -15,16 +17,45 @@ interface OverrideFragment {
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SystemOverrideService {
|
||||
private readonly logger = new Logger(SystemOverrideService.name);
|
||||
private readonly handle: QueueHandle;
|
||||
interface LocalOverrideEntry {
|
||||
condensed: string;
|
||||
fragments: OverrideFragment[];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.handle = createQueue();
|
||||
@Injectable()
|
||||
export class SystemOverrideService implements OnApplicationShutdown {
|
||||
private readonly logger = new Logger(SystemOverrideService.name);
|
||||
private readonly handle: QueueHandle | null;
|
||||
/** Local-tier fallback, keyed by the same tenant/user/session scope as Redis. */
|
||||
private readonly localStore = new Map<string, LocalOverrideEntry>();
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {
|
||||
this.handle = this.mosaicConfig?.queue?.type === 'local' ? null : createQueue();
|
||||
}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle?.close().catch(() => {});
|
||||
}
|
||||
|
||||
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
||||
if (!this.handle) {
|
||||
const key = scopedSessionId(sessionId, scope);
|
||||
const entry = this.localStore.get(key) ?? { condensed: '', fragments: [] };
|
||||
entry.fragments.push({ text: override, addedAt: Date.now() });
|
||||
entry.condensed = await this.condenseOverrides(
|
||||
entry.fragments.map((fragment) => fragment.text),
|
||||
);
|
||||
this.localStore.set(key, entry);
|
||||
this.logger.debug(
|
||||
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load existing fragments
|
||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
||||
const fragments: OverrideFragment[] = existing
|
||||
@@ -54,10 +85,14 @@ export class SystemOverrideService {
|
||||
}
|
||||
|
||||
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
||||
if (!this.handle) {
|
||||
return this.localStore.get(scopedSessionId(sessionId, scope))?.condensed ?? null;
|
||||
}
|
||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
||||
}
|
||||
|
||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
if (!this.handle) return;
|
||||
const pipeline = this.handle.redis.pipeline();
|
||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
@@ -65,6 +100,11 @@ export class SystemOverrideService {
|
||||
}
|
||||
|
||||
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
if (!this.handle) {
|
||||
this.localStore.delete(scopedSessionId(sessionId, scope));
|
||||
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
|
||||
return;
|
||||
}
|
||||
await this.handle.redis.del(
|
||||
SESSION_SYSTEM_KEY(sessionId, scope),
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { QueueService } from './queue.service.js';
|
||||
|
||||
const localConfig = {
|
||||
queue: { type: 'local' },
|
||||
} as MosaicConfig;
|
||||
|
||||
describe('QueueService local tier', () => {
|
||||
it('disables BullMQ and treats queue operations as local no-ops', async () => {
|
||||
const service = new QueueService(null, localConfig);
|
||||
|
||||
expect(service.isEnabled()).toBe(false);
|
||||
expect(service.getQueue('mosaic-test')).toBeNull();
|
||||
expect(service.registerWorker('mosaic-test', vi.fn())).toBeNull();
|
||||
|
||||
await expect(
|
||||
service.addRepeatableJob('mosaic-test', 'local-noop', {}, '* * * * *'),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(service.removeRepeatableJobs('mosaic-test', 'local-noop')).resolves.toBe(0);
|
||||
await expect(service.getHealthStatus()).resolves.toEqual({ queues: {}, healthy: true });
|
||||
await expect(service.listJobs()).resolves.toEqual([]);
|
||||
await expect(service.retryJob('mosaic-test__1')).resolves.toEqual({
|
||||
ok: false,
|
||||
message: 'BullMQ is disabled on local tier.',
|
||||
});
|
||||
await expect(service.pauseQueue('mosaic-test')).resolves.toEqual({
|
||||
ok: false,
|
||||
message: 'BullMQ is disabled on local tier.',
|
||||
});
|
||||
await expect(service.resumeQueue('mosaic-test')).resolves.toEqual({
|
||||
ok: false,
|
||||
message: 'BullMQ is disabled on local tier.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
||||
import type { LogService } from '@mosaicstack/log';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import type { JobDto, JobStatus } from './queue-admin.dto.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -108,21 +110,42 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly connection: ConnectionOptions;
|
||||
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
||||
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
||||
/** False on Local tier — BullMQ/Redis operations become no-ops. */
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(LOG_SERVICE)
|
||||
private readonly logService: LogService | null,
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {
|
||||
this.connection = getConnection();
|
||||
this.enabled = this.mosaicConfig?.queue?.type !== 'local';
|
||||
this.connection = this.enabled
|
||||
? getConnection()
|
||||
: ({ host: '127.0.0.1', port: 6380 } as ConnectionOptions);
|
||||
}
|
||||
|
||||
/** Returns true when BullMQ/Redis is active (Standalone and Federated tiers). */
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.logger.log('QueueService initialised (BullMQ)');
|
||||
if (this.enabled) {
|
||||
this.logger.log('QueueService initialised (BullMQ)');
|
||||
} else {
|
||||
this.logger.log(
|
||||
'QueueService: BullMQ disabled for local tier — no Redis connections will be opened',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.closeAll();
|
||||
if (this.enabled) {
|
||||
await this.closeAll();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -131,8 +154,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/**
|
||||
* Get or create a BullMQ Queue for the given queue name.
|
||||
* Returns null on Local tier where BullMQ is disabled.
|
||||
*/
|
||||
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
|
||||
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
|
||||
if (!this.enabled) return null;
|
||||
let queue = this.queues.get(name) as Queue<T> | undefined;
|
||||
if (!queue) {
|
||||
queue = new Queue<T>(name, { connection: this.connection });
|
||||
@@ -144,6 +169,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* Add a BullMQ repeatable job (cron-style).
|
||||
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
||||
* No-op on Local tier.
|
||||
*/
|
||||
async addRepeatableJob<T extends MosaicJobData>(
|
||||
queueName: string,
|
||||
@@ -151,7 +177,13 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
data: T,
|
||||
cronExpression: string,
|
||||
): Promise<void> {
|
||||
const queue = this.getQueue<T>(queueName);
|
||||
if (!this.enabled) {
|
||||
this.logger.debug(
|
||||
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const queue = this.getQueue<T>(queueName)!;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (queue as Queue<any>).add(jobName, data, {
|
||||
repeat: { pattern: cronExpression },
|
||||
@@ -167,7 +199,14 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* safe retirement of previously registered system-wide jobs.
|
||||
*/
|
||||
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
||||
if (!this.enabled) {
|
||||
this.logger.debug(
|
||||
`Skipping repeatable-job removal for "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
const queue = this.getQueue(queueName);
|
||||
if (!queue) return 0;
|
||||
const jobs = await queue.getRepeatableJobs();
|
||||
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
||||
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
||||
@@ -182,8 +221,18 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* Register a Worker for the given queue name with error handling and
|
||||
* exponential backoff.
|
||||
* Returns null on Local tier where BullMQ is disabled.
|
||||
*/
|
||||
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
|
||||
registerWorker<T extends MosaicJobData>(
|
||||
queueName: string,
|
||||
handler: JobHandler<T>,
|
||||
): Worker<T> | null {
|
||||
if (!this.enabled) {
|
||||
this.logger.debug(
|
||||
`Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const worker = new Worker<T>(
|
||||
queueName,
|
||||
async (job) => {
|
||||
@@ -240,8 +289,12 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/**
|
||||
* Return queue health statistics for all managed queues.
|
||||
* Returns an empty healthy result on Local tier.
|
||||
*/
|
||||
async getHealthStatus(): Promise<QueueHealthStatus> {
|
||||
if (!this.enabled) {
|
||||
return { queues: {}, healthy: true };
|
||||
}
|
||||
const queues: QueueHealthStatus['queues'] = {};
|
||||
let healthy = true;
|
||||
|
||||
@@ -272,8 +325,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* List jobs across all managed queues, optionally filtered by status.
|
||||
* BullMQ jobs are fetched by state type from each queue.
|
||||
* Returns empty array on Local tier.
|
||||
*/
|
||||
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
||||
if (!this.enabled) return [];
|
||||
const jobs: JobDto[] = [];
|
||||
const states: JobStatus[] = status
|
||||
? [status]
|
||||
@@ -300,8 +355,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
|
||||
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
||||
* job IDs are not globally unique — they are scoped to their queue.
|
||||
* Returns an error on Local tier.
|
||||
*/
|
||||
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||
const sep = compositeId.lastIndexOf('__');
|
||||
if (sep === -1) {
|
||||
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
|
||||
@@ -333,6 +390,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Pause a queue by name.
|
||||
*/
|
||||
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||
const queue = this.queues.get(name);
|
||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||
await queue.pause();
|
||||
@@ -344,6 +402,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Resume a paused queue by name.
|
||||
*/
|
||||
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||
const queue = this.queues.get(name);
|
||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||
await queue.resume();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SlashCommandPayload, SystemReloadPayload } from '@mosaicstack/types';
|
||||
import { ReloadService } from './reload.service.js';
|
||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||
|
||||
function createMockCommandRegistry() {
|
||||
return {
|
||||
@@ -104,3 +107,79 @@ describe('ReloadService', () => {
|
||||
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
||||
it('generic per-plugin errors reach the chat surface while raw markers stay server-side only', async () => {
|
||||
const registry = {
|
||||
getManifest: vi.fn().mockReturnValue({
|
||||
version: 1,
|
||||
commands: [
|
||||
{ name: 'reload', aliases: [], scope: 'core', execution: 'socket', available: true },
|
||||
],
|
||||
skills: [],
|
||||
}),
|
||||
};
|
||||
const reloadService = new ReloadService(registry as never);
|
||||
|
||||
const RELOAD_LOAD_LEAK_MARKER = 'RELOAD_LOAD_LEAK_MARKER /srv/load-secret';
|
||||
const RELOAD_UNLOAD_LEAK_MARKER = 'RELOAD_UNLOAD_LEAK_MARKER /srv/unload-secret';
|
||||
|
||||
reloadService.registerPlugin('unload-fails', {
|
||||
pluginName: 'unload-fails',
|
||||
onLoad: vi.fn().mockResolvedValue(undefined),
|
||||
onUnload: vi.fn().mockRejectedValue(new Error(RELOAD_UNLOAD_LEAK_MARKER)),
|
||||
});
|
||||
reloadService.registerPlugin('load-fails', {
|
||||
pluginName: 'load-fails',
|
||||
onLoad: vi.fn().mockRejectedValue(new Error(RELOAD_LOAD_LEAK_MARKER)),
|
||||
onUnload: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const broadcastReload = vi.fn();
|
||||
const mockChatGateway = { broadcastReload };
|
||||
const mockAgentService = { getSession: vi.fn(), applyAgentConfig: vi.fn() };
|
||||
const mockSystemOverride = { set: vi.fn(), get: vi.fn(), clear: vi.fn() };
|
||||
const mockSessionGC = { sweepOrphans: vi.fn() };
|
||||
const mockBrain = { agents: { findByName: vi.fn(), findById: vi.fn(), create: vi.fn() } };
|
||||
|
||||
const executor = new CommandExecutorService(
|
||||
registry as never,
|
||||
mockAgentService as never,
|
||||
mockSystemOverride as never,
|
||||
mockSessionGC as never,
|
||||
null,
|
||||
mockBrain as never,
|
||||
reloadService,
|
||||
mockChatGateway as never,
|
||||
null,
|
||||
);
|
||||
|
||||
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
||||
const result = await executor.execute(payload, { userId: 'user-1', tenantId: 'user-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('unload-fails: unload failed (internal error)');
|
||||
expect(result.message).toContain('load-fails: load failed (internal error)');
|
||||
expect(result.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
|
||||
expect(result.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
|
||||
|
||||
expect(broadcastReload).toHaveBeenCalledOnce();
|
||||
const broadcastPayload = broadcastReload.mock.calls[0]?.[0] as SystemReloadPayload;
|
||||
expect(broadcastPayload.message).toContain('unload-fails: unload failed (internal error)');
|
||||
expect(broadcastPayload.message).toContain('load-fails: load failed (internal error)');
|
||||
expect(broadcastPayload.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
|
||||
expect(broadcastPayload.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
|
||||
|
||||
const loggedUnloadMarker = errorSpy.mock.calls.some((call) =>
|
||||
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_UNLOAD_LEAK_MARKER)),
|
||||
);
|
||||
const loggedLoadMarker = errorSpy.mock.calls.some((call) =>
|
||||
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_LOAD_LEAK_MARKER)),
|
||||
);
|
||||
expect(loggedUnloadMarker).toBe(true);
|
||||
expect(loggedLoadMarker).toBe(true);
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,7 +58,8 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
|
||||
await plugin.onUnload();
|
||||
reloaded.push(name);
|
||||
} catch (err) {
|
||||
errors.push(`${name}: unload failed — ${err}`);
|
||||
this.logger.error(`Plugin "${name}" failed during onUnload: ${err}`);
|
||||
errors.push(`${name}: unload failed (internal error)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +70,8 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
|
||||
try {
|
||||
await plugin.onLoad();
|
||||
} catch (err) {
|
||||
errors.push(`${name}: load failed — ${err}`);
|
||||
this.logger.error(`Plugin "${name}" failed during onLoad: ${err}`);
|
||||
errors.push(`${name}: load failed (internal error)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'reflect-metadata';
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
type INestApplication,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||
import { WorkspaceController } from './workspace.controller.js';
|
||||
|
||||
const bootstrapMock = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
projectId: 'project-1',
|
||||
workspacePath: '/opt/mosaic/.workspaces/users/user-1/project-1',
|
||||
}),
|
||||
);
|
||||
|
||||
const authGuard: CanActivate = {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
||||
requestContext.user = { id: 'user-1' };
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
describe('POST /api/workspaces repoUrl validation', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [WorkspaceController],
|
||||
providers: [
|
||||
{
|
||||
provide: ProjectBootstrapService,
|
||||
useValue: { bootstrap: bootstrapMock },
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(authGuard)
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
bootstrapMock.mockClear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a leading-dash value', '--upload-pack=sh -c id'],
|
||||
['an ext remote helper', 'ext::sh -c id'],
|
||||
['a file URL', 'file:///tmp/repository'],
|
||||
['an unparseable value', 'not a url'],
|
||||
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
||||
['a scheme without //', 'https:example.com/acme/repository.git'],
|
||||
['a hostless git URL', 'git:///tmp/repository'],
|
||||
])('returns 400 for %s', async (_description, repoUrl) => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/workspaces')
|
||||
.send({ name: 'Example', repoUrl })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(bootstrapMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a plain HTTPS repository URL', 'https://example.com/acme/repository.git'],
|
||||
['a git protocol repository URL', 'git://example.com/acme/repository.git'],
|
||||
])('accepts %s', async (_description, repoUrl) => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/workspaces')
|
||||
.send({ name: 'Example', repoUrl })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(bootstrapMock).toHaveBeenCalledWith({
|
||||
name: 'Example',
|
||||
description: undefined,
|
||||
userId: 'user-1',
|
||||
teamId: undefined,
|
||||
repoUrl,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||
import {
|
||||
ProjectBootstrapService,
|
||||
type BootstrapProjectResult,
|
||||
} from './project-bootstrap.service.js';
|
||||
import { CreateWorkspaceDto } from './workspace.dto.js';
|
||||
|
||||
@Controller('api/workspaces')
|
||||
@UseGuards(AuthGuard)
|
||||
@@ -11,20 +15,14 @@ export class WorkspaceController {
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body()
|
||||
body: {
|
||||
name: string;
|
||||
description?: string;
|
||||
teamId?: string;
|
||||
repoUrl?: string;
|
||||
},
|
||||
) {
|
||||
@Body() dto: CreateWorkspaceDto,
|
||||
): Promise<BootstrapProjectResult> {
|
||||
return this.bootstrap.bootstrap({
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
userId: user.id,
|
||||
teamId: body.teamId,
|
||||
repoUrl: body.repoUrl,
|
||||
teamId: dto.teamId,
|
||||
repoUrl: dto.repoUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { IsOptional, IsString, IsUrl, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateWorkspaceDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
teamId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^(?:https|git):\/\//i, {
|
||||
message: 'repoUrl must be a valid https:// or git:// URL',
|
||||
})
|
||||
@IsUrl(
|
||||
{
|
||||
protocols: ['https', 'git'],
|
||||
require_host: true,
|
||||
require_protocol: true,
|
||||
require_tld: false,
|
||||
require_valid_protocol: true,
|
||||
},
|
||||
{ message: 'repoUrl must be a valid https:// or git:// URL' },
|
||||
)
|
||||
repoUrl?: string;
|
||||
}
|
||||
@@ -1,11 +1,33 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { WorkspaceService } from './workspace.service.js';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WorkspaceService } from './workspace.service.js';
|
||||
|
||||
type ExecFileMock = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
options: { cwd: string },
|
||||
callback: (error: Error | null, stdout: string, stderr: string) => void,
|
||||
) => void;
|
||||
|
||||
const { execFileMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn<ExecFileMock>(),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: execFileMock,
|
||||
}));
|
||||
|
||||
describe('WorkspaceService', () => {
|
||||
let service: WorkspaceService;
|
||||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset();
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
callback(null, '', '');
|
||||
});
|
||||
service = new WorkspaceService();
|
||||
});
|
||||
|
||||
@@ -76,4 +98,69 @@ describe('WorkspaceService', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const project = {
|
||||
id: 'project-1',
|
||||
ownerType: 'user',
|
||||
userId: 'user-1',
|
||||
teamId: null,
|
||||
} as const;
|
||||
|
||||
let originalRoot: string | undefined;
|
||||
let temporaryRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
originalRoot = process.env['MOSAIC_ROOT'];
|
||||
temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mosaic-workspace-'));
|
||||
process.env['MOSAIC_ROOT'] = temporaryRoot;
|
||||
service = new WorkspaceService();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalRoot === undefined) {
|
||||
delete process.env['MOSAIC_ROOT'];
|
||||
} else {
|
||||
process.env['MOSAIC_ROOT'] = originalRoot;
|
||||
}
|
||||
await fs.rm(temporaryRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a leading-dash URL', '--upload-pack=sh -c id'],
|
||||
['an ext remote helper', 'ext::sh -c id'],
|
||||
['a file URL', 'file:///tmp/repository'],
|
||||
['an unparseable value', 'not a url'],
|
||||
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
||||
['a scheme without //', 'https:example.com/acme/repository.git'],
|
||||
['a hostless git URL', 'git:///tmp/repository'],
|
||||
])('rejects %s before invoking git', async (_description, repoUrl) => {
|
||||
await expect(service.create(project, repoUrl)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(execFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an HTTPS URL', 'https://example.com/acme/repository.git'],
|
||||
['a git protocol URL', 'git://example.com/acme/repository.git'],
|
||||
])('accepts %s and invokes hardened git clone arguments', async (_description, repoUrl) => {
|
||||
const workspacePath = await service.create(project, repoUrl);
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledOnce();
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'protocol.ext.allow=never',
|
||||
'-c',
|
||||
'protocol.file.allow=never',
|
||||
'clone',
|
||||
'--',
|
||||
repoUrl,
|
||||
'.',
|
||||
],
|
||||
{ cwd: workspacePath },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const allowedRepositoryProtocols = new Set(['https:', 'git:']);
|
||||
const repositoryUrlPrefixPattern = /^(?:https|git):\/\//i;
|
||||
const repositoryUrlError = 'repoUrl must be a valid https:// or git:// URL';
|
||||
|
||||
function assertAllowedRepositoryUrl(repoUrl: string): void {
|
||||
if (repoUrl.startsWith('-') || !repositoryUrlPrefixPattern.test(repoUrl)) {
|
||||
throw new BadRequestException(repositoryUrlError);
|
||||
}
|
||||
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(repoUrl);
|
||||
} catch {
|
||||
throw new BadRequestException(repositoryUrlError);
|
||||
}
|
||||
|
||||
if (!allowedRepositoryProtocols.has(parsedUrl.protocol) || parsedUrl.hostname.length === 0) {
|
||||
throw new BadRequestException(repositoryUrlError);
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkspaceProject {
|
||||
id: string;
|
||||
@@ -39,14 +59,32 @@ export class WorkspaceService {
|
||||
* If repoUrl is provided, clone instead of init.
|
||||
*/
|
||||
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
||||
if (repoUrl !== undefined) {
|
||||
assertAllowedRepositoryUrl(repoUrl);
|
||||
}
|
||||
|
||||
const workspacePath = this.resolvePath(project);
|
||||
|
||||
// Create directory
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
if (repoUrl) {
|
||||
// Clone existing repo
|
||||
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath });
|
||||
if (repoUrl !== undefined) {
|
||||
// Clone existing repo. Defense in depth keeps dangerous local helpers
|
||||
// disabled and terminates option parsing before positional arguments.
|
||||
await execFileAsync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'protocol.ext.allow=never',
|
||||
'-c',
|
||||
'protocol.file.allow=never',
|
||||
'clone',
|
||||
'--',
|
||||
repoUrl,
|
||||
'.',
|
||||
],
|
||||
{ cwd: workspacePath },
|
||||
);
|
||||
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
||||
} else {
|
||||
// Init new git repo
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mosaic</title>
|
||||
<meta name="description" content="Mosaic Stack Dashboard" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
|
||||
/>
|
||||
<script>
|
||||
// set data-theme before first paint so the stored theme never flashes
|
||||
(function () {
|
||||
try {
|
||||
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
|
||||
} catch (error) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,8 +3,10 @@
|
||||
"version": "0.0.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"build": "node ../../scripts/build-web.mjs",
|
||||
"build:vite": "vite build",
|
||||
"dev": "next dev",
|
||||
"dev:vite": "vite",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
@@ -13,12 +15,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/design-tokens": "workspace:^",
|
||||
"@mosaicstack/types": "workspace:^",
|
||||
"better-auth": "^1.5.5",
|
||||
"clsx": "^2.1.0",
|
||||
"next": "^16.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
@@ -28,9 +32,11 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"jsdom": "^29.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^3.2.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,41 +3,56 @@
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import { getSsoProvider } from '@/lib/sso-providers';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
|
||||
export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||
const params = useParams<{ provider: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
||||
const provider = getSsoProvider(providerId);
|
||||
const callbackURL = searchParams.get('callbackURL') ?? '/chat';
|
||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
||||
const [providerName, setProviderName] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const currentProvider = provider;
|
||||
|
||||
if (!currentProvider) {
|
||||
setError('Unknown SSO provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentProvider.enabled) {
|
||||
setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeProvider = currentProvider;
|
||||
let cancelled = false;
|
||||
|
||||
async function redirectToProvider(): Promise<void> {
|
||||
const result = await signIn.oauth2({
|
||||
providerId: activeProvider.id,
|
||||
callbackURL,
|
||||
});
|
||||
try {
|
||||
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
||||
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
||||
if (cancelled) return;
|
||||
|
||||
if (!cancelled && result?.error) {
|
||||
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
|
||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||
if (!provider) {
|
||||
setError('Unknown SSO provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProviderName(provider.name);
|
||||
if (!provider.configured) {
|
||||
setError(`${provider.name} is not enabled in this deployment.`);
|
||||
return;
|
||||
}
|
||||
if (provider.loginMode !== 'oidc') {
|
||||
setError(`${provider.name} is not available for OIDC sign in.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signIn.oauth2({
|
||||
providerId: provider.id,
|
||||
callbackURL,
|
||||
});
|
||||
|
||||
if (!cancelled && result?.error) {
|
||||
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,19 +61,22 @@ export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [callbackURL, provider]);
|
||||
}, [providerId, requestedCallbackURL]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
{provider
|
||||
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...`
|
||||
{providerName
|
||||
? `Redirecting you to ${providerName}...`
|
||||
: 'Preparing your sign-in request...'}
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error">
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
<p>{error}</p>
|
||||
<Link
|
||||
href="/login"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { api } from './api';
|
||||
|
||||
describe('api', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('fetches the supplied relative path with credentials and a JSON body', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
api<{ ok: boolean }>('/api/projects', {
|
||||
method: 'POST',
|
||||
body: { name: 'Mosaic' },
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/projects',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name: 'Mosaic' }),
|
||||
headers: expect.objectContaining({
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws the gateway JSON error with its statusCode', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ statusCode: 403, message: 'Forbidden' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(api('/api/admin/users')).rejects.toMatchObject({
|
||||
name: 'Error',
|
||||
message: 'Forbidden',
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
||||
|
||||
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
}
|
||||
@@ -25,7 +23,7 @@ export async function api<T>(path: string, init?: ApiRequestInit): Promise<T> {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const res = await fetch(`${GATEWAY_URL}${path}`, {
|
||||
const res = await fetch(path, {
|
||||
credentials: 'include',
|
||||
...rest,
|
||||
headers,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('auth client origin contract', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('uses the same-origin BetterAuth mount at /api/auth', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ session: null, user: null }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { authClient } = await import('./auth-client');
|
||||
await authClient.getSession();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const firstCall = fetchMock.mock.calls.at(0);
|
||||
expect(firstCall).toBeDefined();
|
||||
const requestURL = new URL(String(firstCall?.[0]), window.location.origin);
|
||||
expect(requestURL.origin).toBe(window.location.origin);
|
||||
expect(requestURL.pathname).toBe('/api/auth/get-session');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createAuthClient } from 'better-auth/react';
|
||||
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
||||
|
||||
// The gateway and BetterAuth client both use /api/auth. Omitting baseURL keeps
|
||||
// every browser request on the current origin in development and production.
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242',
|
||||
plugins: [adminClient(), genericOAuthClient()],
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveAuthCallbackURL } from './auth-redirect';
|
||||
|
||||
const CURRENT_ORIGIN = 'https://mosaic.example';
|
||||
|
||||
describe('resolveAuthCallbackURL', () => {
|
||||
it('preserves a canonical same-origin path with search and hash', () => {
|
||||
expect(resolveAuthCallbackURL('/projects?view=active#current', CURRENT_ORIGIN)).toBe(
|
||||
'/projects?view=active#current',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
'chat',
|
||||
'//evil.example',
|
||||
'/..//evil.com',
|
||||
'/..//evil.com/x',
|
||||
'/./..//evil.com',
|
||||
'/../..//evil.com',
|
||||
'/foo/..//evil.com',
|
||||
'/\\evil.example',
|
||||
'/\n//evil.example',
|
||||
'/\r//evil.example',
|
||||
'/\t//evil.example',
|
||||
'https://evil.example/phish',
|
||||
])('falls back to chat for an unsafe callback target %#', (candidate) => {
|
||||
expect(resolveAuthCallbackURL(candidate, CURRENT_ORIGIN)).toBe('/chat');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const DEFAULT_AUTH_CALLBACK_URL = '/chat';
|
||||
|
||||
/**
|
||||
* Return a canonical same-origin path for post-auth navigation.
|
||||
*
|
||||
* Parsing before comparing origins rejects protocol-relative URLs, backslash
|
||||
* variants, and control characters that the WHATWG parser normalizes away.
|
||||
*/
|
||||
export function resolveAuthCallbackURL(candidate: string | null, currentOrigin: string): string {
|
||||
if (!candidate?.startsWith('/')) return DEFAULT_AUTH_CALLBACK_URL;
|
||||
|
||||
try {
|
||||
const expectedOrigin = new URL(currentOrigin).origin;
|
||||
const resolved = new URL(candidate, expectedOrigin);
|
||||
if (resolved.origin !== expectedOrigin || resolved.pathname.startsWith('//')) {
|
||||
return DEFAULT_AUTH_CALLBACK_URL;
|
||||
}
|
||||
|
||||
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
||||
} catch {
|
||||
return DEFAULT_AUTH_CALLBACK_URL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Centralizes the type-only import of the shared `/chat` Socket.IO contract from
|
||||
// the public `@mosaicstack/types` package. `import type` is erased at compile
|
||||
// time, so this introduces no runtime dependency — it only reuses the exact
|
||||
// payload shapes instead of redeclaring them.
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import type {
|
||||
AbortPayload,
|
||||
AgentEndPayload,
|
||||
AgentStartPayload,
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ChatMessagePayload,
|
||||
ClientToServerEvents,
|
||||
CommandDef,
|
||||
CommandManifest,
|
||||
CommandManifestPayload,
|
||||
ErrorPayload,
|
||||
MessageAckPayload,
|
||||
RoutingDecisionInfo,
|
||||
ServerToClientEvents,
|
||||
SessionInfoPayload,
|
||||
SessionUsagePayload,
|
||||
SetThinkingPayload,
|
||||
SkillCommandDef,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandPayload,
|
||||
SlashCommandResultPayload,
|
||||
SystemReloadPayload,
|
||||
ToolEndPayload,
|
||||
ToolStartPayload,
|
||||
} from '@mosaicstack/types';
|
||||
|
||||
export type {
|
||||
AbortPayload,
|
||||
AgentEndPayload,
|
||||
AgentStartPayload,
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ChatMessagePayload,
|
||||
ClientToServerEvents,
|
||||
CommandDef,
|
||||
CommandManifest,
|
||||
CommandManifestPayload,
|
||||
ErrorPayload,
|
||||
MessageAckPayload,
|
||||
RoutingDecisionInfo,
|
||||
ServerToClientEvents,
|
||||
SessionInfoPayload,
|
||||
SessionUsagePayload,
|
||||
SetThinkingPayload,
|
||||
SkillCommandDef,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandPayload,
|
||||
SlashCommandResultPayload,
|
||||
SystemReloadPayload,
|
||||
ToolEndPayload,
|
||||
ToolStartPayload,
|
||||
};
|
||||
|
||||
/** The `/chat` namespace socket, narrowed to the exact typed event contract. */
|
||||
export type ChatSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { ioMock } = vi.hoisted(() => ({
|
||||
ioMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('socket.io-client', () => ({
|
||||
io: ioMock,
|
||||
}));
|
||||
|
||||
import { destroySocket, getSocket } from './socket';
|
||||
|
||||
interface MockChatSocket {
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
offAny: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
/** Test-only helper: fires every handler registered for `event` via
|
||||
* `.on`, mirroring how a real socket.io-client instance invokes its own
|
||||
* listeners (e.g. calling the registered `disconnect` handler(s) on a
|
||||
* real transient disconnect). */
|
||||
trigger(event: string): void;
|
||||
}
|
||||
|
||||
function createMockSocket(): MockChatSocket {
|
||||
const handlers = new Map<string, Set<() => void>>();
|
||||
const mockSocket: MockChatSocket = {
|
||||
on: vi.fn((event: string, handler: () => void) => {
|
||||
if (!handlers.has(event)) handlers.set(event, new Set());
|
||||
handlers.get(event)?.add(handler);
|
||||
return mockSocket;
|
||||
}),
|
||||
offAny: vi.fn(() => mockSocket),
|
||||
disconnect: vi.fn(() => mockSocket),
|
||||
trigger(event: string): void {
|
||||
for (const handler of handlers.get(event) ?? []) handler();
|
||||
},
|
||||
};
|
||||
return mockSocket;
|
||||
}
|
||||
|
||||
let currentMock!: MockChatSocket;
|
||||
|
||||
describe('chat socket', () => {
|
||||
beforeEach(() => {
|
||||
ioMock.mockReset();
|
||||
// A fresh object per io() call so identity assertions (same singleton vs.
|
||||
// a genuinely new instance) are meaningful.
|
||||
ioMock.mockImplementation(() => {
|
||||
currentMock = createMockSocket();
|
||||
return currentMock;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroySocket();
|
||||
});
|
||||
|
||||
it('creates one same-origin /chat namespace socket', () => {
|
||||
const first = getSocket();
|
||||
const second = getSocket();
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(ioMock).toHaveBeenCalledOnce();
|
||||
expect(ioMock).toHaveBeenCalledWith('/chat', {
|
||||
withCredentials: true,
|
||||
autoConnect: false,
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the same singleton instance across a transient disconnect', () => {
|
||||
const first = getSocket();
|
||||
|
||||
// socket.ts must not react to a real socket's `disconnect` event by
|
||||
// nulling the singleton — it registers no such handler at all now.
|
||||
// Actually fire every handler registered via `.on('disconnect', ...)`
|
||||
// (mirroring a real socket.io-client reconnect) instead of merely
|
||||
// calling getSocket() again: this is what makes the test fail if
|
||||
// production reintroduces `socket.on('disconnect', () => { socket =
|
||||
// null; })`, since that handler would run here and null the singleton
|
||||
// before the next getSocket() call.
|
||||
currentMock.trigger('disconnect');
|
||||
const second = getSocket();
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(ioMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('only creates a new singleton after an explicit destroySocket()', () => {
|
||||
const first = getSocket();
|
||||
|
||||
destroySocket();
|
||||
const second = getSocket();
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(ioMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
+17
-13
@@ -1,23 +1,27 @@
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { io } from 'socket.io-client';
|
||||
import type { ChatSocket } from './chat-contract';
|
||||
|
||||
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
||||
let socket: ChatSocket | null = null;
|
||||
|
||||
let socket: Socket | null = null;
|
||||
|
||||
export function getSocket(): Socket {
|
||||
export function getSocket(): ChatSocket {
|
||||
if (!socket) {
|
||||
socket = io(`${GATEWAY_URL}/chat`, {
|
||||
// socket.io-client 4.8.3's `io()` factory declaration always returns the
|
||||
// default unparameterized Socket (it accepts no <ListenEvents, EmitEvents>
|
||||
// generics), so this one cast is the unavoidable boundary between that and the
|
||||
// typed `/chat` contract. Every other call site uses the resulting ChatSocket
|
||||
// with no further assertions.
|
||||
socket = io('/chat', {
|
||||
withCredentials: true,
|
||||
autoConnect: false,
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
}) as unknown as ChatSocket;
|
||||
|
||||
// Reset singleton reference when socket is fully closed so the next
|
||||
// getSocket() call creates a fresh instance instead of returning a
|
||||
// closed/dead socket.
|
||||
socket.on('disconnect', () => {
|
||||
socket = null;
|
||||
});
|
||||
// A transient `disconnect` (network blip, server restart) must NOT null
|
||||
// the singleton: socket.io-client auto-reconnects this same instance,
|
||||
// and its listeners stay registered across that reconnect. Nulling here
|
||||
// previously orphaned those listeners on the next getSocket() call by
|
||||
// handing back a brand-new, unconnected instance. Only destroySocket()
|
||||
// (an explicit, intentional teardown) may reset the singleton.
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getEnabledSsoProviders, getSsoProvider } from './sso-providers';
|
||||
|
||||
describe('sso-providers', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('returns the enabled providers in login button order', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true');
|
||||
|
||||
expect(getEnabledSsoProviders()).toEqual([
|
||||
{
|
||||
id: 'workos',
|
||||
buttonLabel: 'Continue with WorkOS',
|
||||
description: 'Enterprise SSO via WorkOS',
|
||||
enabled: true,
|
||||
href: '/auth/provider/workos',
|
||||
},
|
||||
{
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
enabled: true,
|
||||
href: '/auth/provider/keycloak',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks disabled providers without exposing them in the enabled list', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false');
|
||||
|
||||
expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']);
|
||||
expect(getSsoProvider('keycloak')).toEqual({
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
enabled: false,
|
||||
href: '/auth/provider/keycloak',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unknown providers', () => {
|
||||
expect(getSsoProvider('authentik')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
export type SsoProviderId = 'workos' | 'keycloak';
|
||||
|
||||
export interface SsoProvider {
|
||||
id: SsoProviderId;
|
||||
buttonLabel: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const PROVIDER_METADATA: Record<SsoProviderId, Omit<SsoProvider, 'enabled' | 'href'>> = {
|
||||
workos: {
|
||||
id: 'workos',
|
||||
buttonLabel: 'Continue with WorkOS',
|
||||
description: 'Enterprise SSO via WorkOS',
|
||||
},
|
||||
keycloak: {
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
},
|
||||
};
|
||||
|
||||
export function getEnabledSsoProviders(): SsoProvider[] {
|
||||
return (Object.keys(PROVIDER_METADATA) as SsoProviderId[])
|
||||
.map((providerId) => getSsoProvider(providerId))
|
||||
.filter((provider): provider is SsoProvider => provider?.enabled === true);
|
||||
}
|
||||
|
||||
export function getSsoProvider(providerId: string): SsoProvider | null {
|
||||
if (!isSsoProviderId(providerId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...PROVIDER_METADATA[providerId],
|
||||
enabled: isSsoProviderEnabled(providerId),
|
||||
href: `/auth/provider/${providerId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function isSsoProviderId(value: string): value is SsoProviderId {
|
||||
return value === 'workos' || value === 'keycloak';
|
||||
}
|
||||
|
||||
function isSsoProviderEnabled(providerId: SsoProviderId): boolean {
|
||||
switch (providerId) {
|
||||
case 'workos':
|
||||
return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
|
||||
case 'keycloak':
|
||||
return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import { createAppRouter } from '@/routes';
|
||||
import '@/app/globals.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
throw new Error('missing #root element');
|
||||
}
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={createAppRouter()} />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-router-dom';
|
||||
import { LoginPage } from '@/spa/pages/login';
|
||||
import { RegisterPage } from '@/spa/pages/register';
|
||||
import { SsoCallbackPage } from '@/spa/pages/sso-callback';
|
||||
import { ChatPage } from '@/spa/pages/chat';
|
||||
import { ChatRouteErrorBoundary } from '@/spa/pages/chat-error-boundary';
|
||||
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
||||
import { Placeholder } from '@/spa/placeholder';
|
||||
|
||||
function GuestLayout(): ReactElement {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-surface-bg px-4 py-8">
|
||||
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
element: <GuestGuard />,
|
||||
children: [
|
||||
{
|
||||
element: <GuestLayout />,
|
||||
children: [
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/register', element: <RegisterPage /> },
|
||||
{ path: '/auth/provider/:provider', element: <SsoCallbackPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
||||
{ path: '/projects', element: <Placeholder title="Projects" /> },
|
||||
{ path: '/projects/:id', element: <Placeholder title="Project" /> },
|
||||
{ path: '/tasks', element: <Placeholder title="Tasks" /> },
|
||||
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
||||
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function createAppRouter(): ReturnType<typeof createBrowserRouter> {
|
||||
return createBrowserRouter(routes);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { CommandsPanel } from './commands-panel';
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
let root: Root | null;
|
||||
let container: HTMLElement | null;
|
||||
|
||||
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(node);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('CommandsPanel', () => {
|
||||
it('shows the frozen local pendingApproval args in the confirmation area, regardless of misleading server message text', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={{
|
||||
conversationId: 'c1',
|
||||
command: 'deploy', // matches pendingApproval — this is a legitimately approved request
|
||||
success: true,
|
||||
approvalId: 'ap1',
|
||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||
// Free-text server message claims a different, less alarming target
|
||||
// than what will actually be sent — the UI must not rely on this.
|
||||
message: 'This will only affect the staging environment.',
|
||||
}}
|
||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The exact frozen combined action is visible...
|
||||
expect(container?.textContent).toContain('/deploy');
|
||||
expect(container?.textContent).toContain('prod');
|
||||
// ...and the misleading server free-text is never shown next to it.
|
||||
expect(container?.textContent).not.toContain('staging environment');
|
||||
});
|
||||
|
||||
it('does not throw when a manifest commands entry is null', async () => {
|
||||
const manifest = {
|
||||
commands: [
|
||||
null,
|
||||
{
|
||||
name: 'model',
|
||||
aliases: [],
|
||||
description: 'Change the active model',
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
skills: [null],
|
||||
version: 1,
|
||||
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
||||
|
||||
await expect(
|
||||
render(
|
||||
<CommandsPanel
|
||||
manifest={manifest}
|
||||
results={[]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(container?.textContent).toContain('model');
|
||||
});
|
||||
|
||||
it('shows an explicit no-args fallback when the frozen pendingApproval has no args', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={{
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: true,
|
||||
approvalId: 'ap1',
|
||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||
}}
|
||||
pendingApproval={{ command: 'deploy' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent?.toLowerCase()).toContain('no args');
|
||||
});
|
||||
|
||||
it('renders skills from a skills-only manifest', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={{
|
||||
commands: [],
|
||||
skills: [{ name: 'brave-search', description: 'Search the web', available: true }],
|
||||
version: 1,
|
||||
}}
|
||||
results={[]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent).toContain('brave-search');
|
||||
expect(container?.textContent).toContain('Search the web');
|
||||
});
|
||||
|
||||
it('does not show the Run affordance when approval.success/approvalId are objects, even though command matches pendingApproval', async () => {
|
||||
const approval = {
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: { truthy: 'object' },
|
||||
approvalId: { also: 'object' },
|
||||
} as unknown as Parameters<typeof CommandsPanel>[0]['approval'];
|
||||
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={approval}
|
||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
[...(container?.querySelectorAll('button') ?? [])].some((button) =>
|
||||
button.textContent?.includes('Run approved command'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the guarded server-provided denial reason for a denied approval', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={{
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: false,
|
||||
message: 'Not authorized',
|
||||
}}
|
||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent).toContain('Not authorized');
|
||||
});
|
||||
|
||||
it('falls back to a stable "Denied." copy when a denial has no usable message', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={{ conversationId: 'c1', command: 'deploy', success: false }}
|
||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent).toContain('Denied.');
|
||||
});
|
||||
|
||||
it('shows the guarded contract-provided reason for a failed command result, falling back to a stable copy only when absent', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[
|
||||
{ conversationId: 'c1', command: 'model', success: false, message: 'Unknown model' },
|
||||
{ conversationId: 'c1', command: 'deploy', success: false },
|
||||
]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent).toContain('Unknown model');
|
||||
expect(container?.textContent).toContain('Command failed.');
|
||||
});
|
||||
|
||||
it('bounds an oversized command result message at the render site as defense-in-depth', async () => {
|
||||
const hostileMessage = 'y'.repeat(50_000);
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[
|
||||
{ conversationId: 'c1', command: 'model', success: false, message: hostileMessage },
|
||||
]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const text = container?.textContent ?? '';
|
||||
expect(text.length).toBeLessThan(hostileMessage.length);
|
||||
});
|
||||
|
||||
it('does not throw when the manifest fields are malformed (non-array commands/skills)', async () => {
|
||||
const manifest = {
|
||||
commands: 'not-an-array',
|
||||
skills: null,
|
||||
version: 1,
|
||||
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
||||
|
||||
await expect(
|
||||
render(
|
||||
<CommandsPanel
|
||||
manifest={manifest}
|
||||
results={[]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, type ReactElement } from 'react';
|
||||
import type { PendingApproval } from './use-chat-connection';
|
||||
import { MAX_COMMAND_MESSAGE_CHARS } from './limits';
|
||||
import { asNonEmptyString, asString } from './runtime-guards';
|
||||
import type {
|
||||
CommandManifest,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandResultPayload,
|
||||
} from '@/lib/chat-contract';
|
||||
|
||||
/** Stable fallback copy shown for a failed command only when the server's
|
||||
* own guarded, non-empty `message` (e.g. "Unknown model") is absent or
|
||||
* malformed — the structured contract reason itself is otherwise shown
|
||||
* directly, never a raw thrown exception, stack trace, or object value. */
|
||||
const COMMAND_FAILURE_COPY = 'Command failed.';
|
||||
|
||||
/** Render-site defense-in-depth: `use-chat-connection.ts` already bounds a
|
||||
* stored command:result message at ingestion, but this component must never
|
||||
* assume every caller went through that path — bounding again here means a
|
||||
* hostile/oversized message can never force an unbounded render. */
|
||||
function boundMessage(value: string): string {
|
||||
return value.length > MAX_COMMAND_MESSAGE_CHARS
|
||||
? value.slice(0, MAX_COMMAND_MESSAGE_CHARS)
|
||||
: value;
|
||||
}
|
||||
|
||||
interface CommandsPanelProps {
|
||||
manifest: CommandManifest | null;
|
||||
results: SlashCommandResultPayload[];
|
||||
approval: SlashCommandApprovalResultPayload | null;
|
||||
pendingApproval: PendingApproval | null;
|
||||
hasConversation: boolean;
|
||||
onExecute: (input: { command: string; args?: string }) => void;
|
||||
onApprove: (input: { command: string; args?: string }) => void;
|
||||
onRunApproved: () => void;
|
||||
}
|
||||
|
||||
export function CommandsPanel({
|
||||
manifest,
|
||||
results,
|
||||
approval,
|
||||
pendingApproval,
|
||||
hasConversation,
|
||||
onExecute,
|
||||
onApprove,
|
||||
onRunApproved,
|
||||
}: CommandsPanelProps): ReactElement {
|
||||
const [command, setCommand] = useState('');
|
||||
const [args, setArgs] = useState('');
|
||||
|
||||
// Defense-in-depth: the reducer already normalizes success/approvalId
|
||||
// before storing `approval`, but a matching command string alone must
|
||||
// never be trusted here either — require the literal boolean `true` and a
|
||||
// non-empty string approvalId, not merely truthy values.
|
||||
const canRunApproved =
|
||||
approval?.success === true &&
|
||||
typeof approval.approvalId === 'string' &&
|
||||
approval.approvalId.length > 0 &&
|
||||
!!pendingApproval &&
|
||||
pendingApproval.command === approval.command;
|
||||
|
||||
// A manifest arrives from the server as untyped JSON at runtime — guard
|
||||
// both collections before mapping so a malformed manifest cannot throw.
|
||||
const commands = Array.isArray(manifest?.commands) ? manifest.commands : [];
|
||||
const skills = Array.isArray(manifest?.skills) ? manifest.skills : [];
|
||||
|
||||
return (
|
||||
<section aria-label="Commands" className="flex flex-col gap-2 border-b px-4 py-3 text-xs">
|
||||
{commands.length > 0 ? (
|
||||
<ul aria-label="Available commands" className="flex flex-col gap-1">
|
||||
{commands.map((cmd, index) => (
|
||||
<li key={asString(cmd?.name) || `cmd-${index}`}>
|
||||
<strong>/{asString(cmd?.name)}</strong> — {asString(cmd?.description)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{skills.length > 0 ? (
|
||||
<ul aria-label="Available skills" className="flex flex-col gap-1">
|
||||
{skills.map((skill, index) => (
|
||||
<li key={asString(skill?.name) || `skill-${index}`}>
|
||||
<strong>/skill:{asString(skill?.name)}</strong> — {asString(skill?.description)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
aria-label="Command name"
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
placeholder="command"
|
||||
/>
|
||||
<input
|
||||
aria-label="Command arguments"
|
||||
value={args}
|
||||
onChange={(event) => setArgs(event.target.value)}
|
||||
placeholder="args (optional)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasConversation || !command.trim()}
|
||||
onClick={() => onExecute({ command: command.trim(), args: args.trim() || undefined })}
|
||||
>
|
||||
Run command
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasConversation || !command.trim()}
|
||||
onClick={() => onApprove({ command: command.trim(), args: args.trim() || undefined })}
|
||||
>
|
||||
Request approval
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{approval ? (
|
||||
<div role={approval.success ? 'status' : 'alert'} className="flex items-center gap-2">
|
||||
{/* A successful approval shows stable client copy only — never
|
||||
the server-controlled approval.message or echoed
|
||||
approval.command as the primary confirmation. The frozen local
|
||||
pendingApproval below (not this line) is the sole authoritative
|
||||
statement of what will run. A denial, by contrast, is not an
|
||||
execution authority and safely surfaces the guarded structured
|
||||
reason the server gave (e.g. "Not authorized"), falling back to
|
||||
a stable copy only when absent/malformed. */}
|
||||
<span>
|
||||
{approval.success ? 'Approved.' : asNonEmptyString(approval.message, 'Denied.')}
|
||||
</span>
|
||||
{canRunApproved && pendingApproval ? (
|
||||
<>
|
||||
{/* Authoritative frozen local command+args — what the click below
|
||||
will actually emit. The server's `approval` above is display-only
|
||||
and must never be trusted to represent the executed payload. */}
|
||||
<span>
|
||||
Will run: /{pendingApproval.command}{' '}
|
||||
{pendingApproval.args ? pendingApproval.args : '(no args)'}
|
||||
</span>
|
||||
<button type="button" onClick={onRunApproved}>
|
||||
Run approved command
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results.length > 0 ? (
|
||||
<ul aria-label="Command results" className="flex flex-col gap-1">
|
||||
{results.map((result, index) => (
|
||||
<li key={`${result.command}-${index}`} role={result.success ? 'status' : 'alert'}>
|
||||
/{asString(result.command)}: {result.success ? 'success' : 'failed'}
|
||||
{result.success
|
||||
? typeof result.message === 'string' && result.message
|
||||
? ` — ${boundMessage(result.message)}`
|
||||
: ''
|
||||
: ` — ${boundMessage(asNonEmptyString(result.message, COMMAND_FAILURE_COPY))}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
||||
|
||||
interface ComposerProps {
|
||||
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
|
||||
onStop: () => void;
|
||||
streaming: boolean;
|
||||
/** True from local send time through server turn startup/ack and
|
||||
* throughout streaming — a superset of `streaming` that also covers the
|
||||
* pre-ack window where a second send could otherwise slip through. */
|
||||
sending: boolean;
|
||||
hasConversation: boolean;
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
onSend,
|
||||
onStop,
|
||||
streaming,
|
||||
sending,
|
||||
hasConversation,
|
||||
}: ComposerProps): ReactElement {
|
||||
const [content, setContent] = useState('');
|
||||
const [provider, setProvider] = useState('');
|
||||
const [modelId, setModelId] = useState('');
|
||||
const busy = streaming || sending;
|
||||
|
||||
function submit(): void {
|
||||
if (busy) return;
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return;
|
||||
onSend({
|
||||
content: trimmed,
|
||||
provider: provider.trim() || undefined,
|
||||
modelId: modelId.trim() || undefined,
|
||||
});
|
||||
setContent('');
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
className="flex flex-col gap-2 border-t p-4"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
aria-label="Provider"
|
||||
value={provider}
|
||||
onChange={(event) => setProvider(event.target.value)}
|
||||
placeholder="Provider (optional)"
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
/>
|
||||
<input
|
||||
aria-label="Model"
|
||||
value={modelId}
|
||||
onChange={(event) => setModelId(event.target.value)}
|
||||
placeholder="Model (optional)"
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
aria-label="Message"
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={2}
|
||||
placeholder="Message… (Enter to send, Shift+Enter for a new line)"
|
||||
className="flex-1 resize-none rounded border px-3 py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!content.trim() || busy}
|
||||
className="rounded px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Stop"
|
||||
disabled={!hasConversation || !streaming}
|
||||
onClick={onStop}
|
||||
className="rounded px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Bounds on server-fed chat state. A hostile or malfunctioning gateway can
|
||||
* flood any of these collections; caps keep memory/render cost flat instead
|
||||
* of growing unboundedly for the lifetime of the connection.
|
||||
*/
|
||||
|
||||
/** Max characters retained for the in-flight streamed text/thinking buffers. */
|
||||
export const MAX_STREAM_CHARS = 20_000;
|
||||
/** Max transcript turns retained (oldest dropped first). */
|
||||
export const MAX_MESSAGES = 500;
|
||||
/** Max tool-call entries (including anomaly entries) retained per turn history. */
|
||||
export const MAX_TOOLS = 200;
|
||||
/** Max slash-command results retained. */
|
||||
export const MAX_COMMAND_RESULTS = 200;
|
||||
/** Max commands/skills accepted from a single manifest push. */
|
||||
export const MAX_MANIFEST_ITEMS = 500;
|
||||
/** Max executed approval IDs remembered for single-flight dedup. */
|
||||
export const MAX_EXECUTED_APPROVAL_IDS = 200;
|
||||
/** Max characters retained for a single command:result message — a hostile
|
||||
* or malfunctioning gateway must not be able to push an unbounded curated
|
||||
* success/failure reason into state (or, defensively, onto the page). */
|
||||
export const MAX_COMMAND_MESSAGE_CHARS = 1_000;
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import type { ChatTranscriptMessage } from './use-chat-connection';
|
||||
|
||||
interface MessageTranscriptProps {
|
||||
messages: ChatTranscriptMessage[];
|
||||
streaming: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function MessageTranscript({
|
||||
messages,
|
||||
streaming,
|
||||
text,
|
||||
}: MessageTranscriptProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label="Conversation"
|
||||
className="flex flex-1 flex-col gap-3 overflow-y-auto p-4"
|
||||
>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} data-role={message.role} className="whitespace-pre-wrap text-sm">
|
||||
<span className="font-medium">{message.role === 'user' ? 'You' : 'Assistant'}: </span>
|
||||
<span>{message.text}</span>
|
||||
{message.thinking ? (
|
||||
<div className="pt-1 text-xs italic opacity-70">{message.thinking}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{streaming ? (
|
||||
<div data-role="assistant-streaming" className="whitespace-pre-wrap text-sm">
|
||||
<span className="font-medium">Assistant: </span>
|
||||
<span>{text || 'Thinking…'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Socket.IO payloads are only statically typed at the call site — a
|
||||
* misbehaving or compromised gateway can send anything at runtime. These
|
||||
* guards protect the dereference sites that would otherwise throw (`.map` on
|
||||
* a non-array, `.toFixed` on a non-number) or render an object as a React
|
||||
* child.
|
||||
*/
|
||||
|
||||
export function asString(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback;
|
||||
}
|
||||
|
||||
/** Like `asString`, but an empty string also falls back — used for guarded
|
||||
* contract-provided reason strings (e.g. a denial or failure message) where
|
||||
* an empty string is not a meaningful value to display in place of the
|
||||
* stable fallback copy. */
|
||||
export function asNonEmptyString(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
export function asFiniteNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
/** Like `asFiniteNumber`, but returns `null` on failure instead of a numeric
|
||||
* fallback — callers that must not fabricate a plausible-looking value (e.g.
|
||||
* `0 tokens` / `$0.0000` for genuinely unknown usage) use this to render an
|
||||
* honest "unavailable" label instead. */
|
||||
export function asFiniteNumberOrNull(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : [];
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
/** The single point of truth for what counts as a valid conversation ID
|
||||
* anywhere a scoped server event may adopt one into state — a non-empty
|
||||
* string, nothing else. Every site that establishes or compares
|
||||
* `state.conversationId` against a raw socket payload must route through
|
||||
* this guard so a malformed first frame (null/object/number/empty string)
|
||||
* can never be adopted verbatim. */
|
||||
export function asConversationId(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import type { SessionInfoPayload } from '@/lib/chat-contract';
|
||||
import { MAX_MANIFEST_ITEMS } from './limits';
|
||||
import { asString, asStringArray } from './runtime-guards';
|
||||
|
||||
interface SessionPanelProps {
|
||||
sessionInfo: SessionInfoPayload | null;
|
||||
onSetThinking: (level: string) => void;
|
||||
}
|
||||
|
||||
const THINKING_LEVEL_UNAVAILABLE = '';
|
||||
|
||||
export function SessionPanel({
|
||||
sessionInfo,
|
||||
onSetThinking,
|
||||
}: SessionPanelProps): ReactElement | null {
|
||||
if (!sessionInfo) return null;
|
||||
|
||||
// The reducer already caps this before storing it, but the render site
|
||||
// defends independently — a hostile payload must never be able to force
|
||||
// this <select> to lay out an unbounded number of options.
|
||||
const availableThinkingLevels = asStringArray(sessionInfo.availableThinkingLevels).slice(
|
||||
0,
|
||||
MAX_MANIFEST_ITEMS,
|
||||
);
|
||||
const hasThinkingLevels = availableThinkingLevels.length > 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Session info"
|
||||
className="flex flex-wrap items-center gap-3 border-b px-4 py-2 text-xs"
|
||||
>
|
||||
<span>{asString(sessionInfo.provider, 'unknown')}</span>
|
||||
<span>{asString(sessionInfo.modelId, 'unknown')}</span>
|
||||
<label className="flex items-center gap-2">
|
||||
<span>Thinking level</span>
|
||||
<select
|
||||
aria-label="Thinking level"
|
||||
value={
|
||||
hasThinkingLevels ? asString(sessionInfo.thinkingLevel) : THINKING_LEVEL_UNAVAILABLE
|
||||
}
|
||||
onChange={(event) => {
|
||||
// The placeholder option is not a real, settable level — a
|
||||
// malformed availableThinkingLevels list must never let the
|
||||
// client emit set:thinking for it.
|
||||
if (!hasThinkingLevels) return;
|
||||
onSetThinking(event.target.value);
|
||||
}}
|
||||
>
|
||||
{hasThinkingLevels ? (
|
||||
availableThinkingLevels.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{level}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value={THINKING_LEVEL_UNAVAILABLE}>Thinking level unavailable</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
{sessionInfo.routingDecision ? (
|
||||
<span title={asString(sessionInfo.routingDecision.ruleName)}>
|
||||
{asString(sessionInfo.routingDecision.reason)}
|
||||
</span>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { vi } from 'vitest';
|
||||
import type { ClientToServerEvents, ServerToClientEvents } from '@/lib/chat-contract';
|
||||
|
||||
type ServerEvent = keyof ServerToClientEvents;
|
||||
type ClientEvent = keyof ClientToServerEvents;
|
||||
type ServerHandler<K extends ServerEvent> = ServerToClientEvents[K];
|
||||
type ClientPayload<K extends ClientEvent> = Parameters<ClientToServerEvents[K]>[0];
|
||||
|
||||
export interface EmittedEvent<K extends ClientEvent = ClientEvent> {
|
||||
event: K;
|
||||
payload: ClientPayload<K>;
|
||||
}
|
||||
|
||||
/** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */
|
||||
export interface FakeChatSocket {
|
||||
connected: boolean;
|
||||
connect(): FakeChatSocket;
|
||||
on<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||
off<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||
emit<K extends ClientEvent>(event: K, payload: ClientPayload<K>): FakeChatSocket;
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed in-memory stand-in for `getSocket()`. Unlike a bare
|
||||
* `(event: string, payload: unknown) => void` mock, every public method here is
|
||||
* checked against the real `/chat` contract — a typo'd event name or a payload
|
||||
* missing a required field fails to compile instead of silently no-op'ing at
|
||||
* runtime.
|
||||
*/
|
||||
/** Socket.IO's built-in connection-state events. Not part of the app-level
|
||||
* ServerToClientEvents contract, but real sockets always support them and
|
||||
* `useChatConnection` registers a `disconnect` handler on the real socket. */
|
||||
type LifecycleEvent = 'connect' | 'disconnect';
|
||||
|
||||
export function createFakeChatSocket(): {
|
||||
socket: FakeChatSocket;
|
||||
listeners: Map<ServerEvent, Set<(payload: never) => void>>;
|
||||
emitted: EmittedEvent[];
|
||||
serverEmit<K extends ServerEvent>(
|
||||
event: K,
|
||||
payload: Parameters<ServerToClientEvents[K]>[0],
|
||||
): void;
|
||||
/** Escape hatch for malformed-payload tests: bypasses the compile-time
|
||||
* payload contract to simulate a genuinely untrusted runtime value from the
|
||||
* server, e.g. a `session:info` with a non-array `availableThinkingLevels`. */
|
||||
serverEmitRaw(event: ServerEvent, payload: unknown): void;
|
||||
/** Simulates a transient Socket.IO `disconnect` — fires any handler(s)
|
||||
* registered via `socket.on('disconnect', ...)` without clearing any
|
||||
* listeners, mirroring how a real reconnecting socket behaves. */
|
||||
simulateDisconnect(): void;
|
||||
/** Simulates socket.io-client's automatic reconnect of the *same*
|
||||
* instance after a transient disconnect: marks the socket connected again
|
||||
* and fires any handler(s) registered via `socket.on('connect', ...)`,
|
||||
* without clearing or replacing any listeners. */
|
||||
simulateReconnect(): void;
|
||||
} {
|
||||
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
|
||||
const emitted: EmittedEvent[] = [];
|
||||
|
||||
// Internal storage is intentionally keyed loosely (the per-event handler shape
|
||||
// varies by K, which a single Map can't express); the generic signatures on the
|
||||
// exported `socket`/`serverEmit` above and below are what keep test call sites
|
||||
// type-checked against ServerToClientEvents/ClientToServerEvents.
|
||||
const socket = {
|
||||
connected: false,
|
||||
connect: vi.fn(function connect(this: void) {
|
||||
socket.connected = true;
|
||||
return socket;
|
||||
}),
|
||||
on: vi.fn(function on(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
||||
if (!listeners.has(event)) listeners.set(event, new Set());
|
||||
listeners.get(event)?.add(handler);
|
||||
return socket;
|
||||
}),
|
||||
off: vi.fn(function off(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
||||
listeners.get(event)?.delete(handler);
|
||||
return socket;
|
||||
}),
|
||||
emit: vi.fn(function emit(this: void, event: ClientEvent, payload: unknown) {
|
||||
emitted.push({ event, payload } as EmittedEvent);
|
||||
return socket;
|
||||
}),
|
||||
} as unknown as FakeChatSocket;
|
||||
|
||||
function serverEmit<K extends ServerEvent>(
|
||||
event: K,
|
||||
payload: Parameters<ServerToClientEvents[K]>[0],
|
||||
): void {
|
||||
for (const handler of listeners.get(event) ?? []) {
|
||||
(handler as (payload: Parameters<ServerToClientEvents[K]>[0]) => void)(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function serverEmitRaw(event: ServerEvent, payload: unknown): void {
|
||||
for (const handler of listeners.get(event) ?? []) {
|
||||
(handler as (payload: unknown) => void)(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function simulateDisconnect(): void {
|
||||
socket.connected = false;
|
||||
const lifecycleKey = 'disconnect' satisfies LifecycleEvent as unknown as ServerEvent;
|
||||
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
||||
(handler as () => void)();
|
||||
}
|
||||
}
|
||||
|
||||
function simulateReconnect(): void {
|
||||
socket.connected = true;
|
||||
const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent;
|
||||
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
||||
(handler as () => void)();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
socket,
|
||||
listeners,
|
||||
emitted,
|
||||
serverEmit,
|
||||
serverEmitRaw,
|
||||
simulateDisconnect,
|
||||
simulateReconnect,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { ToolCallList } from './tool-call-list';
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
let root: Root | null;
|
||||
let container: HTMLElement | null;
|
||||
|
||||
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(node);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('ToolCallList', () => {
|
||||
it('renders two entries independently, without a duplicate-key warning, when a valid toolCallId is shared', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await render(
|
||||
<ToolCallList
|
||||
tools={[
|
||||
{ toolCallId: 'dup', toolName: 'search', status: 'success' },
|
||||
{ toolCallId: 'dup', toolName: 'search', status: 'running' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const items = [...(container?.querySelectorAll('li') ?? [])];
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]?.textContent).toContain('success');
|
||||
expect(items[1]?.textContent).toContain('running');
|
||||
|
||||
const duplicateKeyWarning = consoleError.mock.calls.some((args) =>
|
||||
args.some((arg) => typeof arg === 'string' && arg.includes('same key')),
|
||||
);
|
||||
expect(duplicateKeyWarning).toBe(false);
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import type { ToolCallState } from './use-chat-connection';
|
||||
|
||||
export function ToolCallList({ tools }: { tools: ToolCallState[] }): ReactElement | null {
|
||||
if (tools.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul aria-label="Tool calls" className="flex flex-col gap-1 px-4 pb-2 text-xs">
|
||||
{tools.map((tool, index) => (
|
||||
<li
|
||||
// A valid server-controlled toolCallId can legitimately repeat
|
||||
// (e.g. two tool:start events sharing one id) — keying on it alone
|
||||
// would give React two identical keys. Pairing it with its
|
||||
// (stable, append-only) render index keeps every key unique.
|
||||
key={`${tool.toolCallId}-${index}`}
|
||||
role={tool.status === 'error' || tool.status === 'anomaly' ? 'alert' : 'status'}
|
||||
>
|
||||
{tool.toolName} —{' '}
|
||||
{tool.status === 'anomaly' ? 'unexpected end (unknown tool call)' : tool.status}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { useSessionMock } = vi.hoisted(() => ({
|
||||
useSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
}));
|
||||
|
||||
import { AuthGuard, GuestGuard } from './guards';
|
||||
|
||||
interface RenderedRouter {
|
||||
container: HTMLDivElement;
|
||||
router: ReturnType<typeof createMemoryRouter>;
|
||||
}
|
||||
|
||||
const mountedRoots: Root[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
async function renderRouter(
|
||||
routeObjects: RouteObject[],
|
||||
initialEntry: string,
|
||||
): Promise<RenderedRouter> {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const router = createMemoryRouter(routeObjects, { initialEntries: [initialEntry] });
|
||||
const root = createRoot(container);
|
||||
mountedRoots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
return { container, router };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of mountedRoots.splice(0)) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
document.body.replaceChildren();
|
||||
useSessionMock.mockReset();
|
||||
});
|
||||
|
||||
const guestRoutes: RouteObject[] = [
|
||||
{
|
||||
path: '/login',
|
||||
element: <GuestGuard />,
|
||||
children: [{ index: true, element: <p>Guest page</p> }],
|
||||
},
|
||||
{ path: '/chat', element: <p>Chat page</p> },
|
||||
];
|
||||
|
||||
const authenticatedRoutes: RouteObject[] = [
|
||||
{
|
||||
path: '/chat',
|
||||
element: <AuthGuard />,
|
||||
children: [{ index: true, element: <p>Private page</p> }],
|
||||
},
|
||||
{ path: '/login', element: <p>Login page</p> },
|
||||
];
|
||||
|
||||
describe('GuestGuard', () => {
|
||||
it('renders the guest outlet while session lookup is pending', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
||||
|
||||
const view = await renderRouter(guestRoutes, '/login');
|
||||
|
||||
expect(view.container.textContent).toContain('Guest page');
|
||||
expect(view.router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('renders the guest outlet when no session exists', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||
|
||||
const view = await renderRouter(guestRoutes, '/login');
|
||||
|
||||
expect(view.container.textContent).toContain('Guest page');
|
||||
expect(view.router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('redirects an authenticated session to chat', async () => {
|
||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||
|
||||
const view = await renderRouter(guestRoutes, '/login');
|
||||
|
||||
expect(view.container.textContent).toContain('Chat page');
|
||||
expect(view.router.state.location.pathname).toBe('/chat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthGuard', () => {
|
||||
it('renders the existing loading treatment while session lookup is pending', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
||||
|
||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||
|
||||
expect(view.container.textContent).toContain('Loading...');
|
||||
expect(view.router.state.location.pathname).toBe('/chat');
|
||||
});
|
||||
|
||||
it('redirects an unauthenticated visitor to login', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||
|
||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||
|
||||
expect(view.container.textContent).toContain('Login page');
|
||||
expect(view.router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('renders the authenticated outlet when a session exists', async () => {
|
||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||
|
||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||
|
||||
expect(view.container.textContent).toContain('Private page');
|
||||
expect(view.router.state.location.pathname).toBe('/chat');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
export function GuestGuard(): ReactElement {
|
||||
const { data: session } = useSession();
|
||||
|
||||
return session ? <Navigate to="/chat" replace /> : <Outlet />;
|
||||
}
|
||||
|
||||
export function AuthGuard(): ReactElement {
|
||||
const { data: session, isPending } = useSession();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { useSessionMock } = vi.hoisted(() => ({
|
||||
useSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
}));
|
||||
|
||||
import { routes } from '@/routes';
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
function Boom(): never {
|
||||
throw new Error('render blew up');
|
||||
}
|
||||
|
||||
/** Recursively clones the real exported route table, replacing only the
|
||||
* `/chat` route's `element` with `<Boom />` — every other route (including
|
||||
* the real `AuthGuard` nesting and the real `/chat` `errorElement`) is left
|
||||
* exactly as exported. This is what makes the test fail if a future change
|
||||
* removes the real route's `errorElement`, unlike a hand-built independent
|
||||
* route tree that could drift from production undetected. */
|
||||
function replaceChatElementWithBoom(nodes: RouteObject[]): RouteObject[] {
|
||||
return nodes.map((node) => {
|
||||
const cloned: RouteObject = { ...node };
|
||||
if (cloned.path === '/chat') {
|
||||
cloned.element = <Boom />;
|
||||
}
|
||||
if (cloned.children) {
|
||||
cloned.children = replaceChatElementWithBoom(cloned.children);
|
||||
}
|
||||
return cloned;
|
||||
});
|
||||
}
|
||||
|
||||
let root: Root | null;
|
||||
let container: HTMLElement;
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
useSessionMock.mockReset();
|
||||
});
|
||||
|
||||
describe('ChatRouteErrorBoundary', () => {
|
||||
it('renders a recoverable, non-blank fallback when the /chat route element throws during render', async () => {
|
||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||
|
||||
const routeObjects = replaceChatElementWithBoom(routes);
|
||||
const router = createMemoryRouter(routeObjects, { initialEntries: ['/chat'] });
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(container.textContent).not.toBe('');
|
||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* `/chat` renders live, server-driven state (streamed text, tool calls,
|
||||
* manifests) that can carry malformed payloads no compile-time contract can
|
||||
* fully rule out at every dereference site. This is the last line of
|
||||
* defense: if something still throws during render, show a recoverable
|
||||
* alert instead of leaving the user on a blank/white screen.
|
||||
*/
|
||||
export function ChatRouteErrorBoundary(): ReactElement {
|
||||
useRouteError();
|
||||
|
||||
return (
|
||||
<div role="alert" className="flex min-h-screen flex-col items-center justify-center gap-3 p-8">
|
||||
<p className="text-sm font-medium">Something went wrong loading chat.</p>
|
||||
<a href="/chat" className="text-sm underline">
|
||||
Reload chat
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createFakeChatSocket } from '@/spa/chat/test-support/fake-chat-socket';
|
||||
import { MAX_MANIFEST_ITEMS } from '@/spa/chat/limits';
|
||||
|
||||
const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({
|
||||
getSocketMock: vi.fn(),
|
||||
destroySocketMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/socket', () => ({
|
||||
getSocket: getSocketMock,
|
||||
destroySocket: destroySocketMock,
|
||||
}));
|
||||
|
||||
import { ChatPage } from './chat';
|
||||
|
||||
function setValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): void {
|
||||
const proto =
|
||||
el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
||||
setter?.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function selectValue(el: HTMLSelectElement, value: string): void {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
|
||||
setter?.call(el, value);
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes(text),
|
||||
);
|
||||
if (!button) throw new Error(`Button with text "${text}" not found`);
|
||||
return button;
|
||||
}
|
||||
|
||||
let fake: ReturnType<typeof createFakeChatSocket>;
|
||||
let root: Root | null;
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
fake = createFakeChatSocket();
|
||||
getSocketMock.mockReset().mockReturnValue(fake.socket);
|
||||
destroySocketMock.mockReset();
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<ChatPage />);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe('ChatPage', () => {
|
||||
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||
});
|
||||
await act(async () => {
|
||||
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'pondering…' });
|
||||
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hel' });
|
||||
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'lo!' });
|
||||
fake.serverEmit('agent:tool:start', {
|
||||
conversationId: 'c1',
|
||||
toolCallId: 't1',
|
||||
toolName: 'web_search',
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('pondering…');
|
||||
expect(container.textContent).toContain('Hello!');
|
||||
expect(container.textContent).toContain('web_search');
|
||||
expect(container.textContent).toMatch(/running/i);
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('agent:tool:end', {
|
||||
conversationId: 'c1',
|
||||
toolCallId: 't1',
|
||||
toolName: 'web_search',
|
||||
isError: false,
|
||||
});
|
||||
fake.serverEmit('agent:end', {
|
||||
conversationId: 'c1',
|
||||
usage: {
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
thinkingLevel: 'medium',
|
||||
tokens: { input: 12, output: 34, cacheRead: 0, cacheWrite: 0, total: 46 },
|
||||
cost: 0.02,
|
||||
context: { percent: 3, window: 200000 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.textContent).toMatch(/success/i);
|
||||
expect(container.textContent).toContain('Hello!');
|
||||
expect(container.textContent).toMatch(/46/);
|
||||
});
|
||||
|
||||
it('renders the commands manifest and session info, and lets the user pick a thinking level', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('commands:manifest', {
|
||||
manifest: {
|
||||
commands: [
|
||||
{
|
||||
name: 'model',
|
||||
aliases: ['m'],
|
||||
description: 'Change the active model',
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
skills: [],
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
fake.serverEmit('session:info', {
|
||||
conversationId: 'c1',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
thinkingLevel: 'medium',
|
||||
availableThinkingLevels: ['low', 'medium', 'high'],
|
||||
routingDecision: {
|
||||
model: 'claude',
|
||||
provider: 'anthropic',
|
||||
ruleName: 'default',
|
||||
reason: 'default routing',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('model');
|
||||
expect(container.textContent).toContain('Change the active model');
|
||||
expect(container.textContent).toContain('anthropic');
|
||||
expect(container.textContent).toContain('default routing');
|
||||
|
||||
const select = container.querySelector(
|
||||
'select[aria-label="Thinking level"]',
|
||||
) as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
expect([...select.options].map((o) => o.value)).toEqual(['low', 'medium', 'high']);
|
||||
|
||||
await act(async () => {
|
||||
selectValue(select, 'high');
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'set:thinking',
|
||||
payload: { conversationId: 'c1', level: 'high' },
|
||||
});
|
||||
});
|
||||
|
||||
it('executes and approves commands with exact payloads and surfaces the approval affordance', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
|
||||
const commandInput = container.querySelector(
|
||||
'input[aria-label="Command name"]',
|
||||
) as HTMLInputElement;
|
||||
const argsInput = container.querySelector(
|
||||
'input[aria-label="Command arguments"]',
|
||||
) as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
setValue(commandInput, 'model');
|
||||
setValue(argsInput, 'gpt-5');
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Run command').click();
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'command:execute',
|
||||
payload: { conversationId: 'c1', command: 'model', args: 'gpt-5' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
setValue(commandInput, 'deploy');
|
||||
setValue(argsInput, 'prod');
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Request approval').click();
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'command:approve',
|
||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('command:approval', {
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: true,
|
||||
approvalId: 'ap1',
|
||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.textContent).toMatch(/approved/i);
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Run approved command').click();
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'command:execute',
|
||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('shows visible alert surfaces for a server error and the structured contract reason for a failed command result', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('error', { conversationId: 'c1', error: 'The model is unavailable' });
|
||||
fake.serverEmit('command:result', {
|
||||
conversationId: 'c1',
|
||||
command: 'model',
|
||||
success: false,
|
||||
message: 'Unknown model',
|
||||
});
|
||||
});
|
||||
|
||||
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
||||
const alertText = alerts.map((node) => node.textContent).join(' ');
|
||||
expect(alertText).toContain('The model is unavailable');
|
||||
// The structured, contract-provided denial reason is visibly rendered.
|
||||
expect(alertText).toContain('Unknown model');
|
||||
});
|
||||
|
||||
it('falls back to a stable "Command failed." copy when a failed command result has no usable message', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmitRaw('command:result', {
|
||||
conversationId: 'c1',
|
||||
command: 'model',
|
||||
success: false,
|
||||
message: { bad: 'object' },
|
||||
});
|
||||
});
|
||||
|
||||
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
||||
const alertText = alerts.map((node) => node.textContent).join(' ');
|
||||
expect(alertText).toContain('Command failed.');
|
||||
});
|
||||
|
||||
it('caps availableThinkingLevels before storing and rendering a hostile session payload', async () => {
|
||||
const hostileLevels = Array.from({ length: MAX_MANIFEST_ITEMS + 50 }, (_, i) => `level-${i}`);
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('session:info', {
|
||||
conversationId: 'c1',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
thinkingLevel: 'level-0',
|
||||
availableThinkingLevels: hostileLevels,
|
||||
});
|
||||
});
|
||||
|
||||
const select = container.querySelector(
|
||||
'select[aria-label="Thinking level"]',
|
||||
) as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
expect(select.options.length).toBeLessThanOrEqual(MAX_MANIFEST_ITEMS);
|
||||
});
|
||||
|
||||
it('renders a safe fallback when session:info arrives with a malformed (non-array) availableThinkingLevels, without throwing', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmitRaw('session:info', {
|
||||
conversationId: 'c1',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
thinkingLevel: 'medium',
|
||||
availableThinkingLevels: null,
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||
const select = container.querySelector(
|
||||
'select[aria-label="Thinking level"]',
|
||||
) as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
// A malformed level list still shows a visible, safe placeholder option
|
||||
// rather than a silently empty select.
|
||||
expect([...select.options]).toHaveLength(1);
|
||||
expect(select.options[0]?.textContent).toMatch(/unavailable/i);
|
||||
|
||||
await act(async () => {
|
||||
selectValue(select, '');
|
||||
});
|
||||
expect(fake.emitted.filter((e) => e.event === 'set:thinking')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders honest unavailable labels — not fabricated zeros — when agent:end usage has malformed/missing numeric fields', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||
fake.serverEmitRaw('agent:end', {
|
||||
conversationId: 'c1',
|
||||
usage: {
|
||||
provider: { nested: 'object' },
|
||||
modelId: undefined,
|
||||
thinkingLevel: 'medium',
|
||||
tokens: { total: 'not-a-number' },
|
||||
cost: undefined,
|
||||
context: { percent: null, window: 200000 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const usage = container.querySelector('[aria-label="Usage"]');
|
||||
expect(usage).toBeTruthy();
|
||||
expect(usage?.textContent).toContain('tokens unavailable');
|
||||
expect(usage?.textContent).toContain('cost unavailable');
|
||||
expect(usage?.textContent).not.toContain('0 tokens');
|
||||
expect(usage?.textContent).not.toContain('$0.0000');
|
||||
expect(usage?.textContent).toContain('unknown/unknown');
|
||||
});
|
||||
|
||||
it('renders a safe fallback for message:ack when messageId is a malformed non-string value, without throwing', async () => {
|
||||
await expect(
|
||||
act(async () => {
|
||||
fake.serverEmitRaw('message:ack', { conversationId: 'c1', messageId: { bad: 'object' } });
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
const status = [...container.querySelectorAll('[role="status"]')].find((node) =>
|
||||
node.textContent?.includes('Message accepted'),
|
||||
);
|
||||
expect(status).toBeTruthy();
|
||||
// A malformed messageId gets a stable, visible fallback — never blank,
|
||||
// never the raw object.
|
||||
expect(status?.textContent).toContain('unknown');
|
||||
});
|
||||
|
||||
it('renders safely and does not throw when system:reload.message is a malformed non-string value', async () => {
|
||||
await expect(
|
||||
act(async () => {
|
||||
fake.serverEmitRaw('system:reload', {
|
||||
commands: [],
|
||||
skills: [],
|
||||
providers: [],
|
||||
message: { bad: 'object' },
|
||||
});
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
const status = container.querySelector('[role="status"]');
|
||||
expect(status).toBeTruthy();
|
||||
// A malformed reload message renders a stable, visible fallback rather
|
||||
// than a silently empty status line.
|
||||
expect(status?.textContent).toContain('Commands reloaded.');
|
||||
});
|
||||
|
||||
it('renders safely and does not throw when a scoped error carries a malformed non-string error value', async () => {
|
||||
await expect(
|
||||
act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmitRaw('error', { conversationId: 'c1', error: ['not', 'a', 'string'] });
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
const providerInput = container.querySelector(
|
||||
'input[aria-label="Provider"]',
|
||||
) as HTMLInputElement;
|
||||
const modelInput = container.querySelector('input[aria-label="Model"]') as HTMLInputElement;
|
||||
|
||||
const stopButtonBefore = container.querySelector(
|
||||
'button[aria-label="Stop"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(stopButtonBefore.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
setValue(textarea, 'hello there');
|
||||
setValue(providerInput, 'anthropic');
|
||||
setValue(modelInput, 'claude');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'message',
|
||||
payload: {
|
||||
conversationId: undefined,
|
||||
content: 'hello there',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
},
|
||||
});
|
||||
expect(container.textContent).toContain('hello there');
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||
});
|
||||
|
||||
const stopButtonDuring = container.querySelector(
|
||||
'button[aria-label="Stop"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(stopButtonDuring.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
stopButtonDuring.click();
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
|
||||
});
|
||||
|
||||
it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => {
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await act(async () => {
|
||||
setValue(textarea, 'hello');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fake.serverEmit('session:info', {
|
||||
conversationId: 'c1',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
thinkingLevel: 'medium',
|
||||
availableThinkingLevels: ['low', 'medium', 'high'],
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain('anthropic');
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
|
||||
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain('anthropic');
|
||||
});
|
||||
|
||||
it('surfaces a pre-ack error as an alert without leaving the Stop control stuck active', async () => {
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await act(async () => {
|
||||
setValue(textarea, 'hello');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fake.serverEmit('error', {
|
||||
conversationId: 'c1',
|
||||
error: 'Failed to start agent session. Please try again.',
|
||||
});
|
||||
});
|
||||
|
||||
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
||||
expect(alerts.some((node) => node.textContent?.includes('Failed to start agent session'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const stopButton = container.querySelector('button[aria-label="Stop"]') as HTMLButtonElement;
|
||||
expect(stopButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('shows an accessible status once the message is acknowledged', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
|
||||
const statuses = [...container.querySelectorAll('[role="status"]')];
|
||||
expect(statuses.some((node) => node.textContent?.includes('m1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('renders finalized thinking text in the transcript after agent:end, not only while streaming', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'reasoning about it' });
|
||||
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Done.' });
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('reasoning about it');
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('agent:end', { conversationId: 'c1' });
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('reasoning about it');
|
||||
expect(container.textContent).toContain('Done.');
|
||||
});
|
||||
|
||||
it('ignores a concurrent approval request and only executes the approved command once', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
|
||||
const commandInput = container.querySelector(
|
||||
'input[aria-label="Command name"]',
|
||||
) as HTMLInputElement;
|
||||
const argsInput = container.querySelector(
|
||||
'input[aria-label="Command arguments"]',
|
||||
) as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
setValue(commandInput, 'deploy');
|
||||
setValue(argsInput, 'prod');
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Request approval').click();
|
||||
});
|
||||
await act(async () => {
|
||||
setValue(argsInput, 'staging');
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Request approval').click();
|
||||
});
|
||||
|
||||
expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(1);
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'command:approve',
|
||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('command:approval', {
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: true,
|
||||
approvalId: 'ap1',
|
||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Run approved command').click();
|
||||
findButton(container, 'Run approved command').click();
|
||||
});
|
||||
|
||||
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(1);
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'command:execute',
|
||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('disables sending a second message while a turn is streaming', async () => {
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await act(async () => {
|
||||
setValue(textarea, 'first');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||
});
|
||||
|
||||
const sendButton = findButton(container, 'Send');
|
||||
expect(sendButton.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
setValue(textarea, 'second');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => {
|
||||
expect(fake.listeners.size).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = null;
|
||||
|
||||
for (const [, handlers] of fake.listeners) {
|
||||
expect(handlers.size).toBe(0);
|
||||
}
|
||||
expect(destroySocketMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { CommandsPanel } from '@/spa/chat/commands-panel';
|
||||
import { Composer } from '@/spa/chat/composer';
|
||||
import { MessageTranscript } from '@/spa/chat/message-transcript';
|
||||
import { asFiniteNumberOrNull, asString } from '@/spa/chat/runtime-guards';
|
||||
import { SessionPanel } from '@/spa/chat/session-panel';
|
||||
import { ToolCallList } from '@/spa/chat/tool-call-list';
|
||||
import { useChatConnection } from '@/spa/chat/use-chat-connection';
|
||||
|
||||
/** Renders a real value normally, but an honest "unavailable" label instead
|
||||
* of a fabricated `0` for a missing/malformed count — a real `0 tokens` and
|
||||
* an unknown token count must never look the same. */
|
||||
function formatTokens(value: unknown): string {
|
||||
const tokens = asFiniteNumberOrNull(value);
|
||||
return tokens === null ? 'tokens unavailable' : `${tokens} tokens`;
|
||||
}
|
||||
|
||||
/** Same honesty guarantee as `formatTokens`, for cost. */
|
||||
function formatCost(value: unknown): string {
|
||||
const cost = asFiniteNumberOrNull(value);
|
||||
return cost === null ? 'cost unavailable' : `$${cost.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export function ChatPage(): ReactElement {
|
||||
const { state, actions } = useChatConnection();
|
||||
const hasConversation = state.conversationId !== null;
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] min-h-0 flex-col overflow-hidden md:h-screen">
|
||||
<header className="border-b px-4 py-3">
|
||||
<h1 className="text-lg font-semibold">Chat</h1>
|
||||
</header>
|
||||
|
||||
{state.systemReload ? (
|
||||
<div role="status" className="border-b px-4 py-2 text-sm">
|
||||
{asString(state.systemReload.message)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.error ? (
|
||||
<div role="alert" className="border-b px-4 py-2 text-sm">
|
||||
{asString(state.error)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.ack ? (
|
||||
<div role="status" className="border-b px-4 py-1 text-xs opacity-70">
|
||||
Message accepted · conversation {asString(state.ack.conversationId, 'unknown')} · id{' '}
|
||||
{asString(state.ack.messageId, 'unknown')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SessionPanel sessionInfo={state.sessionInfo} onSetThinking={actions.setThinking} />
|
||||
|
||||
<MessageTranscript messages={state.messages} streaming={state.streaming} text={state.text} />
|
||||
|
||||
{state.thinking ? (
|
||||
<section aria-label="Thinking" className="px-4 pb-2 text-xs italic opacity-80">
|
||||
{state.thinking}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<ToolCallList tools={state.tools} />
|
||||
|
||||
{state.usage ? (
|
||||
<div aria-label="Usage" className="px-4 pb-2 text-xs opacity-80">
|
||||
{formatTokens(state.usage.tokens?.total)} · {formatCost(state.usage.cost)} ·{' '}
|
||||
{asString(state.usage.provider, 'unknown')}/{asString(state.usage.modelId, 'unknown')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CommandsPanel
|
||||
manifest={state.manifest}
|
||||
results={state.commandResults}
|
||||
approval={state.approval}
|
||||
pendingApproval={state.pendingApproval}
|
||||
hasConversation={hasConversation}
|
||||
onExecute={actions.executeCommand}
|
||||
onApprove={actions.approveCommand}
|
||||
onRunApproved={actions.runApprovedCommand}
|
||||
/>
|
||||
|
||||
<Composer
|
||||
onSend={actions.sendMessage}
|
||||
onStop={actions.abort}
|
||||
streaming={state.streaming}
|
||||
sending={state.sending}
|
||||
hasConversation={hasConversation}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState, type FormEvent, type ReactElement } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons';
|
||||
import { api } from '@/lib/api';
|
||||
import { authClient, signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
|
||||
export function LoginPage(): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
||||
const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState<
|
||||
SsoProviderDiscovery['id'] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
void api<SsoProviderDiscovery[]>('/api/sso/providers').then(
|
||||
(providers) => {
|
||||
if (active) setSsoProviders(providers.filter((provider) => provider.configured));
|
||||
},
|
||||
() => {
|
||||
if (active) setSsoProviders([]);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(event.currentTarget);
|
||||
const email = String(form.get('email') ?? '');
|
||||
const password = String(form.get('password') ?? '');
|
||||
|
||||
try {
|
||||
const result = await signIn.email({ email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Sign in failed');
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/chat', { replace: true });
|
||||
} catch (caught: unknown) {
|
||||
setError(caught instanceof Error ? caught.message : 'Sign in failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise<void> {
|
||||
setError(null);
|
||||
setSsoLoadingProviderId(providerId);
|
||||
|
||||
try {
|
||||
const result = await authClient.signIn.oauth2({
|
||||
providerId,
|
||||
callbackURL: '/chat',
|
||||
newUserCallbackURL: '/chat',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? `Sign in with ${providerId} failed`);
|
||||
setSsoLoadingProviderId(null);
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
setError(caught instanceof Error ? caught.message : `Sign in with ${providerId} failed`);
|
||||
setSsoLoadingProviderId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Sign in</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Sign in to your Mosaic account</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<SsoProviderButtons
|
||||
providers={ssoProviders}
|
||||
loadingProviderId={ssoLoadingProviderId}
|
||||
onOidcSignIn={(providerId) => {
|
||||
void handleSsoSignIn(providerId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-text-muted">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-blue-400 hover:text-blue-300">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState, type FormEvent, type ReactElement } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { signUp } from '@/lib/auth-client';
|
||||
|
||||
export function RegisterPage(): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(event.currentTarget);
|
||||
const name = String(form.get('name') ?? '');
|
||||
const email = String(form.get('email') ?? '');
|
||||
const password = String(form.get('password') ?? '');
|
||||
|
||||
try {
|
||||
const result = await signUp.email({ name, email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Registration failed');
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/chat', { replace: true });
|
||||
} catch (caught: unknown) {
|
||||
setError(caught instanceof Error ? caught.message : 'Registration failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Create account</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Get started with Mosaic</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-text-secondary">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-text-muted">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-blue-400 hover:text-blue-300">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { apiMock, oauth2Mock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
oauth2Mock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
signIn: { oauth2: oauth2Mock },
|
||||
}));
|
||||
|
||||
import { SsoCallbackPage } from './sso-callback';
|
||||
|
||||
const mountedRoots: Root[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
apiMock.mockResolvedValue([
|
||||
{
|
||||
id: 'authentik',
|
||||
name: 'Authentik',
|
||||
protocols: ['oidc'],
|
||||
configured: true,
|
||||
loginMode: 'oidc',
|
||||
callbackPath: '/api/auth/oauth2/callback/authentik',
|
||||
teamSync: { enabled: false, claim: null },
|
||||
samlFallback: { configured: false, loginUrl: null },
|
||||
warnings: [],
|
||||
},
|
||||
]);
|
||||
oauth2Mock.mockResolvedValue({ data: null, error: null });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of mountedRoots.splice(0)) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
document.body.replaceChildren();
|
||||
apiMock.mockReset();
|
||||
oauth2Mock.mockReset();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
describe('SsoCallbackPage', () => {
|
||||
it('rejects a control-character callback that normalizes to an external origin', async () => {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: '/auth/provider/:provider',
|
||||
element: <SsoCallbackPage />,
|
||||
},
|
||||
],
|
||||
{
|
||||
initialEntries: ['/auth/provider/authentik?callbackURL=%2F%0A%2F%2Fevil.example'],
|
||||
},
|
||||
);
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
mountedRoots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<RouterProvider router={router} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(oauth2Mock).toHaveBeenCalledWith({
|
||||
providerId: 'authentik',
|
||||
callbackURL: '/chat',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState, type ReactElement } from 'react';
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
|
||||
export function SsoCallbackPage(): ReactElement {
|
||||
const { provider: providerId = '' } = useParams<'provider'>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
||||
const [providerName, setProviderName] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function redirectToProvider(): Promise<void> {
|
||||
try {
|
||||
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
||||
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
||||
if (cancelled) return;
|
||||
|
||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||
if (!provider) {
|
||||
setError('Unknown SSO provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProviderName(provider.name);
|
||||
if (!provider.configured) {
|
||||
setError(`${provider.name} is not enabled in this deployment.`);
|
||||
return;
|
||||
}
|
||||
if (provider.loginMode !== 'oidc') {
|
||||
setError(`${provider.name} is not available for OIDC sign in.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signIn.oauth2({
|
||||
providerId: provider.id,
|
||||
callbackURL,
|
||||
});
|
||||
|
||||
if (!cancelled && result?.error) {
|
||||
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void redirectToProvider();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [providerId, requestedCallbackURL]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
{providerName
|
||||
? `Redirecting you to ${providerName}...`
|
||||
: 'Preparing your sign-in request...'}
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
<p>{error}</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Return to login
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
|
||||
If the redirect does not start automatically, return to the login page and try again.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export function Placeholder({ title }: { title: string }): ReactElement {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<h1 className="text-xl font-medium">{title}</h1>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { isValidElement } from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { RouteObject } from 'react-router-dom';
|
||||
import { routes } from '@/routes';
|
||||
import { Placeholder } from '@/spa/placeholder';
|
||||
import { ChatPage } from '@/spa/pages/chat';
|
||||
|
||||
function collectPaths(routeObjects: RouteObject[]): string[] {
|
||||
return routeObjects.flatMap((route) => [
|
||||
...(route.path ? [route.path] : []),
|
||||
...(route.children ? collectPaths(route.children) : []),
|
||||
]);
|
||||
}
|
||||
|
||||
function findRoute(routeObjects: RouteObject[], path: string): RouteObject | undefined {
|
||||
for (const route of routeObjects) {
|
||||
if (route.path === path) return route;
|
||||
const nested = route.children ? findRoute(route.children, path) : undefined;
|
||||
if (nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('SPA route table', () => {
|
||||
it('covers every v1 parity route from the Phase P RFC', () => {
|
||||
expect(collectPaths(routes).sort()).toEqual(
|
||||
[
|
||||
'/',
|
||||
'/admin',
|
||||
'/auth/provider/:provider',
|
||||
'/chat',
|
||||
'/login',
|
||||
'/projects',
|
||||
'/projects/:id',
|
||||
'/register',
|
||||
'/settings',
|
||||
'/tasks',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('separates guest and authenticated route groups', () => {
|
||||
const guestPaths = collectPaths(routes.at(0)?.children ?? []);
|
||||
const authPaths = collectPaths(routes.at(1)?.children ?? []);
|
||||
expect(guestPaths).toContain('/login');
|
||||
expect(guestPaths).not.toContain('/chat');
|
||||
expect(authPaths).toContain('/chat');
|
||||
});
|
||||
|
||||
it.each(['/login', '/register', '/auth/provider/:provider'])(
|
||||
'renders a real guest page instead of the P1 placeholder at %s',
|
||||
(path) => {
|
||||
const element = findRoute(routes, path)?.element;
|
||||
expect(isValidElement(element)).toBe(true);
|
||||
if (!isValidElement(element)) throw new Error(`Missing route element for ${path}`);
|
||||
expect(element.type).not.toBe(Placeholder);
|
||||
},
|
||||
);
|
||||
|
||||
it('renders the real chat page instead of the P1 placeholder at /chat, inside the authenticated group', () => {
|
||||
const authPaths = collectPaths(routes.at(1)?.children ?? []);
|
||||
expect(authPaths).toContain('/chat');
|
||||
|
||||
const element = findRoute(routes, '/chat')?.element;
|
||||
expect(isValidElement(element)).toBe(true);
|
||||
if (!isValidElement(element)) throw new Error('Missing route element for /chat');
|
||||
expect(element.type).not.toBe(Placeholder);
|
||||
expect(element.type).toBe(ChatPage);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Vitest abort-controller realm', () => {
|
||||
it('provides a global signal accepted by Node native Request', () => {
|
||||
const controller = new AbortController();
|
||||
const request = new Request('https://mosaic.invalid/navigation', {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(request.signal).toBeInstanceOf(AbortSignal);
|
||||
controller.abort();
|
||||
expect(request.signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { transferableAbortController } from 'node:util';
|
||||
|
||||
// jsdom installs realm-local abort constructors while Node's undici Request
|
||||
// remains native. React Router passes a global AbortSignal to Request, so both
|
||||
// constructors must come from Node's native realm during tests.
|
||||
const nativeController = transferableAbortController();
|
||||
const nativeAbortController = nativeController.constructor;
|
||||
const nativeAbortSignal = nativeController.signal.constructor;
|
||||
|
||||
for (const target of [globalThis, window]) {
|
||||
Object.defineProperties(target, {
|
||||
AbortController: {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: nativeAbortController,
|
||||
},
|
||||
AbortSignal: {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: nativeAbortSignal,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// The proxy exists only in dev; in production the SPA is same-origin with the gateway
|
||||
// (served by it under Candidate A, or behind one FQDN under Candidate B) and every
|
||||
// request uses a relative path, so no origin may ever be configured here or in src/.
|
||||
const gatewayTarget = 'http://localhost:14242';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3100,
|
||||
proxy: {
|
||||
'/api': gatewayTarget,
|
||||
'/socket.io': { target: gatewayTarget, ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,21 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
// tsconfig uses "jsx": "preserve" for Next; tests need esbuild to compile it
|
||||
esbuild: {
|
||||
jsx: 'automatic',
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
isolate: true,
|
||||
exclude: ['e2e/**', 'node_modules/**'],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/gateway/package.json ./apps/gateway/
|
||||
COPY packages/ ./packages/
|
||||
COPY plugins/ ./plugins/
|
||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||
COPY scripts/ ./scripts/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
||||
@@ -21,11 +23,22 @@ RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
# WorkspaceService shells out to git at runtime and roots workspaces at
|
||||
# $MOSAIC_ROOT/.workspaces (apps/gateway/src/workspace/workspace.service.ts);
|
||||
# mount a volume over /opt/mosaic to persist workspaces across container restarts.
|
||||
# Intentionally unpinned: Alpine's signed repository is the trust anchor; pinning
|
||||
# git was declined so routine base-image security updates remain maintainable.
|
||||
RUN apk add --no-cache git \
|
||||
&& mkdir -p /opt/mosaic/.workspaces \
|
||||
&& chown -R node:node /opt/mosaic /app
|
||||
ENV MOSAIC_ROOT=/opt/mosaic
|
||||
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
||||
COPY --from=builder /deploy/node_modules ./node_modules
|
||||
COPY --from=builder /deploy/package.json ./package.json
|
||||
COPY --chown=node:node --from=builder /deploy/node_modules ./node_modules
|
||||
COPY --chown=node:node --from=builder /deploy/package.json ./package.json
|
||||
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
||||
# copy from builder explicitly as belt-and-suspenders
|
||||
COPY --from=builder /app/apps/gateway/dist ./dist
|
||||
EXPOSE 4000
|
||||
COPY --chown=node:node --from=builder /app/apps/gateway/dist ./dist
|
||||
# gateway defaults to port 14242 (apps/gateway/src/main.ts)
|
||||
EXPOSE 14242
|
||||
USER node
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
@@ -8,9 +8,11 @@ WORKDIR /app
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/ ./packages/
|
||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||
COPY scripts/ ./scripts/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm --filter @mosaic/web build
|
||||
RUN pnpm --filter @mosaicstack/web build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
+2
-8
@@ -149,15 +149,9 @@ for any `<Image>` components added in the future.
|
||||
|
||||
---
|
||||
|
||||
## How to Apply
|
||||
## Held future procedure
|
||||
|
||||
```bash
|
||||
# Run the DB migration (requires a live DB)
|
||||
pnpm --filter @mosaicstack/db exec drizzle-kit migrate
|
||||
|
||||
# Or, in Docker/Swarm — migrations run automatically on gateway startup
|
||||
# via runMigrations() in packages/db/src/migrate.ts
|
||||
```
|
||||
This report is non-operative evidence, not a current runbook. Until **KBN-101-00, KBN-101-03, and KBN-101-05** land, do not execute a PostgreSQL runner from this checkout. The approved future procedure is exactly: external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. Deployment will supply the reviewed runner, migration-only credentials, and TLS material; Gateway startup only verifies readiness.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+126
-4
@@ -79,6 +79,29 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
|
||||
|
||||
---
|
||||
|
||||
## Compaction Refresh Trust Lifecycle (M1, #827–#830)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
Context compaction, session replacement, and same-PID runtime reloads can leave a previously VERIFIED runtime lease attached to stale directives. M1 must revoke that authority mechanically for Claude (including Claudex) and Pi without trusting caller-asserted identity or forking the external broker state machine.
|
||||
|
||||
### Requirements
|
||||
|
||||
1. `CR-REQ-01`: Claude `PreCompact` and `SessionStart` with matcher `compact`, plus Pi `session_before_compact` and the first post-`session_compact` `context`, SHALL independently revoke the active broker lease.
|
||||
2. `CR-REQ-02`: Runtime generation increases—including same-PID Pi reload/new/resume/fork and Claude resume/clear—SHALL monotonically replace the prior broker incarnation and inherit no VERIFIED lease.
|
||||
3. `CR-REQ-03`: A fired observer that cannot confirm broker revocation SHALL fail closed through lifecycle cancellation, a private local generation fence, and/or a runtime-local tool latch. The existing all-tools broker gate remains authoritative.
|
||||
4. `CR-REQ-04`: The lease TTL SHALL remain monotonic and capped at 300 seconds. If both observers are missed, within-TTL consequential actions remain allowed and after-TTL actions are denied. This named bounded residual stale window SHALL be documented without claiming a mutator-action bound inside the window.
|
||||
5. `CR-REQ-05`: Hook descendants SHALL use the broker-minted session and owner-only current-generation state inherited from register-before-exec. Caller-minted sessions and parallel lease state machines remain forbidden.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. `AC-CR-01`: Real-socket tests prove each Claude observer revokes, Pi lifecycle tests prove both observer paths, and Claudex isolated settings preserve and install the mandatory hooks.
|
||||
2. `AC-CR-02`: A same-PID generation test proves the old generation is stale and the replacement generation is UNVERIFIED across reload/resume/fork-equivalent lifecycle events.
|
||||
3. `AC-CR-03`: RED-first T12b/T30 evidence explicitly reports dual-hook miss within TTL as **ALLOWED** and after TTL as **DENIED**.
|
||||
4. `AC-CR-04`: Attributable executable coverage is at least 85%, the full repository suite is green on deterministic main, and independent code/security review completes before merge.
|
||||
|
||||
---
|
||||
|
||||
## Fleet Declarative Configuration Management Workstream (FCM, #758)
|
||||
|
||||
### Problem and objective
|
||||
@@ -125,6 +148,105 @@ are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
|
||||
|
||||
---
|
||||
|
||||
## Exact Cross-Harness Fleet Communications Contract (#766)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
Fleet runtime contracts currently combine exact peer rows with generic operational metavariables and
|
||||
independently parsed roster data. Non-Claude harnesses can mistake those metavariables for values to
|
||||
infer, producing incorrect host, session, socket, or helper targets. The objective is one
|
||||
roster-resolved communications contract that every supported harness receives unchanged.
|
||||
|
||||
### Normative requirements
|
||||
|
||||
1. `FCOM-REQ-01`: Fleet commands and runtime composition SHALL use one shared v1 roster structural
|
||||
resolver. A second lenient communications parser is forbidden.
|
||||
2. `FCOM-REQ-02`: The composed contract SHALL render the local roster member's authoritative host,
|
||||
exact agent/session name, resolved tmux socket, exact helper path, and deterministic communications
|
||||
generation.
|
||||
3. `FCOM-REQ-03`: Every known peer SHALL have one exact executable command. Same-host commands SHALL
|
||||
omit `-H`; cross-host commands SHALL use only that peer's explicit roster `ssh` target; the one
|
||||
supported fleet-wide named socket SHALL use `-L` with its exact value. A per-agent socket declaration
|
||||
must equal that fleet-wide value; unsupported independent sockets and missing cross-host SSH data SHALL
|
||||
fail closed.
|
||||
4. `FCOM-REQ-04`: Operational fleet examples SHALL not contain unresolved host, session, socket, or
|
||||
helper-path metavariables. Agents SHALL select an exact rendered peer row and SHALL NOT infer,
|
||||
substitute, or fuzzy-match targeting values.
|
||||
5. `FCOM-REQ-05`: An unknown local member or requested peer SHALL fail closed with exact-name discovery
|
||||
guidance. Runtime composition SHALL not silently omit a requested fleet member's communications
|
||||
contract.
|
||||
6. `FCOM-REQ-06`: Claude Code, Codex, OpenCode, and Pi SHALL receive equivalent authoritative
|
||||
communications data through the common runtime composer.
|
||||
7. `FCOM-REQ-07`: Tests SHALL prove the contract from framework-source `TOOLS.md`, through a fresh
|
||||
installed `TOOLS.md`, to final runtime composition and helper executability. User-owned installed
|
||||
`TOOLS.md` content SHALL remain preserved.
|
||||
8. `FCOM-REQ-08`: Stale installed or active composed context SHALL be reported with deterministic
|
||||
generation/repair/relaunch guidance. Currency requires the expected source and installed contract
|
||||
marker/version plus bounded byte equality. The supported current-version repair SHALL run independently
|
||||
of package updates, preserve divergent `TOOLS.md` bytes in a digest-qualified no-clobber backup, restore
|
||||
a regular executable helper without following symlinks, and be idempotent. Detection and reporting SHALL
|
||||
NOT rewrite active context, restart a session, or mutate a live fleet.
|
||||
9. `FCOM-REQ-09`: The shared resolver SHALL preserve and strictly validate every schema-supported v1
|
||||
connector kind (`tmux`, `discord`, and `matrix`) from YAML and JSON. Every accepted snake/camel alias
|
||||
pair SHALL reject differing dual declarations and accept identical declarations. JSON roster fallback
|
||||
SHALL occur only when `roster.yaml` is absent; all other YAML access failures SHALL fail closed.
|
||||
10. `FCOM-REQ-10`: The communications generation SHALL cover the complete canonical rendered semantic
|
||||
contract, including identity, role/class, resolved host/socket/helper, peer metadata, and exact commands.
|
||||
Installed helpers SHALL be validated with no-follow filesystem inspection as regular executable files.
|
||||
Keep-mode reseed and relaunch discovery SHALL preserve and support both YAML and JSON rosters.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. `AC-FCOM-01`: Contract fixtures contain no unresolved operational targeting metavariables; local
|
||||
identity contains exact host/session/socket/helper values.
|
||||
2. `AC-FCOM-02`: Same-host, cross-host, named-socket, literal-default-socket, and missing-SSH tests prove
|
||||
exact targeting and fail-closed behavior.
|
||||
3. `AC-FCOM-03`: Unknown identities and peers report known exact names plus an exact self-scoped
|
||||
discovery command; no fuzzy session selection is emitted.
|
||||
4. `AC-FCOM-04`: Four-harness tests prove byte-equal authoritative communications sections.
|
||||
5. `AC-FCOM-05`: Source, fresh-install, preserved-custom-install, stale-installed, composed-generation,
|
||||
helper executable, agent-send socket isolation, and exact-target tests pass.
|
||||
6. `AC-FCOM-06`: Documentation defines non-mutating stale-context detection and operator-authorized,
|
||||
exact-agent relaunch; no implementation path performs automatic session mutation.
|
||||
7. `AC-FCOM-07`: YAML and JSON fixtures cover every connector kind; all snake/camel aliases cover
|
||||
identical acceptance and conflicting rejection; non-`ENOENT` YAML failures do not fall back.
|
||||
8. `AC-FCOM-08`: Missing, directory, symlink, and non-executable installed helpers fail closed. Explicit
|
||||
current-version repair proves partial-deletion recovery, digest-qualified backup collision safety,
|
||||
symlink-target safety, and repeated-run idempotence.
|
||||
9. `AC-FCOM-09`: Markerless-equal and wrong-version source/installed contracts are stale, and a rendered
|
||||
role/class change produces a different communications generation.
|
||||
|
||||
---
|
||||
|
||||
## KBN-101 Database Runtime/Migration Role Split (#771)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
PostgreSQL Gateway/storage currently uses one `DATABASE_URL` for runtime queries and migrations. That makes the deployed application identity an owner and prevents certification that KBN immutable event, artifact, checkpoint, and evidence relations reject runtime `UPDATE`/`DELETE`. KBN-101 freezes a least-privilege runtime/migration split before KBN-100 schema work.
|
||||
|
||||
### Normative requirements
|
||||
|
||||
1. `K101-REQ-01`: `DATABASE_URL` SHALL be the non-owner PostgreSQL runtime connection and `DATABASE_MIGRATION_URL` SHALL be the migration-only owner/migrator connection. They are required respectively for runtime and the dedicated `mosaic-db-migrator --run|--verify` phase in `standalone`/`federated`; local PGlite is the explicit exception. The published `@mosaicstack/db` bin maps exactly `mosaic-db-migrator` to `./dist/cli.js`, its image entrypoint is exactly `mosaic-db-migrator`, accepts no URL/SQL/schema/role argv, and returns stable sanitized exits. Every current/future PostgreSQL DDL entrypoint SHALL route to that runner or be denied, and SHALL reject `DATABASE_URL`-only execution before connection/DDL. Data migration may connect only after the runner prepares and verifies the PostgreSQL target, through dedicated non-DDL `mosaic_data_importer` and exactly `--target-url-file /run/secrets/mosaic-migrate-target-url`, its fixed paired authenticated provider-version file `/run/secrets/mosaic-migrate-target-version`, plus `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`. KBN-101-05 obtains URL key `url` and version only from the same successful Vault KV-v2 response at `secret-{env}/mosaic-stack/database/importer` (`data.metadata.version`), renders them as one immutable generation into separate consumer copies, and never infers a provider version from DSN bytes. The trusted runner verifies TLS/identity/manifest, reads its fixed importer URL/version copies only for binding through safe no-follow fd checks, and signs a credential-free JCS/Ed25519 attestation using its runner-only fixed root-owned private-key file; no signing key reaches importer/runtime. The artifact binds secret version and SHA-256 of exact high-entropy credential-file bytes, canonical TLS host/port/database, CA/SPKI, PostgreSQL system identifier/database OID, importer role, manifest/schema fingerprints, producer invocation/build/image digest, issued/expires/nonce, and correlation. Before target connection the importer validates URL/version/attestation/public-key files, signature/key/expiry/replay/authenticated provider version/digest/generation/bindings and the importer-only CA at exact `DATABASE_TLS_CA_CERT_PATH`; after verified TLS and before DML it validates server/database/role/CA/schema identity, with same-fd/in-memory-byte TOCTOU protection, rotation/revocation, a privileged producer-only-to-importer-only artifact handoff controller that verifies/copies/fsyncs/atomically renames/seals before importer start, consumer isolation/no logging-oracle, and sanitized errors. Raw `--target-url`, `DATABASE_URL` fallback, runtime-owner use, missing/unsafe/substituted files, stale/replayed/tampered/wrong-key attestation, wrong binding, and DDL attempt fail before target connection/DDL; post-connect mismatch closes with zero DML/DDL. A reviewed finite classifier inventories executable current source/scripts/package bins, operator docs, deploy manifests, and exact normative contracts by path; active secure records pin both options/files, producer/key/bindings/tests, while normative contracts cannot mask instructions. Unknown active commands, duplicate-owner, ownerless, missing-path, and historical/status-only masking hits fail. `db:push` is forbidden outside an explicitly disposable local developer database and cannot accept a production-like URL.
|
||||
2. `K101-REQ-02`: Gateway runtime/replicas SHALL not execute migrations or DDL. The runner SHALL hold one `max:1` session and fixed two-int advisory namespace `1297044289` (`MOSA`), `1262636593` (`KBN1`) across preflight, reconciliation, migration, verification, and release. It SHALL compare the versioned canonical manifest v1 tuple (journal logical index/tag plus exact SQL-byte SHA-256) to the complete observed ledger mapping; count/set-only, timestamps, and physical insertion order are non-normative and insufficient.
|
||||
3. `K101-REQ-03`: PostgreSQL SHALL separate non-login platform database owner, non-login schema owner, dedicated `NOLOGIN SUPERUSER` `mosaic_extension_owner`, login migrator, dedicated login non-DDL data importer, non-login runtime capability, and login runtime roles. For PostgreSQL 17 + pgvector 0.8.2, `vector` is untrusted (`trusted` is absent and `relocatable=true`): only an externally controlled audited platform-bootstrap superuser session may `SET ROLE mosaic_extension_owner` for CREATE/UPDATE/SET SCHEMA, then `RESET ROLE`; the role has `rolcanlogin=false`, `rolsuper=true`, zero members, no runtime credential/Vault secret, and is never provided to app containers. It owns `mosaic_extensions`, fresh `vector`, and owner-bearing extension members, while `mosaic_schema_owner` receives only `USAGE` for type resolution and never ownership/`CREATE`/`ALTER`/`DROP`/member-change/default-privilege authority there. Superuser cannot be constrained by `GRANT`/`REVOKE`; this is identity/non-login/no-membership/external-control/audit isolation, not a false least-privilege claim. Extension operations require control-plane change, independent review, backup/rollback, maintenance window, and audit evidence. Managed targets that cannot establish this exact role are ineligible until an independently approved versioned provider-owned extension-owner profile exists; app/migrator ownership is never silently retained. Existing approved-owner extension relocation validates exact `pg_namespace.nspowner`, `pg_extension.extowner`, member ownership/schema/version, while legacy runtime-owned extension fails closed to a controlled shadow-database migration—never unsupported ownership alteration, catalog mutation, ownership adoption, or `DROP CASCADE`. Runtime, migrator, schema owner, importer, and all service roles must fail `SET ROLE`, catalog/direct `ALTER`/`UPDATE`/`DROP`/membership-change denial, role ownership, superuser/role-creation/schema-creation/TEMPORARY, unsafe membership, untrusted search path, missing grants, unauthenticated TLS, and immutable privilege drift checks. Application schema is fixed `mosaic` with exact `pg_catalog,mosaic` session path; historical public migrations remain byte-immutable legacy bootstrap only, every future Drizzle application declaration targets `mosaic`, and `vector` is explicitly qualified from non-writable `mosaic_extensions`. No config-derived SQL identifier is permitted.
|
||||
4. `K101-REQ-04`: `mosaicstack/stack` KBN-101-00 SHALL exclusively own `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, and bootstrap tests; KBN-101-05 SHALL exclusively own `tools/db/render-postgres-secrets.ts`, its tests, and current Compose/Portainer/two-gateway deployment declarations, consuming the versioned bootstrap interface without overlap. Environment IaC/Vault is named input and Mosaic deployment control plane/Jason is activation authority. Distinct runtime/migrator/importer URL, importer authenticated provider-version, DB-client CA, Gateway leaf, and PostgreSQL server key/certificate materials are provisioned before a production-like database starts. Importer and migrator have separate immutable URL/version copies at fixed `10002:10002`/`10003:10003` identities; runtime/unrelated containers receive neither importer material, attestation private key, or importer artifact. Runtime, migrator, and importer require their mounted CA plus `sslmode=verify-full`. Exact UID/GID/mode/rendering, service-DNS SANs, Vault/compose/Swarm consumer isolation, two-gateway pair ordering, server activation, pre-enforcement legacy-client drain and `hostssl` zero-plaintext-session proof, fresh/existing transition, CA-overlap rotation, TLS-only rollback, and standalone/federated/Swarm/two-gateway positive/negative TLS evidence are required. No application-generated production certificate or plaintext bootstrap exception is permitted.
|
||||
5. `K101-REQ-05`: KBN immutable relations SHALL permit the real runtime role INSERT/SELECT only and deny UPDATE/DELETE; parent retention remains RESTRICT/no-cascade. Role/password/Vault creation is external platform control, never application migration/source.
|
||||
6. `K101-REQ-06`: N-1 single-URL compatibility, rollout/rollback, Vault ownership/rotation/redaction, CI, installer, compose/Portainer, observability, and deployment handoffs SHALL be separately bounded one-card/one-PR work. Prepared slices remain inactive while current owner-runtime deployments stay N-1; Mosaic control plane/Jason alone authorizes one final atomic activation or rollback, with no force-on-red/bypass. KBN-101 planning itself SHALL not mutate production.
|
||||
7. `K101-REQ-07`: KBN-100 SHALL begin only after the KBN-101 foundation role/schema-boundary certificate; it SHALL rebase on that main head, restore generated Drizzle declaration/snapshot/journal consistency, and bound procedural immutable-table grant/trigger/backfill additions to its schema slice. KBN-101 real deployed-role immutable-operation certification SHALL complete after KBN-100 creates those relations and before KBN-105.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. `AC-K101-01`: DTO/command-matrix tests prove required modes, PGlite exception, `mosaic-db-migrator --help|--run|--verify`/stable exits/argv refusal, public-import negative, every finite classified DDL/static-bypass inventory path and both harness pairs reject `DATABASE_URL`-only before connection/DDL, no migration-to-runtime fallback, and `db:push` refusal outside an allowlisted disposable DB. Before inventory, ownership, or status masking, the semantic fixture fails README's exact former commented code-fence generic-wrapper form and the user guide's exact former executable generic-wrapper form; source-consistency proves current `packages/storage/src/cli.ts` directly `execSync`s `pnpm --filter @mosaicstack/db db:migrate` and no `mosaic-db-migrator` bin exists, so runner-delegation documentation fails. The active `docs/guides/migrate-tier.md` route is inventoried to KBN-101-07 and proves runner-produced `--target-url-file /run/secrets/mosaic-migrate-target-url`, fixed paired provider-version file, and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`; runner-only signing/private-key isolation; Vault KV-v2 same-response version provenance, separate immutable generation mounts, importer CA, JCS/Ed25519 signature/key rotation/revocation, atomic artifact, expiry/replay, safe-fd secret-version/digest, canonical TLS/CA/server/database/role/manifest/schema bindings, dedicated non-DDL importer, consumer isolation/no log-oracle, and exact no-connection versus zero-DML rejection for missing/wrong/stale/replayed/tampered/wrong-key/substituted/generation-mismatched inputs. The full current non-normative docs inventory—including user guide, federation historical task/MILESTONES status, and non-operative SETUP—has an exact safe disposition. Scanner semantic checks reject automatic first-boot/startup extension/schema/migration wording, Compose-up-before-runner, init-script authority, production `.env`/monorepo auto-load/`EnvironmentFile=`/credential-export-or-argv/restart-as-secret-activation routes, and every unqualified operator-document `mosaic-db-migrator --run|--verify` hit regardless of named/normative/status classification. The exact former README/dev/deployment Compose-first sequences, former SETUP wording, exact former MILESTONES wording `pgvector extension installed + verified on startup`, former architecture-plan/PERFORMANCE/backlog runner routes, and any unqualified runner fixture fail before inventory masking. Only one `Held future procedure` Markdown section—bounded through the next equal-or-higher heading—may contain the explicit non-operative/no-current-command-authority form that names KBN-101-00/-03/-05 and preserves external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness; every runner hit outside that section fails. The README assertion for the checked-in direct CI `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` passes only as active legacy N-1, uncertified, non-authorizing-as-an-operator-route status against an isolated disposable CI database pending KBN-101-06 removal—not as an ordinary operator or approved DDL-authority route. Only local PGlite data-layer work or non-PostgreSQL Compose is current (Gateway/Web local startup is held pending daemon/inherited/project-DSN rejection).
|
||||
2. `AC-K101-02`: Fixed namespace lock contention/crash/readiness/non-interference and exact manifest-v1 reconciliation tests prove no replica race/runtime auto-migration and fail closed on every missing/unknown/duplicate/ambiguous/corrupt/stale ledger state.
|
||||
3. `AC-K101-03`: Actual PostgreSQL 17 + pgvector 0.8.2 control-file, catalog, Drizzle-generation, vector-query/operator, fresh/approved-owner/legacy-shadow/partial/resume/rollback/N-1, and real deployed-role tests prove `trusted` absent/untrusted plus relocatability, external-superuser `SET ROLE` create/update/`RESET ROLE` audit, exact `rolcanlogin=false`/`rolsuper=true`/zero-membership/no-runtime-secret state, platform/schema/extension-owner/migrator/importer/runtime separation, `pg_extension.extowner` plus owner-bearing extension-member/schema/version assertions, and runtime/migrator/schema-owner/importer/all-service-role `SET ROLE`/ALTER/DROP/member-update denial. They also prove `pg_catalog,mosaic` per-session pool safety, `mosaic_extensions` qualification, identifier injection denial, ownership/membership/ledger-read/TEMP/default grants, and unsafe privilege denial.
|
||||
4. `AC-K101-04`: Disposable standalone, federated/Swarm, and two-gateway verified-TLS positives plus for both pairs missing CA/wrong CA/wrong SAN/sslmode downgrade, server/Gateway key mode, UID/GID, secret-consumer isolation, and legacy-drain/`hostssl` negatives prove server bootstrap, ordering, and readiness; PGlite is expressly excluded from this PostgreSQL evidence.
|
||||
5. `AC-K101-05`: Real runtime-role evidence proves INSERT/SELECT succeeds and UPDATE/DELETE fails for every frozen immutable KBN relation.
|
||||
6. `AC-K101-06`: N-1/atomic activation/rollback, Vault/CA-overlap rotation/redaction, health/operator behavior, CI/deployment handoff, independent exact-head security review, and terminal-green CI evidence the foundation before KBN-100; after KBN-100, the real deployed-role immutable-operation certificate and Ultron approval release KBN-105.
|
||||
|
||||
**Normative implementation contract:** [`docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md`](./native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md). `ASSUMPTION:` existing `standalone` and `federated` are all PostgreSQL production-like modes; any new PostgreSQL tier inherits these requirements until an explicit versioned amendment.
|
||||
|
||||
---
|
||||
|
||||
## Tess Interaction Agent Workstream (TESS)
|
||||
|
||||
### Problem and Objective
|
||||
@@ -1082,10 +1204,10 @@ Telegram remote control channel.
|
||||
|
||||
### AC-10: Deployment
|
||||
|
||||
- [ ] `docker compose up` starts full stack from clean state
|
||||
- [ ] `mosaic` CLI installable and functional on bare metal
|
||||
- [ ] Database migrations run automatically on first start
|
||||
- [ ] `.env.example` documents all required configuration
|
||||
- [ ] PGlite data-layer work uses no PostgreSQL; optional Compose services are selected individually and do not start PostgreSQL; Gateway/Web local start remains held until KBN-101-02 rejects daemon/inherited/project DSNs before connection or DDL
|
||||
- [ ] PostgreSQL/federated activation is unavailable until KBN-101-00/-03/-05 deliver external bootstrap, TLS/roles, runner `--run`, runner `--verify`, and Gateway/Compose readiness in that order
|
||||
- [ ] `mosaic` CLI installable and functional on bare metal after the reviewed KBN-101-05 secret-renderer/process-exec or `LoadCredential` interface exists
|
||||
- [ ] Local-only configuration documentation is distinct from production generation-pinned Vault-rendered consumer material
|
||||
|
||||
### AC-11: @mosaicstack/\* Packages
|
||||
|
||||
|
||||
+40
-4
@@ -1,11 +1,39 @@
|
||||
# Documentation Sitemap
|
||||
|
||||
## Compaction refresh lease broker
|
||||
|
||||
- [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings.
|
||||
- [Broker operations](guides/lease-broker-operations.md) — protected paths, startup, constrained recovery, fail-closed posture, distinct-principal deployment, and residual risk.
|
||||
- [Constrained recovery skill](../packages/mosaic/framework/skills/mosaic-context-refresh/SKILL.md) — source-resident thin wrapper, receipt scope, C4 replay boundary, and T-C middle-drop disclosure.
|
||||
- [Lease-broker security notes](architecture/lease-broker-security.md) — identity, whole-class authorization, threat boundaries, and coordinator review requirements.
|
||||
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
|
||||
- [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window.
|
||||
|
||||
## CLI and skill management
|
||||
|
||||
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
||||
- [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes.
|
||||
|
||||
## Fleet configuration management
|
||||
|
||||
- [Generated environment boundary](fleet/reference/generated-env-boundary.md) — roster-derived launch projection, strict local data, legacy quarantine, and downstream interface evidence.
|
||||
- [Roster v2 structural contract](fleet/reference/roster-v2-fields.md) — local-tmux schema v2 parsing and structural validation.
|
||||
- [Role classes and authority](fleet/reference/role-classes.md) — canonical role resolver and protected authority boundaries.
|
||||
- [Executable asset dispositions](fleet/migration/example-profile-disposition.md) — shipped v1 fixture/profile/service validation posture.
|
||||
- [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
|
||||
- [Desired, derived, and observed state](fleet/concepts/desired-vs-observed-state.md) — roster authority, generation, ownership, and drift.
|
||||
- [Identity, class, and runtime](fleet/concepts/identity-class-runtime.md) — stable name, display alias, class, runtime, provider, and model separation.
|
||||
- [Role authority and leases](fleet/concepts/role-authority-and-leases.md) — validator/merge-gate separation and bounded lease authority.
|
||||
- [Generated launch chain](fleet/concepts/generated-env-launch-chain.md) — strict data parsing, precedence, and quarantine.
|
||||
- [Roster v2 structural contract](fleet/reference/roster-v2-fields.md) — schema, supported values, required fields, defaults, and constraints.
|
||||
- [Fleet CLI reference](fleet/reference/cli.md) — local desired-state commands, JSON/exit behavior, and gateway-catalog separation.
|
||||
- [Lifecycle transitions](fleet/reference/lifecycle-transitions.md) — create/apply/reboot/migration/rollback boundaries.
|
||||
- [Status and drift](fleet/reference/status-and-drift.md) — desired/managed/observed state and current/future classifications.
|
||||
- [Safe agent CRUD](fleet/how-to/create-update-delete-agent.md) — expected generation, dry-run, and partial-failure recovery.
|
||||
- [Local lifecycle operations](fleet/how-to/start-stop-restart.md) — persisted versus one-shot actions.
|
||||
- [Configurable interaction instance](fleet/how-to/configure-tess-interaction.md) and [validator instance](fleet/how-to/configure-ultron-validator.md) — generic identities and protected limits.
|
||||
- [Reconcile and recover](fleet/operations/reconcile-and-recover.md) — plan/apply lock and recovery behavior.
|
||||
- [Environment quarantine](fleet/operations/env-quarantine.md) — private evidence and value-free diagnostics.
|
||||
- [Systemd/tmux troubleshooting](fleet/operations/systemd-tmux-troubleshooting.md) — socket, holder, unmanaged-session, and lock decisions.
|
||||
- [Backup/restore boundary](fleet/operations/backup-restore.md) and [upgrade-assets hold](fleet/operations/upgrade-assets.md).
|
||||
- [v1-to-v2 migration preview](fleet/migration/v1-to-v2.md) and [executable artifact dispositions](fleet/migration/example-profile-disposition.md).
|
||||
- [FCM M5 closure evidence](reports/documentation/758-fleet-config-ia-closure.md) and [approved deferrals](reports/deferred/758-fleet-config-deferrals.md).
|
||||
|
||||
## Official channel plugins
|
||||
|
||||
@@ -21,7 +49,10 @@
|
||||
- [Workstream index](native-kanban-sot/INDEX.md) — artifact map, lane partition, and delivery order.
|
||||
- [Mission manifest](native-kanban-sot/MISSION-MANIFEST.md) — scope, authority, invariants, and gate model.
|
||||
- [Task decomposition](native-kanban-sot/TASKS.md) — dependency-ordered implementation slices and ownership boundaries.
|
||||
- [KBN-101 database role split](native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md) — rc.16 direct-Drizzle storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README/user-guide wrapper forms fail before masking and source-consistency rejects runner-delegation copy; held bootstrap → TLS/roles → run → verify → readiness; plus prior attestation, pgvector owner, classifier, TLS, activation, and certification prerequisite.
|
||||
- [Federated tier data migration](guides/migrate-tier.md) — active KBN-101-07 operator route: runner-produced target attestation, dedicated non-DDL importer, and paired credential-/attestation-file references only.
|
||||
- [Frozen shared contract](native-kanban-sot/SHARED-CONTRACT.md) — schema, API, Coordinator, health, recovery, and migration contracts.
|
||||
- [KBN-101 exact-head security review](reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md) — retained prior REQUEST CHANGES evidence for `da742ca`; rc.16 awaits independent exact-head re-review after closing the current generic storage-wrapper authority HIGH finding.
|
||||
- [Initial independent review](reports/native-kanban-sot/canon-initial-review-no-go.md) — KCR-001–016 findings that blocked the first draft.
|
||||
- [Final independent re-review](reports/native-kanban-sot/canon-final-rereview-go.md) — closure evidence and GO verdict.
|
||||
- [Ultron final gate](reports/native-kanban-sot/ultron-final-go.md) — final requirements, authority, schema, migration, recovery, and evidence review.
|
||||
@@ -65,3 +96,8 @@
|
||||
- [Logical identity and connector lease/fencing implementation](https://git.mosaicstack.dev/mosaicstack/stack/issues/755)
|
||||
- [M1 logical identity and fencing architecture](architecture/mos-runtime-portability-m1.md)
|
||||
- [M1 connector lease operations](guides/mos-connector-lease-operations.md)
|
||||
|
||||
## Comms evolution — Matrix-native MACP (design, draft)
|
||||
|
||||
- [RFC-001 — MACP: a Mosaic-native, Matrix-native comms layer](rfcs/RFC-001-MACP-MATRIX-NATIVE.md) — Synapse + Mosaic appservice backbone, MACP v1 protocol, presence/escalation, federation, strangler migration off the Hermes MCP bridge.
|
||||
- [RFC-002 — Install, configuration & topology for the Matrix/MACP comms system](rfcs/RFC-002-INSTALL-CONFIG-TOPOLOGY.md) — open-source install topology modes, ACME cert provisioning, pluggable secret backend, and config precedence.
|
||||
|
||||
+14
-14
@@ -52,20 +52,20 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
||||
> the repository quality gates, independent code and security review, terminal-green CI, and
|
||||
> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
|
||||
|
||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0–M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 |
|
||||
| FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation |
|
||||
| FCM-M1-002 | in-progress | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | Started 2026-07-14 from `aa5b43b`; one shared resolver only; validator certificate-only; merge-gate sole merge authority |
|
||||
| FCM-M1-003 | not-started | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | Every shipped artifact must validate, be versioned v1, or be retired with replacement |
|
||||
| FCM-M2-001 | not-started | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | No arbitrary command compatibility path; diagnostics expose key names/hashes only |
|
||||
| FCM-M2-002 | not-started | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | Fresh create persists stopped unless explicit persisted start |
|
||||
| FCM-M3-001 | not-started | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | Exact systemd/tmux ownership; remote/schema-only entries are inventory only |
|
||||
| FCM-M3-002 | not-started | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Proves stopped-state preservation and zero fuzzy destructive targeting |
|
||||
| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference |
|
||||
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session |
|
||||
| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral |
|
||||
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI |
|
||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0–M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 |
|
||||
| FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation |
|
||||
| FCM-M1-002 | done | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | #768 squash `a5e8e55`; shared resolver and canonical authority/alias validation delivered |
|
||||
| FCM-M1-003 | done | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | #770 squash `e9c4aa3`; shipped artifact disposition validation delivered |
|
||||
| FCM-M2-001 | done | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | #772 squash `191efae`; generated/local boundary and private quarantine delivered |
|
||||
| FCM-M2-002 | done | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | #773 squash `bc5e736`; generation-guarded atomic CRUD and recovery contracts delivered |
|
||||
| FCM-M3-001 | done | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | #785 squash `4990905`; exact roster-owned systemd/tmux reconcile and lifecycle contracts delivered |
|
||||
| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only |
|
||||
| FCM-M4-001 | done | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | PR #788; final head `d63bb0206a1d312ab8352ec1d3ca3631146b0baa`; tree `4da210da9a71b035130d4160a4a2e691bdfde2da`; squash `9745bc3f29c26b021a478b7ad03cfb494f6c9de3`; descendant-main pipeline 1855 terminal success |
|
||||
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | HOLD: never starts a previously stopped agent or kills an unproven unmanaged session; not authorized by FCM-M5-001 |
|
||||
| FCM-M5-001 | done | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | #789 content squash 627cf2bb; de-flake repair PR#851/#849 squash 77c9a826; completion proof wp1937 @aa999daf push/ci step 49632 recovery_runtime_unittest.py 3/3 OK (closes wp1932 step 49576 Errno111) |
|
||||
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | HOLD: final #758 gate; quality, independent code/security review, validator certificate, merge-gate approval, and green CI remain out of M5-001 |
|
||||
|
||||
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Compaction observer revocation and runtime generations
|
||||
|
||||
WI-3 connects Claude and Pi compaction/session lifecycle events to the existing authenticated lease-broker state machine. It does not add a second lease store or let runtime hooks assert identity. Each observer inherits the broker-minted session, resolves the current private runtime generation, and sends the existing `revoke_lease` action over the authenticated Unix socket.
|
||||
|
||||
## Observer matrix
|
||||
|
||||
| Runtime | Lifecycle signal | Action |
|
||||
| ---------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Claude / Claudex | `PreCompact` | Revoke the current lease before compaction. A non-zero hook result blocks the lifecycle transition. |
|
||||
| Claude / Claudex | `SessionStart` with matcher `compact` | Revoke again after compacted context starts. |
|
||||
| Claude / Claudex | `SessionStart` with matcher `resume\|clear` | Atomically advance the private generation, then revoke the replacement incarnation. |
|
||||
| Pi | `session_before_compact` | Revoke before compaction; return `{ cancel: true }` if revocation cannot be confirmed. |
|
||||
| Pi | `session_compact` then the first `context` | Arm and run an independent post-compaction revoke. A failed post observer blocks later tools locally until a retry succeeds. |
|
||||
| Pi | `session_start` with reason `reload`, `new`, `resume`, or `fork` | Atomically advance the private generation, then revoke the replacement incarnation before reuse. |
|
||||
|
||||
The first observer that reaches the broker deletes pending promotion tokens and makes the lease `UNVERIFIED`. The second compaction observer is deliberate redundancy, not a prerequisite for the first. Claudex receives the same mandatory hooks in its isolated `CLAUDE_CONFIG_DIR`; hook merging preserves unrelated isolated settings and rejects malformed or symlinked settings fail-closed.
|
||||
|
||||
## Private generation authority
|
||||
|
||||
`launch-runtime.py` still registers before `exec`, preserving the kernel-authenticated PID/starttime anchor. It now also creates `generation-<broker-session>.state` beside the broker socket. The file is owner-only mode `0600` under the broker's mode-`0700` directory. Hook descendants read that file instead of relying only on an immutable inherited environment value.
|
||||
|
||||
Generation changes use an exclusive file lock, validate owner/type/mode/size, increment monotonically, truncate and write the complete new value, and `fsync` before contacting the broker. Therefore reload, new-session, resume, and fork events may retain the same PID/starttime while still becoming a new broker incarnation. The higher generation causes the broker to atomically discard prior tokens and lease authority; the replacement generation inherits no VERIFIED lease.
|
||||
|
||||
If an observer fires while broker transport is unavailable, `revoke-lease.py` advances the private generation as a local fence before returning non-zero. Every later all-tools gate reads that higher value. When the broker is reachable again, authentication of that value performs the same old-generation revocation before authorization. Pi also keeps a process-local post-compaction/rollover failure latch that blocks tool calls. An unsafe or unreadable generation file itself makes both lifecycle revocation and tool authorization fail closed.
|
||||
|
||||
## Threat contract and stopping boundary
|
||||
|
||||
### BOUNDED RESIDUAL STALE WINDOW
|
||||
|
||||
If **both** pre- and post-compaction observers are missed entirely, no revocation signal exists. During the remaining unexpired lease, **within-TTL consequential actions are allowed**. Their count and timing are **bounded by lease expiry, not by the mutator gate**. WI-3 makes no claim that it bounds mutator actions inside this stale interval. The broker's monotonic lease TTL is capped at 300 seconds; after expiry, the next consequential tool is denied with `LEASE_EXPIRED`.
|
||||
|
||||
This is the named D2-v5 T-A residual. It is distinct from an observer that fires but cannot contact the broker: the latter creates a local generation fence and fails closed. It is also distinct from T-C total rot, where the lifecycle observers and the all-tools gate are both absent or replaced. Server-side branch protection, required CI, and independent review remain the irreducible backstop for T-C.
|
||||
|
||||
| Condition | Result |
|
||||
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| Either compaction observer succeeds | Existing lease and pending promotion tokens are revoked immediately. |
|
||||
| Observer runs but broker confirmation fails | Lifecycle transition is denied where supported; local generation fence and runtime latch prevent inherited authority. |
|
||||
| Both observers are missed, lease unexpired | **ALLOWED** inside the bounded residual stale window. No within-window mutator bound is claimed. |
|
||||
| Both observers are missed, lease expired | **DENIED** by monotonic TTL expiry. |
|
||||
| Generation advances on reload/new/resume/fork | Prior incarnation revoked; replacement starts `UNVERIFIED`. |
|
||||
| Lifecycle observers and all-tools gate both fail or are removed | T-C total-hook-miss residual; protected-branch controls remain required. |
|
||||
|
||||
## T-C server-side branch-protection posture
|
||||
|
||||
The required posture is that `main` is push-blocked and PR-only-merge is **MANDATORY**, regardless
|
||||
of client-gate state. The client-side gate narrows the exposure window only; it is not the T-C
|
||||
guarantee. The server-side protected-branch configuration is the irreducible guarantee for protected
|
||||
repository actions. Status-check enforcement and approval enforcement are **RECOMMENDED**.
|
||||
|
||||
## Current-vs-required gap (recorded, not enacted)
|
||||
|
||||
The current empirical configuration is recorded here without re-probing or mutating live branch
|
||||
protection. `enable_push=False` (push-block present), so the mandatory push-block/PR-only-merge core
|
||||
holds. `require_approvals=0` (approvals not enforced), `enable_status_check=False` (status checks not
|
||||
enforced), and `block_on_official_review=False` (official review not enforced). Those recommended
|
||||
merge-quality controls are the current gap; changing them is a separate, owner-gated operations
|
||||
decision and is not enacted by this documentation change.
|
||||
|
||||
The permanent T12b/T30 acceptance case prints both required outcomes: dual-hook miss within TTL is **ALLOWED**, and the same lease after TTL is **DENIED**. Separate real-socket tests prove each Claude observer and same-PID generation rollover; Pi lifecycle tests exercise pre/post observers, all four replacement reasons, and local failure closure.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Authenticated external lease broker protocol
|
||||
|
||||
The compaction-refresh lease broker is a Linux-only, newline-framed JSON protocol over a Unix stream socket. It is runtime-neutral; M1 consumers are limited to Claude and Pi. This is an internal process boundary, not an HTTP API, so it is intentionally absent from OpenAPI.
|
||||
|
||||
The broker, never the caller, obtains `(pid, uid, gid)` from kernel `SO_PEERCRED`. It correlates the PID with `/proc/<pid>/stat` field 22 (`starttime`) and mints `session_id` on `register_anchor`. Presence of `session_id` in that request is refused even when its value is `null` or empty. Later requests must originate from the anchor or a descendant. The broker walks parent PIDs to the `(pid,starttime)` anchor and then rereads every walked PID's starttime before accepting the chain.
|
||||
|
||||
## Request and response boundary
|
||||
|
||||
Each connection carries exactly one UTF-8 JSON object followed by one newline, capped at 64 KiB. The protocol deliberately uses EOF to prove that there is exactly one frame: immediately after writing the newline, the client **MUST half-close its write side** with `shutdown(SHUT_WR)` (or Node `socket.end()`) before awaiting the response. A client that writes a newline but leaves its write side open receives no successful response; the broker's one-second connection deadline fails closed. Malformed, unterminated, multiple (including a delayed second frame), or oversized frames fail closed. Responses are one JSON object and one newline. Success has `{"ok":true,...}`; refusal has `{"ok":false,"code":"TYPED_CODE"}`. Requests are:
|
||||
|
||||
- `register_anchor`: `action`, non-negative `runtime_generation`; no `session_id` field.
|
||||
- `authenticate`: `action`, broker-minted `session_id`, non-negative `runtime_generation`.
|
||||
- `mint_token`: authenticated identity plus `binding` containing exactly `compaction_epoch`, `request_epoch`, `h_source`, `h_payload`, and `schema_version`.
|
||||
- `consume_token`: authenticated identity plus `token`.
|
||||
- `begin_verification`: authenticated identity, runtime (`claude` or `pi`), cycle `binding`, and a TTL no greater than 300 seconds. The broker revokes existing authority first, enters `PENDING_VERIFICATION`, and returns a single-use promotion token.
|
||||
- `begin_recovery`: the constrained recovery entrypoint. It rejects caller-provided receipt/challenge fields and delegates to the same `begin_verification` transition, but reports `PENDING_DELIVERY` and marks the volatile cycle as recovery-owned.
|
||||
- `complete_recovery`: authenticated identity only. It rejects caller-provided receipt/challenge fields, obtains the current recovery challenge only from broker state, and delegates to the same trusted-observer → evidence → consume → promote sequence. An observation failure revokes recovery authority; retry starts a fresh challenge.
|
||||
|
||||
The daemon owns a second protected production observer socket (mode `0600`) unless a private `--test-observer-file` fixture is selected. That transport accepts only the exact `record_runtime_observation` schema after kernel `SO_PEERCRED` plus the existing anchor/ancestry authentication; it validates the pending runtime/generation before storing one finalized assistant entry for the in-process `RuntimeReceiptObserver`. It is **not** a broker request action. Claude sends its latest assistant entry from the Stop-hook transport; Pi sends only finalized `message_end` assistant content. The public broker socket continues to reject request-supplied `latest_assistant_message` in begin, observe, and complete paths.
|
||||
|
||||
- `promote_lease`: authenticated identity plus the exact pending promotion token. The broker commits token consumption before making `VERIFIED` visible.
|
||||
- `revoke_lease`: authenticated observer signal; deletes pending tokens and makes the session `UNVERIFIED` immediately. WI-3 Claude/Pi hooks send this existing action; `runtime` and bounded `reason` fields are diagnostic input only and never identity authority.
|
||||
- `authorize_tool`: authenticated identity, runtime, and exact runtime-reported tool name. The broker returns an explicit allow/deny decision from the whole-class policy and current lease.
|
||||
|
||||
A higher generation for the same anchor atomically replaces the stored incarnation and deletes all prior tokens and lease authority for that session. A lower generation is stale. Runtime descendants resolve the current generation from an owner-only, locked generation file created by the register-before-exec launcher; reload/new/resume/fork observers advance and `fsync` it before broker revocation. This supports generation replacement even when PID/starttime do not change. Tokens are 256-bit values from the operating-system cryptographic RNG and are single use. At most 256 pending tokens may be persisted; another mint fails with `TOKEN_CAPACITY` before mutation. Successful consumption deletes the token, while a replay still fails with `TOKEN_REPLAY`. Live v1 token records retain the existing `consumed: false` schema.
|
||||
|
||||
VERIFIED leases are volatile and monotonic-time bounded: broker restart, generation change, explicit observer revocation, or expiry returns the session to `UNVERIFIED`. `begin_verification` always revokes before minting a new prerequisite. `begin_recovery` reuses that exact transition and mints a new challenge, so a normal-path receipt/challenge cannot be replayed through recovery. `promote_lease` is valid only from the matching pending cycle; persistence failure rolls token and lease state back, while post-rename durability uncertainty terminates the broker. The WI-1 token is the atomic promotion prerequisite substrate.
|
||||
|
||||
## Receipt boundary and T-C residual (R1)
|
||||
|
||||
Receipt evidence is a T-A delivery/liveness prerequisite only; it cannot replace the mechanical
|
||||
mutator gate as safety authority. The receipt detects an **ABSENT** or **PREFIX-TRUNCATED** terminal
|
||||
token. A **MIDDLE-DROP** that preserves the tail is a T-C contract violation that is **NOT receipt-detectable**. It is covered by server-side protected-branch controls, **NOT** by the receipt; no category-wide receipt-detection claim is made for that tail-preserving transformation.
|
||||
|
||||
State replacement serializes and enforces the 4 MiB maximum before opening a temporary file, then uses a mode-`0600` temporary file, `fsync`, atomic rename, and parent-directory `fsync`. Every broker mutation snapshots the prior v1 state. A commit failure before rename restores that snapshot and leaves durable state unchanged. A failure after rename makes durability uncertain, so the store is poisoned without rolling memory back and the daemon terminates rather than serving with divergent state. Existing state is opened without following symlinks, must be a bounded regular file at mode `0600`, and is fully schema- and invariant-validated before use. Persisted tokens must be unconsumed, match their session's current generation, and remain within the 256-token cap. Session identity is uniquely keyed by `(anchor_pid,anchor_starttime)`; duplicate logical sessions for one anchor refuse startup. State integrity or mode failures refuse startup. The daemon does not log session IDs or tokens.
|
||||
@@ -0,0 +1,26 @@
|
||||
# WI-1 lease broker security notes
|
||||
|
||||
- Trusted identity comes only from Linux `SO_PEERCRED` plus `/proc` starttime, never request identity fields.
|
||||
- Descendant authorization is anchored to `(pid,starttime)` and uses a complete second starttime pass to fail closed on disappearance or PID-reuse races.
|
||||
- Runtime generations are monotonic per anchor; a bump revokes prior-incarnation tokens before persistence commits. WI-3 stores the live generation in an owner-only locked file so same-PID Pi reload/new/resume/fork and Claude resume/clear transitions cannot inherit a VERIFIED lease.
|
||||
- Session IDs and cycle tokens use the OS cryptographic RNG. `Math.random` and model output are not token sources.
|
||||
- Framing and persistence failures fail closed. Sensitive tokens are not logged.
|
||||
- Built-in `0700`/`0600` filesystem modes provide same-principal hardening only, not socket authenticity against the same UID. WI-1 provides no distinct-principal isolation. That stronger deployment requires an external protected proxy, ACL, or service boundary, and the boundary must preserve authenticated client identity for the broker's `SO_PEERCRED` and ancestry authorization rather than substituting a shared proxy identity.
|
||||
- WI-2 whole-class authorization denies every consequential, unknown, and custom tool while UNVERIFIED; it does not inspect shell strings or trust wrapper selection. First-class Claude/Pi, both Claudex dispatch modes, PRDY, QA remediation, coord, orchestrator, and fleet starts converge on broker register-before-exec; Claudex additionally installs the mandatory all-tools hook inside its preserved isolated config and fails closed on unsafe settings.
|
||||
- The permanent `check-runtime-launches.py` suite/CI guard scans production source for direct literal, absolute-path, process-API, command-array, and dynamic Claude/Pi launches. It has no bypass allowlist: an unrecognized launch form fails CI until routed through the common boundary.
|
||||
- WI-2 promotion consumes a WI-1 cycle token before VERIFIED becomes visible. Observer revocation, runtime-generation replacement, broker restart, and monotonic TTL expiry remove authority.
|
||||
- WI-3 wires redundant Claude `PreCompact`/`SessionStart(compact)` and Pi `session_before_compact`/post-`session_compact` `context` observers to that same revoke action. If broker confirmation fails after an observer fires, the revoker advances the private generation as a local fence; subsequent authorization revokes the stale broker incarnation before any consequential allow.
|
||||
- Dual observer absence while a lease remains live is the named **bounded residual stale window**: consequential tools remain allowed until monotonic expiry, with no claimed within-window action bound. After expiry they are denied. Total observer-plus-gate absence remains T-C.
|
||||
- Receipt observation, payload construction, and constrained recovery implementation remain later surfaces. A receipt can become a promotion prerequisite but is never the safety mechanism.
|
||||
|
||||
## Named residual: promote-lease-lost-ACK (WI-3 D2-v5)
|
||||
|
||||
A valid `promote_lease` can leave a session `VERIFIED` in the broker while the client never learns of it. This is a named, bounded D2-v5 T-A residual — an **authority-observability divergence, not an authority divergence, not an ALLOW-risk, and not a retry double-apply**. It is disclosed here, not laundered.
|
||||
|
||||
**Window — where it can occur.** The broker commits token consumption and durable `VERIFIED` state _before_ the success reply becomes visible (see the promotion order in `lease-broker-protocol.md`). The residual is confined to the interval after that commit+fsync when the broker→client reply or peer-ACK is lost — for example an extreme-contention send failure or peer disconnect after `handle()` has already mutated and persisted state (the #838 fail-closed transport path). The lease mutation is already durable broker-side; only the acknowledgement to the client is lost. No uncommitted or partially-applied state is involved: the commit either happened (and is authoritative) or it did not (and no lease exists).
|
||||
|
||||
**Fail-safe direction — the client can only under-claim.** Broker intent is the ceiling; client authority is always ≤ broker intent, never more. Client-side authority-belief is granted only by a _received_ acknowledgement; a lost acknowledgement conveys nothing, so the client cannot conclude "verified" and continues to treat itself as `UNVERIFIED` (it re-verifies or recovers). If the client retries `promote_lease` with the same token, the token is already consumed and the broker rejects the retry (`PROMOTION_TOKEN_MISMATCH` / `INVALID_LEASE_TRANSITION`); there is no double-apply. The committed `VERIFIED` state the broker holds is authority the lease _legitimately earned_ from a real promotion — the broker authorizing consequential tools under it is correct, not inflation. Divergence is therefore strictly toward _less_ client authority than the broker granted; it never produces authority the broker did not grant.
|
||||
|
||||
**Bound — TTL plus the observer/gen-bump revoke backstop, self-healing.** The orphaned `VERIFIED` lease is indistinguishable to the broker from any other legitimately verified lease, so the identical D2-v5 revocation backstops dispose of it: any compaction observer (`PreCompact` / `SessionStart(compact)` for Claude; `session_before_compact` / post-`session_compact` `context` for Pi), any same-PID runtime-generation bump (reload/new/resume/fork), broker restart, or monotonic-time expiry returns the session to `UNVERIFIED`. Monotonic TTL expiry (capped at 300 seconds) is **unconditional** — it requires no observer at all — so the maximum exposure of the orphaned lease is one TTL, ≤ 300 s, after which the next consequential tool is denied with `LEASE_EXPIRED`. Any observer that fires shortens the window further. The residual self-heals: "≥1 observer fires OR expiry ⇒ revoke" catches the lost-ACK lease on the same terms as every other stale lease. As with the dual-observer-miss stale window, WI-3 makes no claim that the mutator gate bounds actions inside the residual interval; the interval is bounded by TTL and the revoke backstop, and the server-side branch-protection / required-CI / independent-review line remains the irreducible backstop for protected-repository mutations.
|
||||
|
||||
Coordinator security review must rerun the real socket/peercred and mutator-gate acceptance suites on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Whole mutator-class lease gate
|
||||
|
||||
WI-2 adds the framework-native authorization boundary for Claude (including the supported Claudex overlay) and Pi. Every runtime-reported tool name reaches the lease broker before execution. The gate classifies capabilities by the whole tool class; it never parses a Bash command to decide whether that particular string looks read-only.
|
||||
|
||||
## Default-deny policy
|
||||
|
||||
While a session is not VERIFIED, only these exact classes are allowed:
|
||||
|
||||
- Claude: `Read`, `Grep`, `Glob`, `Ls`, `Find`
|
||||
- Pi: `read`, `grep`, `find`, `ls`
|
||||
- Both runtimes: the fixed `mosaic_context_recover` primitive
|
||||
|
||||
Every other built-in, unknown tool, and custom/MCP tool is consequential by default and is denied. This includes Claude `Bash`, `Edit`, `Write`, and `NotebookEdit`, plus Pi `bash`, `edit`, and `write`. A compromised model therefore cannot bypass Mosaic wrappers by selecting raw `git`, `curl`, `kubectl`, provider, deployment, or filesystem commands inside a generic mutator—the generic mutator itself is blocked before its input executes.
|
||||
|
||||
## Broker-owned transition order
|
||||
|
||||
The authenticated broker is the sole lease writer:
|
||||
|
||||
1. `begin_verification` revokes existing authority and pending tokens first, then records `PENDING_VERIFICATION` and mints one WI-1 single-use promotion token bound to the exact cycle.
|
||||
2. `promote_lease` accepts only that session/generation/binding/token combination.
|
||||
3. Token consumption commits before the volatile lease becomes VERIFIED. Promotion is last and cannot be reached directly from UNVERIFIED.
|
||||
4. `revoke_lease`, a runtime-generation increase, broker restart, or monotonic expiry removes mutator authority.
|
||||
|
||||
The initial TTL is capped at the ratified 300-second maximum. A caller may request a shorter positive TTL but cannot lengthen the maximum. WI-3 installs the [compaction observer and generation lifecycle](compaction-revocation.md). Dual compaction-hook miss within an unexpired lease remains the ratified bounded T-A residual: consequential tools are allowed until expiry, with no claimed within-window action bound; once either observer revokes or TTL expires, the next consequential tool is denied.
|
||||
|
||||
A receipt is only a future promotion prerequisite. It is not an obedience, residency, or safety proof and never replaces this mechanical gate.
|
||||
|
||||
## Runtime adapters
|
||||
|
||||
`launch-runtime.py` registers itself with the broker and then `exec`s Claude or Pi so PID/starttime remain the authenticated parent anchor. It exports the broker-minted session ID and an owner-only generation-file reference to descendants; lifecycle hooks advance that file for same-PID replacement generations.
|
||||
|
||||
- Claude installs `mutator-gate.py` as an all-tools (`.*`) `PreToolUse` hook.
|
||||
- `mosaic claudex` and `mosaic yolo claudex` preserve their isolated `CLAUDE_CONFIG_DIR`, merge the mandatory hook into that isolated `settings.json`, and use the same register-before-exec launcher. Malformed or symlinked isolated settings deny launch.
|
||||
- Pi invokes the same executable from its `tool_call` handler.
|
||||
|
||||
The executable submits the runtime's actual tool name to `authorize_tool`. Missing identity, malformed input/reply, timeout, broker unavailability, or denial exits with status 2 and blocks fail-closed.
|
||||
|
||||
## Runtime-launch choke-point and permanent guard
|
||||
|
||||
Every repository-owned Claude/Pi launch entry converges on `launch-runtime.py`, either directly or through `mosaic` → `execLeaseGatedRuntime`. PRDY init/update and QA remediation invoke the wrapper directly so their existing prompts, dangerous-permission behavior, working directory, and environment survive without skipping broker registration. The raw Claude `--dangerously-skip-permissions` primitive is owned only by `launch-runtime.py`; callers request semantic `--dangerous` mode, and the wrapper validates Claude before injecting the primitive. `@mosaicstack/coord` rewrites direct Claude commands to `mosaic claude` and rejects unknown custom Claude launchers.
|
||||
|
||||
`check-runtime-launches.py` is the permanent completeness guard. It scans production shell, TypeScript/JavaScript, Python, and data launch definitions under `packages/`, `apps/`, `plugins/`, and `tools/`; direct literal, absolute-path, process-API, dynamic, command-substitution, `eval`, and variable-execution runtime launches fail. Shell comments are stripped with quote awareness, wrapper prefixes are tokenized with `shlex`, and only an invocation in command position with `--runtime` before the command separator is gated. Literal and tracked-variable command tokens use one terminal resolver after any nesting of `exec`, `command`, `nohup`, or `env` plus assignments. A direct command always wins over an inert marker on the same line. Independently, the raw dangerous primitive anywhere outside the choke-point is RED.
|
||||
|
||||
The command parser is a best-effort CI defense, not a complete shell interpreter. Alias/function redefinition, sourced commands, generated scripts, and encoded pipelines are intentionally residual rather than an invitation to chase an unbounded shell language. Two runtime controls backstop that residual surface: primitive ownership rejects a dangerous launch even when command identity is alias-indirected, and Claude's global `.*` `PreToolUse` hook invokes the broker gate for non-dangerous launches. Without `MOSAIC_LEASE_SESSION_ID`, representative read, mutator, and custom/MCP tools all fail closed with `GATE_UNAVAILABLE`. Hook absence or replacement remains in the documented T-C boundary.
|
||||
|
||||
### Parser stopping criterion
|
||||
|
||||
- **A — realistic parser matrix:** comments, inert strings/assignments, heredocs, continuations, chained commands, command substitution, `eval`, bare tracked variables, and quoted/unquoted tracked variables behind `exec`, `command`, `nohup`, or `env` are permanent RED regressions. Prefix-variable forms are covered in both multiline and same-line assignment shapes.
|
||||
- **B — residual backstops:** a dangerous alias-indirected launch is RED solely through primitive anchoring; a parser-missed non-dangerous alias launch is paired with an acceptance test proving the global all-tools hook denies every representative tool class as `GATE_UNAVAILABLE` without a lease.
|
||||
- **C — independent fresh review:** the parser class is considered complete only when reviewers find no new non-overlapping realistic evasion on the exact head. A and B are repository evidence; C is supplied by the fresh review round.
|
||||
|
||||
All three layers are load-bearing and complementary. The guard is mandatory in `@mosaicstack/mosaic`'s test script, so root CI fails on a future realistic bypass. Real-socket tests separately prove PRDY init/update and QA receive broker sessions and deny an unverified mutator.
|
||||
|
||||
The live inventory is emitted by:
|
||||
|
||||
```bash
|
||||
python3 packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py --root . --json
|
||||
```
|
||||
|
||||
| Production launch family | Gated entries |
|
||||
| ------------------------------------------------------ | ------------: |
|
||||
| `@mosaicstack/coord` default/configured Claude command | 2 |
|
||||
| Fleet runtime start | 1 |
|
||||
| QA remediation + generated QA command | 2 |
|
||||
| Orchestrator command construction/session launches | 3 |
|
||||
| PRDY init/update | 2 |
|
||||
| Mosaic Claude/Pi/Claudex adapter and wrapper boundary | 4 |
|
||||
| **Total** | **14 / 14** |
|
||||
|
||||
## Assurance boundary
|
||||
|
||||
This closes T-A after an observer fires or lease expiry and T-B for in-runtime tool calls. Hook/extension absence, a runtime executing outside the gated launcher, ptrace/same-UID broker replacement, and other fully rotted behavior remain T-C. Server-side branch protection and required PR review/CI remain the irreducible line for protected repository mutations.
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P5 Gate0 replay probe; BUILT ONLY, execution is Mos-gated.
|
||||
|
||||
Run only under fresh-executor authorization:
|
||||
python3 -I -S -B docs/compaction-refresh/probes/p5_receipt_replay.py
|
||||
|
||||
Each of the default three isolated runs launches the shipped lease-broker daemon
|
||||
in a distinct private temporary directory. This driver never changes broker
|
||||
state directly and does not replace the promote gate: every transition is sent
|
||||
over the daemon's real Unix socket. It proves the shipped order is
|
||||
PENDING_DELIVERY -> observe/evidence commit -> consume -> VERIFIED and that a
|
||||
consumed challenge cannot be replayed or reopen/renew its lease.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
REPOSITORY = HERE.parents[2]
|
||||
TOOLS = REPOSITORY / "packages/mosaic/framework/tools/lease-broker"
|
||||
DAEMON = TOOLS / "daemon.py"
|
||||
FRAGMENTS = TOOLS / "normative_fragments.py"
|
||||
|
||||
|
||||
def load_shipped_fragments():
|
||||
if not FRAGMENTS.is_file():
|
||||
raise RuntimeError(f"shipped normative construction missing: {FRAGMENTS}")
|
||||
spec = importlib.util.spec_from_file_location("p5_shipped_normative_fragments", FRAGMENTS)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("unable to load shipped normative construction")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def request(socket_path: Path, value: dict[str, object]) -> dict[str, object]:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(3.0)
|
||||
connection.connect(str(socket_path))
|
||||
connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode())
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
response = bytearray()
|
||||
while True:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
if not response.endswith(b"\n") or response.count(b"\n") != 1:
|
||||
raise AssertionError(f"unframed broker reply: {bytes(response)!r}")
|
||||
parsed = json.loads(response[:-1])
|
||||
if not isinstance(parsed, dict):
|
||||
raise AssertionError(f"non-object broker reply: {parsed!r}")
|
||||
return parsed
|
||||
|
||||
|
||||
def wait_ready(process: subprocess.Popen[str], socket_path: Path) -> None:
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
if socket_path.exists():
|
||||
return
|
||||
if process.poll() is not None:
|
||||
output = process.stdout.read() if process.stdout is not None else ""
|
||||
raise RuntimeError(f"shipped daemon exited before READY: {output}")
|
||||
time.sleep(0.02)
|
||||
raise TimeoutError("shipped daemon did not create private probe socket")
|
||||
|
||||
|
||||
def expect_refused(reply: dict[str, object], code: str) -> None:
|
||||
if reply != {"ok": False, "code": code}:
|
||||
raise AssertionError(f"expected refusal {code}, got {reply!r}")
|
||||
|
||||
|
||||
def run_once(index: int) -> str:
|
||||
fragments = load_shipped_fragments()
|
||||
root = Path(tempfile.mkdtemp(prefix=f"mosaic-p5-replay-{index}-"))
|
||||
os.chmod(root, 0o700)
|
||||
socket_path = root / "broker.sock"
|
||||
state_path = root / "state.json"
|
||||
observer_path = root / "test-observer.json"
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
sys.executable, "-I", "-S", "-B", str(DAEMON), "--socket", str(socket_path),
|
||||
"--state", str(state_path), "--test-observer-file", str(observer_path),
|
||||
],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
wait_ready(process, socket_path)
|
||||
registered = request(socket_path, {"action": "register_anchor", "runtime_generation": 1})
|
||||
if registered.get("ok") is not True or not isinstance(registered.get("session_id"), str):
|
||||
raise AssertionError(f"registration failed: {registered!r}")
|
||||
session_id = registered["session_id"]
|
||||
construction = fragments.build_payload(
|
||||
manifest_version=1,
|
||||
generator_version="p5-replay-probe",
|
||||
fragments=[
|
||||
fragments.NormativeFragment(
|
||||
"authority/probe",
|
||||
b"P5 shipped transition driver\n",
|
||||
"63537df1a6cb0d80195a96757ab11d629e5b5e1f23be167218b84cb195b1c1d6",
|
||||
),
|
||||
],
|
||||
)
|
||||
if construction.injectionDecision != "ACCEPTED" or not construction.promotion:
|
||||
raise AssertionError("shipped normative construction refused P5 fixture")
|
||||
binding = {
|
||||
"compaction_epoch": index,
|
||||
"request_epoch": index + 100,
|
||||
"h_source": construction.h_source,
|
||||
"h_payload": construction.h_payload,
|
||||
"schema_version": 1,
|
||||
}
|
||||
construction_request = {
|
||||
"manifest_version": 1,
|
||||
"generator_version": "p5-replay-probe",
|
||||
"fragments": [{
|
||||
"source_id": "authority/probe",
|
||||
"content_base64": base64.b64encode(b"P5 shipped transition driver\n").decode("ascii"),
|
||||
"expected_sha256": "63537df1a6cb0d80195a96757ab11d629e5b5e1f23be167218b84cb195b1c1d6",
|
||||
}],
|
||||
}
|
||||
pending = request(socket_path, {
|
||||
"action": "begin_verification",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": 1,
|
||||
"runtime": "pi",
|
||||
"binding": binding,
|
||||
"construction": construction_request,
|
||||
})
|
||||
if pending.get("ok") is not True or pending.get("state") != "PENDING_VERIFICATION":
|
||||
raise AssertionError(f"shipped pending-delivery transition failed: {pending!r}")
|
||||
challenge = pending.get("receipt_challenge")
|
||||
receipt = pending.get("receipt")
|
||||
if not isinstance(challenge, str) or not isinstance(receipt, str):
|
||||
raise AssertionError(f"shipped broker did not mint a receipt challenge: {pending!r}")
|
||||
|
||||
# Promotion before observation/evidence/consumption is forbidden.
|
||||
expect_refused(request(socket_path, {
|
||||
"action": "promote_lease",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": 1,
|
||||
"receipt_challenge": challenge,
|
||||
}), "INVALID_LEASE_TRANSITION")
|
||||
|
||||
observer_path.write_text(json.dumps({
|
||||
"session_id": session_id,
|
||||
"runtime_generation": 1,
|
||||
"latest_assistant_message": receipt,
|
||||
}), encoding="utf-8")
|
||||
os.chmod(observer_path, 0o600)
|
||||
observed = request(socket_path, {
|
||||
"action": "observe_receipt",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": 1,
|
||||
"receipt_challenge": challenge,
|
||||
})
|
||||
if observed.get("ok") is not True or observed.get("state") != "PENDING_PROMOTION":
|
||||
raise AssertionError(f"shipped evidence transition failed: {observed!r}")
|
||||
durable = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
evidence = durable["tokens"][challenge].get("evidence")
|
||||
if not isinstance(evidence, dict) or not isinstance(evidence.get("h_latest_assistant"), str):
|
||||
raise AssertionError("shipped receipt evidence was not committed before consume/promote")
|
||||
|
||||
promoted = request(socket_path, {
|
||||
"action": "promote_lease",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": 1,
|
||||
"receipt_challenge": challenge,
|
||||
})
|
||||
if promoted.get("ok") is not True or promoted.get("state") != "VERIFIED":
|
||||
raise AssertionError(f"shipped consume-before-promote transition failed: {promoted!r}")
|
||||
|
||||
# T25/T28: the actual consumed challenge, re-presented through the
|
||||
# shipped daemon, can neither be observed again nor re-promote/reopen.
|
||||
expect_refused(request(socket_path, {
|
||||
"action": "observe_receipt",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": 1,
|
||||
"receipt_challenge": challenge,
|
||||
}), "RECEIPT_REPLAY")
|
||||
expect_refused(request(socket_path, {
|
||||
"action": "promote_lease",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": 1,
|
||||
"receipt_challenge": challenge,
|
||||
}), "RECEIPT_REPLAY")
|
||||
return challenge
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=3.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--runs", type=int, default=3)
|
||||
arguments = parser.parse_args()
|
||||
if arguments.runs != 3:
|
||||
raise SystemExit("P5 requires exactly three isolated runs")
|
||||
challenges = [run_once(index) for index in range(arguments.runs)]
|
||||
if len(set(challenges)) != arguments.runs:
|
||||
raise AssertionError("separate shipped cycles did not mint unique challenges")
|
||||
print("P5 receipt replay probe PASS: 3 isolated shipped-daemon runs")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P6 constrained-recovery probe; BUILT ONLY and Mos-gated.
|
||||
|
||||
DO NOT self-fire. Under Mos authorization only:
|
||||
python3 -I -S -B docs/compaction-refresh/probes/p6_constrained_recovery.py
|
||||
|
||||
The default three isolated runs launch the shipped daemon plus its production
|
||||
observer transport on private sockets. The driver invokes the shipped recovery
|
||||
command and adapter gate identity; it never resets broker state, mocks promote,
|
||||
or taps a live model-output stream.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
REPOSITORY = HERE.parents[2]
|
||||
TOOLS = REPOSITORY / "packages/mosaic/framework/tools/lease-broker"
|
||||
DAEMON = TOOLS / "daemon.py"
|
||||
GATE = TOOLS / "mutator-gate.py"
|
||||
RECOVERY_COMMAND = TOOLS / "recover-context.py"
|
||||
OBSERVER_CLIENT = TOOLS / "receipt-observer-client.py"
|
||||
FRAGMENTS = TOOLS / "normative_fragments.py"
|
||||
CLAUDE_SETTINGS = REPOSITORY / "packages/mosaic/framework/runtime/claude/settings.json"
|
||||
PI_EXTENSION = REPOSITORY / "packages/mosaic/framework/runtime/pi/mosaic-extension.ts"
|
||||
|
||||
|
||||
def load_shipped_fragments():
|
||||
spec = importlib.util.spec_from_file_location("p6_shipped_fragments", FRAGMENTS)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("shipped normative construction unavailable")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def request(socket_path: Path, value: dict[str, object]) -> dict[str, object]:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(3.0)
|
||||
connection.connect(str(socket_path))
|
||||
connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode())
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
response = bytearray()
|
||||
while True:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
if not response.endswith(b"\n") or response.count(b"\n") != 1:
|
||||
raise AssertionError(f"unframed broker reply: {bytes(response)!r}")
|
||||
reply = json.loads(response[:-1])
|
||||
if not isinstance(reply, dict):
|
||||
raise AssertionError("broker reply is not an object")
|
||||
return reply
|
||||
|
||||
|
||||
def wait_ready(process: subprocess.Popen[str], socket_path: Path) -> None:
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
if socket_path.exists():
|
||||
return
|
||||
if process.poll() is not None:
|
||||
output = process.stdout.read() if process.stdout is not None else ""
|
||||
raise RuntimeError(f"shipped daemon exited before READY: {output}")
|
||||
time.sleep(0.02)
|
||||
raise TimeoutError("shipped daemon did not create private probe socket")
|
||||
|
||||
|
||||
def run_json(command: list[str], environment: dict[str, str], input_value: object | None = None) -> dict[str, object]:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
input=None if input_value is None else json.dumps(input_value),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=environment,
|
||||
check=False,
|
||||
)
|
||||
if not completed.stdout.endswith("\n"):
|
||||
raise AssertionError(f"command omitted framed result: {completed.stderr!r}")
|
||||
reply = json.loads(completed.stdout)
|
||||
if not isinstance(reply, dict):
|
||||
raise AssertionError("command result is not an object")
|
||||
return reply
|
||||
|
||||
|
||||
def gate_recovery(runtime: str, phase: str, environment: dict[str, str]) -> None:
|
||||
command = [sys.executable, "-I", "-S", "-B", str(GATE), "--runtime", runtime]
|
||||
if runtime == "claude":
|
||||
command.extend(["--recovery-command", str(RECOVERY_COMMAND)])
|
||||
recovery_invocation = (
|
||||
f"python3 {RECOVERY_COMMAND} begin --construction /tmp/p6.json "
|
||||
"--compaction-epoch 1 --request-epoch 1"
|
||||
if phase == "begin"
|
||||
else f"python3 {RECOVERY_COMMAND} complete"
|
||||
)
|
||||
value = {"tool_name": "Bash", "tool_input": {"command": recovery_invocation}}
|
||||
else:
|
||||
value = {"tool_name": "mosaic_context_recover"}
|
||||
completed = subprocess.run(command, input=json.dumps(value), text=True, capture_output=True, env=environment, check=False)
|
||||
if completed.returncode != 0:
|
||||
raise AssertionError(f"{runtime} recovery invocation remained gated: {completed.stderr!r}")
|
||||
|
||||
|
||||
def record_production_observation(runtime: str, message: str, root: Path, environment: dict[str, str]) -> None:
|
||||
command = [sys.executable, "-I", "-S", "-B", str(OBSERVER_CLIENT), "--runtime", runtime]
|
||||
if runtime == "claude":
|
||||
transcript = root / "claude-transcript.jsonl"
|
||||
transcript.write_text(json.dumps({"message": {"role": "assistant", "content": message}}) + "\n", encoding="utf-8")
|
||||
payload = {"transcript_path": str(transcript)}
|
||||
command.append("--latest-entry")
|
||||
else:
|
||||
payload = {"latest_assistant_message": message}
|
||||
completed = subprocess.run(command, input=json.dumps(payload), text=True, capture_output=True, env=environment, check=False)
|
||||
if completed.returncode != 0:
|
||||
raise AssertionError(f"{runtime} production observer transport refused: {completed.stderr!r}")
|
||||
|
||||
|
||||
def run_once(index: int, runtime: str) -> None:
|
||||
# Parity guard: drive the shipped command and the repaired adapter/observer
|
||||
# bytes, not a shadow receipt or promotion implementation.
|
||||
recovery_source = RECOVERY_COMMAND.read_text(encoding="utf-8")
|
||||
if '"action": "begin_recovery"' not in recovery_source or '"action": "complete_recovery"' not in recovery_source:
|
||||
raise AssertionError("P6 parity guard: recovery command no longer drives shipped broker entrypoints")
|
||||
gate_source = GATE.read_text(encoding="utf-8")
|
||||
if "--recovery-command" not in CLAUDE_SETTINGS.read_text(encoding="utf-8"):
|
||||
raise AssertionError("P6 parity guard: Claude recovery mapping is missing")
|
||||
if "_SHELL_ACTIVE" not in gate_source or "argv[1] != str(recovery_command)" not in gate_source:
|
||||
raise AssertionError("P6 parity guard: Claude mapping is not literal-only")
|
||||
if "const RECOVERY_TOOL = 'mosaic_context_recover'" not in PI_EXTENSION.read_text(encoding="utf-8"):
|
||||
raise AssertionError("P6 parity guard: Pi recovery tool mapping is missing")
|
||||
|
||||
fragments = load_shipped_fragments()
|
||||
root = Path(tempfile.mkdtemp(prefix=f"mosaic-p6-recovery-{index}-"))
|
||||
os.chmod(root, 0o700)
|
||||
socket_path = root / "broker.sock"
|
||||
observer_socket = root / "observer.sock"
|
||||
state_path = root / "state.json"
|
||||
construction_path = root / "construction.json"
|
||||
content = b"P6 constrained recovery fixture\n"
|
||||
construction = {
|
||||
"manifest_version": 1,
|
||||
"generator_version": "p6-constrained-recovery",
|
||||
"fragments": [{
|
||||
"source_id": "authority/p6",
|
||||
"content_base64": base64.b64encode(content).decode("ascii"),
|
||||
"expected_sha256": hashlib.sha256(content).hexdigest(),
|
||||
}],
|
||||
}
|
||||
construction_path.write_text(json.dumps(construction), encoding="utf-8")
|
||||
os.chmod(construction_path, 0o600)
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-I", "-S", "-B", str(DAEMON), "--socket", str(socket_path),
|
||||
"--state", str(state_path), "--observer-socket", str(observer_socket)],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
wait_ready(process, socket_path)
|
||||
registered = request(socket_path, {"action": "register_anchor", "runtime_generation": 1})
|
||||
session_id = registered.get("session_id")
|
||||
if registered.get("ok") is not True or not isinstance(session_id, str):
|
||||
raise AssertionError(f"broker anchor registration failed: {registered!r}")
|
||||
built = fragments.build_payload_from_wire(construction)
|
||||
normal = request(socket_path, {
|
||||
"action": "begin_verification", "session_id": session_id, "runtime_generation": 1,
|
||||
"runtime": runtime, "construction": construction,
|
||||
"binding": {"compaction_epoch": index, "request_epoch": index + 100,
|
||||
"h_source": built.h_source, "h_payload": built.h_payload, "schema_version": 1},
|
||||
})
|
||||
normal_challenge = normal.get("receipt_challenge")
|
||||
normal_receipt = normal.get("receipt")
|
||||
if not isinstance(normal_challenge, str) or not isinstance(normal_receipt, str):
|
||||
raise AssertionError("normal path did not mint a receipt challenge")
|
||||
environment = {
|
||||
**os.environ,
|
||||
"MOSAIC_LEASE_BROKER_SOCKET": str(socket_path),
|
||||
"MOSAIC_RECEIPT_OBSERVER_SOCKET": str(observer_socket),
|
||||
"MOSAIC_LEASE_SESSION_ID": session_id,
|
||||
"MOSAIC_RUNTIME_GENERATION": "1",
|
||||
"MOSAIC_LEASE_RUNTIME": runtime,
|
||||
}
|
||||
gate_recovery(runtime, "begin", environment)
|
||||
recovery = run_json([
|
||||
sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "begin", "--construction", str(construction_path),
|
||||
"--compaction-epoch", str(index + 10), "--request-epoch", str(index + 110),
|
||||
], environment)
|
||||
challenge = recovery.get("receipt_challenge")
|
||||
receipt = recovery.get("receipt")
|
||||
if recovery.get("state") != "PENDING_DELIVERY" or not isinstance(challenge, str) or not isinstance(receipt, str):
|
||||
raise AssertionError(f"recovery command did not drive pending delivery: {recovery!r}")
|
||||
if challenge == normal_challenge:
|
||||
raise AssertionError("recovery reused a normal-path challenge")
|
||||
|
||||
# C4: production observer content is still exact-current-cycle only.
|
||||
record_production_observation(runtime, normal_receipt, root, environment)
|
||||
refused = run_json([sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "complete"], environment)
|
||||
if refused.get("ok") is not False or refused.get("code") != "RECEIPT_MISMATCH":
|
||||
raise AssertionError(f"normal-path receipt replay was not refused: {refused!r}")
|
||||
|
||||
gate_recovery(runtime, "begin", environment)
|
||||
recovery = run_json([
|
||||
sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "begin", "--construction", str(construction_path),
|
||||
"--compaction-epoch", str(index + 20), "--request-epoch", str(index + 120),
|
||||
], environment)
|
||||
receipt = recovery.get("receipt")
|
||||
if recovery.get("state") != "PENDING_DELIVERY" or not isinstance(receipt, str):
|
||||
raise AssertionError(f"fresh recovery retry did not pend: {recovery!r}")
|
||||
record_production_observation(runtime, receipt, root, environment)
|
||||
gate_recovery(runtime, "complete", environment)
|
||||
promoted = run_json([sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "complete"], environment)
|
||||
if promoted.get("ok") is not True or promoted.get("state") != "VERIFIED":
|
||||
raise AssertionError(f"recovery consume-before-promote failed: {promoted!r}")
|
||||
replay = run_json([sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "complete"], environment)
|
||||
if replay.get("ok") is not False or replay.get("code") != "INVALID_LEASE_TRANSITION":
|
||||
raise AssertionError(f"consumed recovery challenge re-promoted: {replay!r}")
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=3.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--runs", type=int, default=3)
|
||||
arguments = parser.parse_args()
|
||||
if arguments.runs != 3:
|
||||
raise SystemExit("P6 requires exactly three isolated runs")
|
||||
for index, runtime in enumerate(("pi", "claude", "pi")):
|
||||
run_once(index, runtime)
|
||||
print("P6 constrained recovery probe PASS: 3 isolated shipped recovery-command runs")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,290 @@
|
||||
# Design — #791: Framework upgrades must not destroy operator-owned config under `~/.config/mosaic`
|
||||
|
||||
- **Issue:** mosaicstack/stack#791
|
||||
- **Branch:** `feat/791-upgrade-config-protection` (off `origin/main` `9745bc3f`)
|
||||
- **Author:** ms-791 worker lane
|
||||
- **Status:** Phase 1 — DESIGN, awaiting MS-LEAD confirmation before implementation
|
||||
- **Ratified scope (Mos-approved, not re-litigated):** deliver **(b) strict ownership separation [PRIMARY]** + **(a) transactional pre-update snapshot [safety net]** + **(d) regeneration-from-SSOT [recovery]**. **(c) periodic backup timer is DEFERRED** — noted as future work only.
|
||||
|
||||
---
|
||||
|
||||
## 1. Current updater behavior + exact wipe mechanism (evidence)
|
||||
|
||||
### 1.1 What runs on `mosaic update`
|
||||
|
||||
`mosaic update` re-seeds the framework by invoking the **bash installer** in sync-only, keep mode:
|
||||
|
||||
- `packages/mosaic/src/runtime/update-checker.ts:509` `buildReseedCommand()` returns
|
||||
`bash <frameworkRoot>/install.sh` with env `MOSAIC_SYNC_ONLY=1`, `MOSAIC_INSTALL_MODE=keep`,
|
||||
`MOSAIC_HOME=<mosaicHome>`.
|
||||
- The same `install.sh` is the direct/`tools/install.sh` upgrade path and the framework-vN migration path.
|
||||
|
||||
So the destructive surface is **`packages/mosaic/framework/install.sh`**.
|
||||
|
||||
### 1.2 The wipe
|
||||
|
||||
`sync_framework()` (`install.sh:177`) performs, in `keep` mode:
|
||||
|
||||
```
|
||||
rsync -a --delete --exclude .git --exclude .framework-version --exclude '*.pre-constitution.bak' \
|
||||
[--exclude "/$path" for each PRESERVE_PATHS entry] SOURCE_DIR/ TARGET_DIR/
|
||||
```
|
||||
|
||||
- `install.sh:199` — `rsync -a --delete`. **`--delete` prunes every path in `~/.config/mosaic`
|
||||
that is NOT present in the shipped framework source**, unless excluded.
|
||||
- `install.sh:47` — `PRESERVE_PATHS` is the **only** thing standing between `--delete` and operator
|
||||
data. It is a _denylist of exclusions_:
|
||||
```
|
||||
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md"
|
||||
"memory" "sources" "credentials" "fleet/roster.yaml" "fleet/roster.json" "fleet/agents"
|
||||
"fleet/run" "fleet/backlog" "fleet/roles.local")
|
||||
```
|
||||
- The cp-fallback (no rsync) is equally destructive: `install.sh:223`
|
||||
`find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ... -exec rm -rf {} +` then re-copies source, restoring
|
||||
only PRESERVE_PATHS globs.
|
||||
|
||||
**Root-cause model:** _"Everything under `~/.config/mosaic` is framework-owned and pruneable UNLESS
|
||||
explicitly preserved."_ Any operator path the list forgets is destroyed on the next upgrade.
|
||||
|
||||
### 1.3 The exact operator paths wiped
|
||||
|
||||
Cross-referencing the issue's operator-owned list against `PRESERVE_PATHS`:
|
||||
|
||||
| Operator path (issue #791) | In PRESERVE_PATHS? | Fate on `mosaic update` |
|
||||
| ----------------------------------------------------------------- | --------------------------------------- | ----------------------- |
|
||||
| `agents/*.conf` (per-agent runtime) | **NO** | **WIPED** |
|
||||
| `policy/*.md` (operator overlays) | **NO** | **WIPED** |
|
||||
| `*.local.md` (SOUL/USER/STANDARDS) | **NO** | **WIPED** |
|
||||
| harvester / SOP artifacts + timers | **NO** | **WIPED** |
|
||||
| `tools/_lib/credentials.json` | **NO** (`credentials/` dir ≠ this path) | **WIPED** |
|
||||
| `fleet/agents/*.env` | yes (`fleet/agents`, added by #631) | survives |
|
||||
| `memory/`, `fleet/roster.*`, `fleet/backlog`, `fleet/roles.local` | yes | survives |
|
||||
|
||||
The `fleet/agents`, `memory`, `fleet/backlog` entries were **retro-added after prior incidents**
|
||||
(#631). This whack-a-mole is the structural signature of a denylist.
|
||||
|
||||
**Stale-comment evidence:** `update-checker.ts:492` claims the reseed preserves
|
||||
"`SOUL/USER/*.local/credentials`" — but `PRESERVE_PATHS` contains **no `*.local` entry**. The code
|
||||
documents protection it does not deliver.
|
||||
|
||||
### 1.4 Second code path (TS) — already non-destructive, but drifted
|
||||
|
||||
`FileConfigAdapter.syncFramework()` (`packages/mosaic/src/config/file-adapter.ts:157`) →
|
||||
`syncDirectory()` (`packages/mosaic/src/platform/file-ops.ts:66`) is a **copy-overlay**: it copies
|
||||
source over target and skips preserved paths, but **never deletes** target paths absent from source
|
||||
(`file-ops.ts:77-109`). It is used by the wizard/init flow, not `mosaic update`.
|
||||
|
||||
Two problems remain:
|
||||
|
||||
1. Its `preservePaths` (`file-adapter.ts:164-185`) has **already diverged** from `install.sh` — it is
|
||||
**missing `fleet/backlog` and `fleet/roles.local`**. Two hand-maintained denylists, drifted. This
|
||||
is direct evidence for a single shared SSOT manifest.
|
||||
2. Even non-destructive, it will happily _overwrite_ an operator file that collides with a
|
||||
framework-shipped path unless that path is on its (incomplete) preserve list.
|
||||
|
||||
### 1.5 Existing snapshot is inadequate for rollback
|
||||
|
||||
`make_snapshot()`/`restore_snapshot()` (`install.sh:76-87`) copy `TARGET_DIR` to `mktemp -d` under
|
||||
`/tmp`, restore **only on `ERR/INT/TERM` trap**, and are **deleted on success** (`cleanup_snapshot`,
|
||||
`install.sh:345`). Consequences: ephemeral `/tmp`, no retention, no post-success rollback, and **no
|
||||
`mosaic restore`**. It is crash-safety only, not the transactional safety net #791 requires.
|
||||
|
||||
---
|
||||
|
||||
## 2. Fix (b) — Strict ownership separation [PRIMARY / root cause]
|
||||
|
||||
### 2.1 Ownership model (invert to allow-list)
|
||||
|
||||
Replace _"framework-owned unless preserved"_ with _"operator-owned unless framework-owned"_, resolved
|
||||
**per target path** with operator carve-outs winning inside shared framework subtrees.
|
||||
|
||||
Two declared lists, one SSOT data file shipped in the framework
|
||||
(`framework/framework-manifest.json`), consumed by **both** bash and TS:
|
||||
|
||||
- **`framework` globs** — paths the updater is entitled to create / overwrite / prune. Authored to
|
||||
match exactly what the framework ships in `packages/mosaic/framework/` (e.g. `CONSTITUTION.md`,
|
||||
`AGENTS.md`, `STANDARDS.md`, `TOOLS.md`, `guides/**`, `constitution/**`, `templates/**`, `tools/**`,
|
||||
`skills/**`, `mcp/**`, `defaults/**`, `fleet/examples/**`, `fleet/roles/**`, `fleet/profiles/**`,
|
||||
`fleet/roster.schema.json`).
|
||||
- **`operatorReserved` globs** — NEVER written or pruned, even nested inside a `framework` subtree;
|
||||
these **win** over `framework` (deny-wins / most-specific-wins). At minimum:
|
||||
`agents/**`, `policy/**`, `memory/**`, `sources/**`, `credentials/**`, `*.local.md`,
|
||||
`tools/_lib/credentials.json`, `fleet/roster.yaml`, `fleet/roster.json`, `fleet/agents/**`,
|
||||
`fleet/run/**`, `fleet/backlog/**`, `fleet/roles.local/**`, plus operator harvester/SOP artifacts.
|
||||
|
||||
### 2.2 Ownership resolution for a target path `P`
|
||||
|
||||
1. `P` matches `operatorReserved` → **operator-owned**: updater MUST NOT write, MUST NOT delete.
|
||||
2. else `P` matches `framework` → **framework-owned**: may overwrite; may prune **only if absent from
|
||||
the current SOURCE** (a genuinely retired framework file).
|
||||
3. else (matches neither) → **UNKNOWN ⇒ operator-owned by default (fail-safe)**: never delete.
|
||||
|
||||
Rule 3 is the actual root-cause fix: an operator path the manifest authors forget is still protected,
|
||||
because _unknown defaults to operator_. A denylist can never provide this guarantee.
|
||||
|
||||
### 2.3 Sync mechanism change (the mechanically-critical part)
|
||||
|
||||
`--delete` cannot express "prune only framework-owned" without re-enumerating every operator path
|
||||
(the denylist trap). So:
|
||||
|
||||
1. **Drop `--delete` from the bulk sync.** Copy `SOURCE → TARGET` non-destructively (writes/overwrites
|
||||
all framework files; deletes nothing). rsync without `--delete`, or the existing overlay copy.
|
||||
2. **Explicit manifest-scoped prune pass.** Iterate the **`framework` manifest** (not the whole tree);
|
||||
for each framework path present in `TARGET` but **absent in `SOURCE`**, delete it — after
|
||||
re-checking it does not match `operatorReserved`. Because the prune iterates only declared
|
||||
framework globs, operator/unknown paths are **structurally unreachable** by deletion.
|
||||
|
||||
This is implemented in both bash `sync_framework()` and TS `syncFramework()` from the shared manifest.
|
||||
A pure **prune-planner** function (TS) computes the delete-set from
|
||||
`(manifest, sourceListing, targetListing)` so the invariant is unit-testable in isolation.
|
||||
`PRESERVE_PATHS` becomes redundant (kept as a defense-in-depth alias mapping to `operatorReserved`, or
|
||||
removed) — either way the two lists stop drifting because they read one file.
|
||||
|
||||
### 2.4 HARD GATE test — "upgrade touches no path outside the manifest"
|
||||
|
||||
Filesystem-observation test in the existing `test-install-migration.sh` harness pattern (mktemp
|
||||
`MOSAIC_HOME`, `MOSAIC_SYNC_ONLY=1`), plus TS specs:
|
||||
|
||||
1. Seed a throwaway `TARGET` with a realistic operator mix — one sentinel per operator class:
|
||||
`agents/x.conf`, `policy/p.md`, `SOUL.local.md`, `memory/m.md`,
|
||||
`tools/_lib/credentials.json` (with a secret value), `fleet/agents/a.env`, `fleet/roster.yaml`,
|
||||
`harvester/sop.md`, **and a deliberately-unanticipated `unknown-operator-dir/x`**.
|
||||
2. Record hash+mtime of every sentinel.
|
||||
3. Run the upgrade from a `SOURCE` containing none of those operator paths.
|
||||
4. **Assert:** every sentinel exists, byte-identical, **mtime unchanged** (not even rewritten). The
|
||||
`unknown-operator-dir` surviving proves the fail-safe default — a denylist could not pass this case.
|
||||
5. **Positive controls:** framework files WERE updated; a retired framework file WAS pruned.
|
||||
6. **Property test** (TS prune-planner): for fuzzed operator paths, `deleteSet ⊆ {matches framework ∧
|
||||
in target ∧ not in source}` and `deleteSet ∩ operatorReserved = ∅`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Fix (a) — Transactional pre-update snapshot [safety net]
|
||||
|
||||
- **Destination:** `${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-ts>/`.
|
||||
**Outside `~/.config/mosaic`** (so no future sync can sweep it) and outside any repo.
|
||||
- **Perms:** dir `0700`, files `0600` — enforced with `umask 077` around the copy **and** explicit
|
||||
`chmod`. Never world-readable.
|
||||
- **Scope:** the operator-owned surface (`operatorReserved` paths that exist) — bounded; does not copy
|
||||
the framework tree.
|
||||
- **Timing:** taken before ANY mutation in the upgrade flow.
|
||||
- **Post-sync verify + selective restore:** after sync, diff the operator surface against the snapshot;
|
||||
since (b) should never touch operator paths, any diff means a manifest bug — restore the affected
|
||||
paths from the snapshot and warn loudly. This is precisely (a) catching a miss in (b).
|
||||
- **Retention:** keep N most-recent (default 5; `MOSAIC_BACKUP_RETENTION` override); prune older.
|
||||
- **`mosaic restore`:** `--list` (default, dry-run) enumerates snapshots by timestamp;
|
||||
`--from <ts>` restores that snapshot over the operator surface, confirmation-gated. Reports
|
||||
counts/paths only.
|
||||
- **Secret-safety:** snapshot copy and restore never emit file **contents**; only paths/counts.
|
||||
Tests assert `0700/0600` and that no secret value appears in stdout/stderr.
|
||||
|
||||
---
|
||||
|
||||
## 4. Fix (d) — Regeneration-from-SSOT [recovery]
|
||||
|
||||
The incident's live blast radius: `fleet/agents/*.env` (systemd `EnvironmentFile` sources) gone →
|
||||
`mosaic-agent@<name>` boots **unit defaults** on restart (because `EnvironmentFile=-...` is
|
||||
absent-tolerant) → **silent identity/runtime/workdir downgrade**.
|
||||
|
||||
The SSOT for those `.env` files is the roster. The reconciler **already** separates a
|
||||
`regenerate-projections-from-roster` projection phase from lifecycle
|
||||
(`packages/mosaic/src/fleet/fleet-reconciler.ts:93,234`; env rendering in
|
||||
`generated-env-boundary.ts:149-264`).
|
||||
|
||||
**`mosaic fleet regen`** is therefore a **thin recovery-framed wrapper over the existing projection
|
||||
phase** — it does NOT reimplement fleet logic and does NOT preempt in-flight FCM cards (M4/M5):
|
||||
|
||||
- Regenerates derivable config (per-agent `*.env.generated`, unit files) from roster SSOT.
|
||||
- **Preview-first:** dry-run default; `--write` to apply. Idempotent.
|
||||
- **Never restarts agents** (the recovery order forbids restart-before-verify).
|
||||
- Prints the runbook's next step (verify `EnvironmentFile` resolves, THEN restart).
|
||||
|
||||
Alternatively documentable as `install.sh --relink` per the issue; `mosaic fleet regen` is preferred
|
||||
because it reuses the merged reconciler plumbing.
|
||||
|
||||
---
|
||||
|
||||
## 5. Secret-safety approach (secrev surface)
|
||||
|
||||
- Snapshots/backups: `0700`/`0600`, outside any repo, never world-readable. (§3)
|
||||
- No secret **value** ever emitted to logs/stdout/stderr by snapshot, restore, sync, or regen —
|
||||
paths/counts only. Adversarial test: a secret value placed in `tools/_lib/credentials.json` must
|
||||
never appear in installer or command output.
|
||||
- `tools/_lib/credentials.json` is an explicit `operatorReserved` carve-out inside the framework-owned
|
||||
`tools/**` subtree — it is never overwritten or pruned.
|
||||
- The HARD GATE test doubles as a secret-safety test (asserts the credentials sentinel is untouched).
|
||||
|
||||
---
|
||||
|
||||
## 6. Test plan (TDD, tests-first, ≥85% on new code, co-located `*.spec.ts`)
|
||||
|
||||
1. **Manifest SSOT parity** — bash and TS resolve identical framework/operator sets from the one file;
|
||||
a test fails if either path hard-codes a divergent list.
|
||||
2. **Manifest completeness** — every path shipped in `framework/` is covered by a `framework` glob (so
|
||||
a new shipped file cannot silently fall outside the manifest and become un-prunable/undeclared).
|
||||
3. **HARD GATE** — upgrade touches nothing outside the manifest, incl. the unanticipated-path case
|
||||
(§2.4).
|
||||
4. **Prune-planner** unit + property tests (§2.4.6).
|
||||
5. **Snapshot** — perms `0700/0600`, correct destination, retention prune, secret value absent from
|
||||
output.
|
||||
6. **Restore** — `--list` / `--from` round-trip restores operator surface byte-exact; confirmation
|
||||
gate; no secret leakage.
|
||||
7. **Regen** — roster→env projection deterministic + idempotent; dry-run makes no writes; `--write`
|
||||
restores `*.env`; **never** issues a lifecycle/restart call.
|
||||
8. **Cross-path regression** — TS `syncFramework` and bash `install.sh` agree on a shared fixture
|
||||
(closes the current #631-style drift).
|
||||
|
||||
Gates before every push: `pnpm typecheck && pnpm lint && pnpm format:check` + mosaic package tests
|
||||
green. Never `--no-verify`.
|
||||
|
||||
---
|
||||
|
||||
## 7. web1 recovery runbook (operator-agnostic; web1 specifics live in the issue as evidence only)
|
||||
|
||||
For a currently-wiped fleet EnvironmentFile state — **do NOT service-restart while
|
||||
`fleet/agents/*.env` is absent** (a restart boots unit defaults and silently downgrades identity):
|
||||
|
||||
1. **Regenerate:** `mosaic fleet regen --write` — rebuild `~/.config/mosaic/fleet/agents/*.env` from
|
||||
roster SSOT.
|
||||
2. **Verify each unit resolves to the intended runtime/workdir** _before_ any restart:
|
||||
`systemctl --user show mosaic-agent@<name> -p EnvironmentFile` and confirm the generated env exists
|
||||
and carries the intended `MOSAIC_AGENT_*` runtime/workdir values.
|
||||
3. **Only then** `systemctl --user restart mosaic-agent@<name>`, one unit at a time.
|
||||
|
||||
If config (not just fleet env) was lost, `mosaic restore --list` → `mosaic restore --from <ts>` before
|
||||
step 1.
|
||||
|
||||
---
|
||||
|
||||
## 8. Proposed PR split (reviewable; DAG-ordered)
|
||||
|
||||
| PR | Scope | Depends | Review focus |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -------------------------- |
|
||||
| PR1 | **PRIMARY** — shared `framework-manifest.json` + ownership resolver + non-deleting sync + scoped prune (bash + TS) + **HARD GATE** + prune-planner tests | — | correctness (root fix) |
|
||||
| PR2 | **Safety net** — pre-update snapshot (`~/.local/state`, 0700/0600, retention) + post-sync verify/restore + `mosaic restore` | PR1 | **secrev** (backup/secret) |
|
||||
| PR3 | **Recovery** — `mosaic fleet regen` (projection-only, preview-first, no restart) + docs (upgrade-safety + recovery runbook) | PR1 | correctness + docs |
|
||||
|
||||
Rationale: PR1 closes the failure class on its own; if PR2/PR3 slip, the class stays fixed. Each PR is
|
||||
one reviewable unit with its own tests ≥85%. Independent review (author≠reviewer) on all; **secrev** on
|
||||
PR2 (and PR1's secret-sentinel assertions).
|
||||
|
||||
## 9. Deferred (noted per scope)
|
||||
|
||||
**(c) periodic backup timer** — a systemd user timer snapshotting operator dirs on a cadence
|
||||
(defense-in-depth for non-upgrade losses). Explicitly **out of scope now**; future phase.
|
||||
|
||||
## 10. Constraints honored
|
||||
|
||||
- **Framework-PR firewall:** manifest + logic are operator-agnostic; no SOUL/USER/operator specifics
|
||||
in framework code; web1 details are issue evidence only.
|
||||
- **Capacity-fill:** must not preempt M5-001 or #790; `fleet regen` reuses merged FCM-M3 plumbing and
|
||||
does not overlap FCM-M4/M5 migration cards.
|
||||
- **Delivery gates:** TDD tests-first, ≥85% new-code coverage, trunk-based squash PRs, independent
|
||||
review + secrev, completion = merged PR + descendant-main green + #791 closed.
|
||||
|
||||
---
|
||||
|
||||
**Requesting MS-LEAD confirmation of:** (1) the manifest allow-list + non-deleting-sync + scoped-prune
|
||||
approach as the (b) root-cause fix; (2) snapshot destination/retention + `mosaic restore` UX;
|
||||
(3) `mosaic fleet regen` as a projection-only wrapper; (4) the 3-PR split. Implementation begins only
|
||||
on your confirmation.
|
||||
@@ -0,0 +1,63 @@
|
||||
# npm `@next` prerelease lane
|
||||
|
||||
Status: **IMPLEMENTED**
|
||||
|
||||
## Current behavior
|
||||
|
||||
`tools/install.sh --next` provides the prerelease integration lane for the permanent `next` branch.
|
||||
|
||||
The lane is fast-by-default:
|
||||
|
||||
1. Install framework files from the `next` source archive.
|
||||
2. Resolve the Gitea npm registry `next` dist-tag for the globally installed packages:
|
||||
|
||||
```bash
|
||||
npm view @mosaicstack/gateway@next version
|
||||
npm view @mosaicstack/mosaic@next version
|
||||
```
|
||||
|
||||
3. Require both resolved versions to share the same `next.<pipeline>` suffix, then install the exact resolved versions.
|
||||
4. If either `@next` package is missing, unreachable, mismatched, or fails to install, fall back to the source-build path at `next`.
|
||||
|
||||
`--next` never hard-fails solely because the prerelease npm dist-tag is unavailable.
|
||||
|
||||
## Published packages
|
||||
|
||||
The `next` publish pipeline publishes non-private `@mosaicstack/*` packages to the Mosaic Gitea npm registry:
|
||||
|
||||
```text
|
||||
https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||
```
|
||||
|
||||
Observed `next` dist-tags after enabling the pipeline:
|
||||
|
||||
```text
|
||||
@mosaicstack/mosaic@next -> 0.0.49-next.1633
|
||||
@mosaicstack/gateway@next -> 0.0.7-next.1633
|
||||
```
|
||||
|
||||
The gateway also publishes a Docker image as `gateway:sha-<short>` on `next` merges. The installer fast path uses the npm gateway package when available; the Docker image is for deployed gateway/runtime harness flows.
|
||||
|
||||
## Explicit source lanes
|
||||
|
||||
Source builds remain available and are still the authority for explicit ref validation:
|
||||
|
||||
- `--dev` always builds from source.
|
||||
- `--ref <ref>` / `MOSAIC_REF=<ref>` wins over `--next` and uses the source path for that exact ref.
|
||||
|
||||
## Pipeline shape
|
||||
|
||||
1. Trigger on `next` merges.
|
||||
2. Compute the next prerelease version from the upcoming stable version plus the Woodpecker pipeline number (`<target-stable>-next.<CI_PIPELINE_NUMBER>`).
|
||||
3. Build and publish non-private packages in CI.
|
||||
4. Publish to the Mosaic Gitea npm registry with dist-tag `next`.
|
||||
5. Keep `latest` untouched; only main/release promotion can update `latest`.
|
||||
6. Publish gateway Docker images from `next` as `gateway:sha-<short>` only.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- `@next` is mutable prerelease convenience, not a deployment pin.
|
||||
- Stable installs continue to use `@latest`.
|
||||
- Contributor validation remains available through `--dev --ref <branch>`.
|
||||
- Pipeline output traces every prerelease package back to the source commit on `next`.
|
||||
- The installer falls back to source rather than hard-failing on prerelease registry issues.
|
||||
@@ -70,6 +70,10 @@ export function createQueue(config?: QueueConfig): QueueHandle {
|
||||
|
||||
### `@mosaicstack/db` (packages/db/src/client.ts)
|
||||
|
||||
> **Historical design specimen — status-only, not an operator instruction.** KBN-101 supersedes
|
||||
> this pre-split `DATABASE_URL` fallback shape; it cannot authorize runtime migration, DDL, or a
|
||||
> connection-string fallback. See the KBN-101 runner/role contract for the produced interface.
|
||||
|
||||
```typescript
|
||||
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
||||
@@ -54,7 +54,7 @@ Every milestone adds tests to these layers. A milestone cannot be claimed comple
|
||||
- Add `"tier": "federated"` to `mosaic.config.json` schema and validators
|
||||
- Docker Compose `federated` profile (`docker-compose.federated.yml`) adds: Postgres+pgvector (5433), Valkey (6380), dedicated volumes
|
||||
- Tier detector in gateway bootstrap: reads config, asserts required services reachable, refuses to start otherwise
|
||||
- `pgvector` extension installed + verified on startup
|
||||
- **Historical/status only:** the prior startup-provisioning statement is superseded. Runtime/startup extension provisioning is forbidden. PostgreSQL activation remains non-operative with no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05 land; this record authorizes no current DDL, Compose/init, or startup path.
|
||||
- Migration logic: safe upgrade path from `local`/`standalone` → `federated` (data export/import script, one-way)
|
||||
- `mosaic doctor` reports tier + service health
|
||||
- Gateway continues to serve as a normal standalone instance (no federation yet)
|
||||
|
||||
+72
-278
@@ -1,280 +1,74 @@
|
||||
# Federated Tier Setup Guide
|
||||
|
||||
## What is the federated tier?
|
||||
|
||||
The federated tier is designed for multi-user and multi-host deployments. It consists of PostgreSQL 17 with pgvector extension (for embeddings and RAG), Valkey for distributed task queueing and caching, and a shared configuration across multiple Mosaic gateway instances. Use this tier when running Mosaic in production or when scaling beyond a single-host deployment.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- Ports 5433 (PostgreSQL) and 6380 (Valkey) available on your host (or adjust environment variables)
|
||||
- At least 2 GB free disk space for data volumes
|
||||
|
||||
## Start the federated stack
|
||||
|
||||
Run the federated overlay:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
```
|
||||
|
||||
This starts PostgreSQL 17 with pgvector and Valkey 8. The pgvector extension is created automatically on first boot.
|
||||
|
||||
Verify the services are running:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.federated.yml ps
|
||||
```
|
||||
|
||||
Expected output shows `postgres-federated` and `valkey-federated` both healthy.
|
||||
|
||||
## Configure mosaic for federated tier
|
||||
|
||||
Create or update your `mosaic.config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tier": "federated",
|
||||
"database": "postgresql://mosaic:mosaic@localhost:5433/mosaic",
|
||||
"queue": "redis://localhost:6380"
|
||||
}
|
||||
```
|
||||
|
||||
If you're using environment variables instead:
|
||||
|
||||
```bash
|
||||
export DATABASE_URL="postgresql://mosaic:mosaic@localhost:5433/mosaic"
|
||||
export REDIS_URL="redis://localhost:6380"
|
||||
```
|
||||
|
||||
## Verify health
|
||||
|
||||
Run the health check:
|
||||
|
||||
```bash
|
||||
mosaic gateway doctor
|
||||
```
|
||||
|
||||
Expected output (green):
|
||||
|
||||
```
|
||||
Tier: federated Config: mosaic.config.json
|
||||
✓ postgres localhost:5433 (42ms)
|
||||
✓ valkey localhost:6380 (8ms)
|
||||
✓ pgvector (embedded) (15ms)
|
||||
```
|
||||
|
||||
For JSON output (useful in CI/automation):
|
||||
|
||||
```bash
|
||||
mosaic gateway doctor --json
|
||||
```
|
||||
|
||||
## Step 2: Step-CA Bootstrap
|
||||
|
||||
Step-CA is a certificate authority that issues X.509 certificates for federation peers. In Mosaic federation, it signs peer certificates with custom OIDs that embed grant and user identities, enforcing authorization at the certificate level.
|
||||
|
||||
### Prerequisites for Step-CA
|
||||
|
||||
Before starting the CA, you must set up the dev password:
|
||||
|
||||
```bash
|
||||
cp infra/step-ca/dev-password.example infra/step-ca/dev-password
|
||||
# Edit dev-password and set your CA password (minimum 16 characters)
|
||||
```
|
||||
|
||||
The password is required for the CA to boot and derive the provisioner key used by the gateway.
|
||||
|
||||
### Start the Step-CA service
|
||||
|
||||
Add the step-ca service to your federated stack:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.federated.yml --profile federated up -d step-ca
|
||||
```
|
||||
|
||||
On first boot, the init script (`infra/step-ca/init.sh`) runs automatically. It:
|
||||
|
||||
- Generates the CA root key and certificate in the Docker volume
|
||||
- Creates the `mosaic-fed` JWK provisioner
|
||||
- Applies the X.509 template from `infra/step-ca/templates/federation.tpl`
|
||||
|
||||
The volume is persistent, so subsequent boots reuse the existing CA keys.
|
||||
|
||||
Verify the CA is healthy:
|
||||
|
||||
```bash
|
||||
curl https://localhost:9000/health --cacert /tmp/step-ca-root.crt
|
||||
```
|
||||
|
||||
(If the root cert file doesn't exist yet, see the extraction steps below.)
|
||||
|
||||
### Extract credentials for the gateway
|
||||
|
||||
The gateway requires two credentials from the running CA:
|
||||
|
||||
**1. Provisioner key (for `STEP_CA_PROVISIONER_KEY_JSON`)**
|
||||
|
||||
```bash
|
||||
docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json > /tmp/step-ca-provisioner.json
|
||||
```
|
||||
|
||||
This JSON file contains the JWK public and private keys for the `mosaic-fed` provisioner. Store it securely and pass its contents to the gateway via the `STEP_CA_PROVISIONER_KEY_JSON` environment variable.
|
||||
|
||||
**2. Root certificate (for `STEP_CA_ROOT_CERT_PATH`)**
|
||||
|
||||
```bash
|
||||
docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
|
||||
```
|
||||
|
||||
This PEM file is the CA's root certificate, used to verify peer certificates issued by step-ca. Pass its path to the gateway via `STEP_CA_ROOT_CERT_PATH`.
|
||||
|
||||
### Custom OID Registry
|
||||
|
||||
Federation certificates include custom OIDs in the certificate extension. These encode authorization metadata:
|
||||
|
||||
| OID | Name | Description |
|
||||
| ------------------- | ---------------------- | --------------------- |
|
||||
| 1.3.6.1.4.1.99999.1 | mosaic_grant_id | Federation grant UUID |
|
||||
| 1.3.6.1.4.1.99999.2 | mosaic_subject_user_id | Subject user UUID |
|
||||
|
||||
These OIDs are verified by the gateway after the CSR is signed, ensuring the certificate was issued with the correct grant and user context.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Configure the gateway with the following environment variables before startup:
|
||||
|
||||
| Variable | Required | Description |
|
||||
| ------------------------------ | -------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `STEP_CA_URL` | Yes | Base URL of the step-ca instance, e.g. `https://step-ca:9000` (use `https://localhost:9000` in local dev) |
|
||||
| `STEP_CA_PROVISIONER_KEY_JSON` | Yes | JSON-encoded JWK from `/home/step/secrets/mosaic-fed.json` |
|
||||
| `STEP_CA_ROOT_CERT_PATH` | Yes | Absolute path to the root CA certificate (e.g. `/tmp/step-ca-root.crt`) |
|
||||
| `BETTER_AUTH_SECRET` | Yes | Secret used to seal peer private keys at rest; already required for M1 |
|
||||
|
||||
Example environment setup:
|
||||
|
||||
```bash
|
||||
export STEP_CA_URL="https://localhost:9000"
|
||||
export STEP_CA_PROVISIONER_KEY_JSON="$(cat /tmp/step-ca-provisioner.json)"
|
||||
export STEP_CA_ROOT_CERT_PATH="/tmp/step-ca-root.crt"
|
||||
export BETTER_AUTH_SECRET="<your-secret>"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port conflicts
|
||||
|
||||
**Symptom:** `bind: address already in use`
|
||||
|
||||
**Fix:** Stop the base dev stack first:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
```
|
||||
|
||||
Or change the host port with an environment variable:
|
||||
|
||||
```bash
|
||||
PG_FEDERATED_HOST_PORT=5434 VALKEY_FEDERATED_HOST_PORT=6381 \
|
||||
docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
```
|
||||
|
||||
### pgvector extension error
|
||||
|
||||
**Symptom:** `ERROR: could not open extension control file`
|
||||
|
||||
**Fix:** pgvector is created at first boot. Check logs:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.federated.yml logs postgres-federated | grep -i vector
|
||||
```
|
||||
|
||||
If missing, exec into the container and create it manually:
|
||||
|
||||
```bash
|
||||
docker exec <postgres-federated-id> psql -U mosaic -d mosaic -c "CREATE EXTENSION vector;"
|
||||
```
|
||||
|
||||
### Valkey connection refused
|
||||
|
||||
**Symptom:** `Error: connect ECONNREFUSED 127.0.0.1:6380`
|
||||
|
||||
**Fix:** Check service health:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.federated.yml logs valkey-federated
|
||||
```
|
||||
|
||||
If Valkey is running, verify your firewall allows 6380. On macOS, Docker Desktop may require binding to `host.docker.internal` instead of `localhost`.
|
||||
|
||||
## Key rotation (deferred)
|
||||
|
||||
Federation peer private keys (`federation_peers.client_key_pem`) are sealed at rest using AES-256-GCM with a key derived from `BETTER_AUTH_SECRET` via SHA-256. If `BETTER_AUTH_SECRET` is rotated, all sealed `client_key_pem` values in the database become unreadable and must be re-sealed with the new key before rotation completes.
|
||||
|
||||
The full key rotation procedure (decrypt all rows with old key, re-encrypt with new key, atomically swap the secret) is out of scope for M2. Operators must not rotate `BETTER_AUTH_SECRET` without a migration plan for all sealed federation peer keys.
|
||||
|
||||
## OID Assignments — Mosaic Internal OID Arc
|
||||
|
||||
Mosaic uses the private enterprise arc `1.3.6.1.4.1.99999` for custom X.509
|
||||
certificate extensions in federation grant certificates.
|
||||
|
||||
**IMPORTANT:** This is a development/internal OID arc. Before deploying to a
|
||||
production environment accessible by external parties, register a proper IANA
|
||||
Private Enterprise Number (PEN) at <https://pen.iana.org/pen/PenApplication.page>
|
||||
and update these assignments accordingly.
|
||||
|
||||
### Assigned OIDs
|
||||
|
||||
| OID | Symbolic name | Description |
|
||||
| --------------------- | --------------------------------- | --------------------------------------------------------- |
|
||||
| `1.3.6.1.4.1.99999.1` | `mosaic.federation.grantId` | UUID of the `federation_grants` row authorising this cert |
|
||||
| `1.3.6.1.4.1.99999.2` | `mosaic.federation.subjectUserId` | UUID of the local user on whose behalf the cert is issued |
|
||||
|
||||
### Encoding
|
||||
|
||||
Each extension value is DER-encoded as an ASN.1 **UTF8String**:
|
||||
|
||||
```
|
||||
Tag 0x0C (UTF8String)
|
||||
Length 0x24 (36 decimal — fixed length of a UUID string)
|
||||
Value <36 ASCII bytes of the UUID>
|
||||
```
|
||||
|
||||
The step-ca X.509 template at `infra/step-ca/templates/federation.tpl`
|
||||
produces this encoding via the Go template expression:
|
||||
|
||||
```
|
||||
{{ printf "\x0c\x24%s" .Token.mosaic_grant_id | b64enc }}
|
||||
```
|
||||
|
||||
The resulting base64 value is passed as the `value` field of the extension
|
||||
object in the template JSON.
|
||||
|
||||
### CA Environment Variables
|
||||
|
||||
The `CaService` (`apps/gateway/src/federation/ca.service.ts`) requires the
|
||||
following environment variables at gateway startup:
|
||||
|
||||
| Variable | Required | Description |
|
||||
| ------------------------------ | -------- | -------------------------------------------------------------------- |
|
||||
| `STEP_CA_URL` | Yes | Base URL of the step-ca instance, e.g. `https://step-ca:9000` |
|
||||
| `STEP_CA_PROVISIONER_PASSWORD` | Yes | JWK provisioner password for the `mosaic-fed` provisioner |
|
||||
| `STEP_CA_PROVISIONER_KEY_JSON` | Yes | JSON-encoded JWK (public + private) for the `mosaic-fed` provisioner |
|
||||
| `STEP_CA_ROOT_CERT_PATH` | Yes | Absolute path to the step-ca root CA certificate PEM file |
|
||||
|
||||
Set these variables in your environment or secret manager before starting
|
||||
the gateway. In the federated Docker Compose stack they are expected to be
|
||||
injected via Docker secrets and environment variable overrides.
|
||||
|
||||
### Fail-loud contract
|
||||
|
||||
The CA service (and the X.509 template) are designed to fail loudly if the
|
||||
custom OIDs cannot be embedded:
|
||||
|
||||
- The template produces a malformed extension value (zero-length UTF8String
|
||||
body) when the JWT claims `mosaic_grant_id` or `mosaic_subject_user_id` are
|
||||
absent. step-ca rejects the CSR rather than issuing a cert without the OIDs.
|
||||
- `CaService.issueCert()` throws a `CaServiceError` on every error path with
|
||||
a human-readable `remediation` string. It never silently returns a cert that
|
||||
may be missing the required extensions.
|
||||
> **KBN-101 N-1 hold:** This page is **non-operative** and grants no current command
|
||||
> authority until KBN-101-00, KBN-101-03, and KBN-101-05 land and KBN-101-08 activates a
|
||||
> reviewed release. It does not authorize a deployment operation, initialization artifacts,
|
||||
> implicit extension/schema/migration creation, raw `CREATE`, direct database initialization, or
|
||||
> a Gateway against an unverified database. The prior direct-start wording is retired; its
|
||||
> regression fixture is owned by KBN-101-06.
|
||||
|
||||
## Held future procedure
|
||||
|
||||
This section is non-operative and grants no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05 land.
|
||||
|
||||
The deployment control plane—not an operator shell or deployment lifecycle hook—performs this
|
||||
exact held future sequence after activation authorization: external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness.
|
||||
|
||||
1. External bootstrap provisions the approved database/extension prerequisites.
|
||||
2. TLS/roles are installed through the generation-pinned renderer.
|
||||
3. The dedicated one-shot runner executes `mosaic-db-migrator --run`.
|
||||
4. The same runner executes `mosaic-db-migrator --verify`, including readiness and the
|
||||
importer-target attestation where that route is enabled.
|
||||
5. Only after successful verification may Gateway reach its independent verified-TLS Gateway
|
||||
readiness gate.
|
||||
|
||||
No step may be reordered, skipped, replaced by a raw SQL command, or delegated to an initialization
|
||||
hook.
|
||||
A missing extension, schema, migration, role, secret generation, or readiness proof is a failed
|
||||
control-plane precondition; it is not an instruction to start Compose, retry startup, or create
|
||||
anything directly.
|
||||
|
||||
## N-1 status and required disposition
|
||||
|
||||
The current branch retains historical federation artifacts, but they are not a deployable
|
||||
procedure. `docs/federation/TASKS.md` records their shipped status only. KBN-101-02 retires
|
||||
runtime/init DDL; KBN-101-05 owns the renderer/deployment handoff; KBN-101-06 verifies the
|
||||
finite scanner and command matrix; and KBN-101-07 owns this operator route. A path named in an
|
||||
inventory, a historical-status label, or a normative requirement cannot suppress the semantic
|
||||
checks above.
|
||||
|
||||
Until the activation certificate names an exact release, use no database startup or recovery
|
||||
command from this document. For the produced importer interface, see
|
||||
[the federated tier migration contract](../guides/migrate-tier.md); it is likewise non-operative
|
||||
until activation.
|
||||
|
||||
## Federation and Step-CA reference
|
||||
|
||||
Federation uses PostgreSQL 17 with pgvector, Valkey, and a shared configuration across multiple
|
||||
Gateway instances. Step-CA issues federation peer X.509 certificates whose custom OIDs carry a
|
||||
grant and subject identity. The following facts are reference material only; provisioning and
|
||||
secret delivery remain deployment-control-plane work under the activation sequence.
|
||||
|
||||
| OID | Name | Description |
|
||||
| ------------------- | ------------------------ | --------------------- |
|
||||
| 1.3.6.1.4.1.99999.1 | `mosaic_grant_id` | Federation grant UUID |
|
||||
| 1.3.6.1.4.1.99999.2 | `mosaic_subject_user_id` | Subject user UUID |
|
||||
|
||||
The internal arc `1.3.6.1.4.1.99999` is development-only. Before an externally reachable
|
||||
production deployment, register an IANA Private Enterprise Number and version the assignments.
|
||||
Each value is DER-encoded as an ASN.1 UTF8String containing the UUID.
|
||||
|
||||
The future activated Gateway requires `STEP_CA_URL`, `STEP_CA_PROVISIONER_PASSWORD`,
|
||||
`STEP_CA_PROVISIONER_KEY_JSON`, `STEP_CA_ROOT_CERT_PATH`, and `BETTER_AUTH_SECRET` through the
|
||||
reviewed secret mechanism. These names do not authorize shell exports, copied credential files,
|
||||
or an ad hoc service start.
|
||||
|
||||
## Failure disposition
|
||||
|
||||
- A TLS, CA, SAN, role, runner, or readiness failure is a control-plane incident. Preserve only
|
||||
sanitized evidence and follow the approved rollback/repair record.
|
||||
- A pgvector/extension failure is a failed external-bootstrap or runner precondition. Do not use
|
||||
direct extension SQL, init artifacts, or a startup retry as remediation.
|
||||
- A port, container, or Valkey problem does not permit bypassing the activation sequence.
|
||||
- Federation peer-key rotation remains deferred until its separately approved migration plan;
|
||||
do not rotate `BETTER_AUTH_SECRET` without that plan.
|
||||
|
||||
+14
-14
@@ -15,20 +15,20 @@
|
||||
|
||||
Goal: Gateway runs in `federated` tier with containerized PG+pgvector+Valkey. No federation logic yet. Existing standalone behavior does not regress.
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| FED-M1-01 | done | Extend `mosaic.config.json` schema: add `"federated"` to `tier` enum in validator + TS types. Keep `local` and `standalone` working. Update schema docs/README where referenced. | #460 | sonnet | feat/federation-m1-tier-config | — | 4K | Shipped in PR #470. Renamed `team` → `standalone`; added `team` deprecation alias; added `DEFAULT_FEDERATED_CONFIG`. |
|
||||
| FED-M1-02 | done | Author `docker-compose.federated.yml` as an overlay profile: Postgres 17 + pgvector extension (port 5433), Valkey (6380), named volumes, healthchecks. Compose-up should boot cleanly on a clean machine. | #460 | sonnet | feat/federation-m1-compose | FED-M1-01 | 5K | Shipped in PR #471. Overlay defines `postgres-federated`/`valkey-federated`, profile-gated, with pg-init for pgvector extension. |
|
||||
| FED-M1-03 | done | Add pgvector support to `packages/storage/src/adapters/postgres.ts`: create extension on init (idempotent), expose vector column type in schema helpers. No adapter changes for non-federated tiers. | #460 | sonnet | feat/federation-m1-pgvector | FED-M1-02 | 8K | Shipped in PR #472. `enableVector` flag on postgres StorageConfig; idempotent CREATE EXTENSION before migrations. |
|
||||
| FED-M1-04 | done | Implement `apps/gateway/src/bootstrap/tier-detector.ts`: reads config, asserts PG/Valkey/pgvector reachable for `federated`, fail-fast with actionable error message on failure. Unit tests for each failure mode. | #460 | sonnet | feat/federation-m1-detector | FED-M1-03 | 8K | Shipped in PR #473. 12 tests; 5s timeouts on probes; pgvector library/permission discrimination; rejects non-bullmq for federated. |
|
||||
| FED-M1-05 | done | Write `scripts/migrate-to-federated.ts`: one-way migration from `local` (PGlite) / `standalone` (PG without pgvector) → `federated`. Dumps, transforms, loads; dry-run + confirm UX. Idempotent on re-run. | #460 | sonnet | feat/federation-m1-migrate | FED-M1-04 | 10K | Shipped in PR #474. `mosaic storage migrate-tier`; DrizzleMigrationSource (corrects P0 found in review); 32 tests; idempotent. |
|
||||
| FED-M1-06 | done | Update `mosaic doctor`: report current tier, required services, actual health per service, pgvector presence, overall green/yellow/red. Machine-readable JSON output flag for CI use. | #460 | sonnet | feat/federation-m1-doctor | FED-M1-04 | 6K | Shipped in PR #475 as `mosaic gateway doctor`. Probes lifted to @mosaicstack/storage; structural TierConfig breaks dep cycle. |
|
||||
| FED-M1-07 | done | Integration test: gateway boots in `federated` tier with docker-compose `federated` profile; refuses to boot when PG unreachable (asserts fail-fast); pgvector extension query succeeds. | #460 | sonnet | feat/federation-m1-integration | FED-M1-04 | 8K | Shipped in PR #476. 3 test files, 4 tests, gated by FEDERATED_INTEGRATION=1; reserved-port helper avoids host collisions. |
|
||||
| FED-M1-08 | done | Integration test for migration script: seed a local PGlite with representative data (tasks, notes, users, teams), run migration, assert row counts + key samples equal on federated PG. | #460 | sonnet | feat/federation-m1-migrate-test | FED-M1-05 | 6K | Shipped in PR #477. Caught P0 in M1-05 (camelCase→snake_case) missed by mocked unit tests; fix in same PR. |
|
||||
| FED-M1-09 | done | Standalone regression: full agent-session E2E on existing `standalone` tier with a gateway built from this branch. Must pass without referencing any federation module. | #460 | sonnet | feat/federation-m1-regression | FED-M1-07 | 4K | Clean canary. 351 gateway tests + 85 storage unit tests + full pnpm test all green; only FEDERATED_INTEGRATION-gated tests skip. |
|
||||
| FED-M1-10 | done | Code review pass: security-focused on the migration script (data-at-rest during migration) + tier detector (error-message sensitivity leakage). Independent reviewer, not authors of tasks 01-09. | #460 | sonnet | feat/federation-m1-security-review | FED-M1-09 | 8K | 2 review rounds caught 7 issues: credential leak in pg/valkey/pgvector errors + redact-error util; missing advisory lock; SKIP_TABLES rationale. |
|
||||
| FED-M1-11 | done | Docs update: `docs/federation/` operator notes for tier setup; README blurb on federated tier; `docs/guides/` entry for migration. Do NOT touch runbook yet (deferred to FED-M7). | #460 | haiku | feat/federation-m1-docs | FED-M1-10 | 4K | Shipped: `docs/federation/SETUP.md` (119 lines), `docs/guides/migrate-tier.md` (147 lines), README Configuration blurb. |
|
||||
| FED-M1-12 | done | PR, CI green, merge to main, close #460. | #460 | sonnet | feat/federation-m1-close | FED-M1-11 | 3K | M1 closed. PRs #470-#480 merged across 11 tasks. Issue #460 closed; release tag `fed-v0.1.0-m1` published. |
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| FED-M1-01 | done | Extend `mosaic.config.json` schema: add `"federated"` to `tier` enum in validator + TS types. Keep `local` and `standalone` working. Update schema docs/README where referenced. | #460 | sonnet | feat/federation-m1-tier-config | — | 4K | Shipped in PR #470. Renamed `team` → `standalone`; added `team` deprecation alias; added `DEFAULT_FEDERATED_CONFIG`. |
|
||||
| FED-M1-02 | done | Historical shipped-status record: authored a federated Compose overlay with PostgreSQL/pgvector, Valkey, volumes, and healthchecks. It is not a current startup, init, extension, schema, or migration procedure. | #460 | sonnet | feat/federation-m1-compose | FED-M1-01 | 5K | Shipped in PR #471 status only. KBN-101-02 retires its init authority; KBN-101-05 replaces deployment rendering; KBN-101-07 SETUP is non-operative until activation. |
|
||||
| FED-M1-03 | done | Historical shipped-status record: add pgvector support to `packages/storage/src/adapters/postgres.ts`; no adapter changes for non-federated tiers. | #460 | sonnet | feat/federation-m1-pgvector | FED-M1-02 | 8K | Shipped in PR #472 status only. **KBN-101 supersedes this behavior:** it cannot authorize current runtime extension creation or any DDL; only the runner/external bootstrap contract may do so. |
|
||||
| FED-M1-04 | done | Implement `apps/gateway/src/bootstrap/tier-detector.ts`: reads config, asserts PG/Valkey/pgvector reachable for `federated`, fail-fast with actionable error message on failure. Unit tests for each failure mode. | #460 | sonnet | feat/federation-m1-detector | FED-M1-03 | 8K | Shipped in PR #473. 12 tests; 5s timeouts on probes; pgvector library/permission discrimination; rejects non-bullmq for federated. |
|
||||
| FED-M1-05 | done | Historical shipped-status record: prior tier migration implementation. | #460 | sonnet | feat/federation-m1-migrate | FED-M1-04 | 10K | Shipped in PR #474 status only. **KBN-101 supersedes this route:** it cannot authorize current credentials, target connection, or DDL. The future active route requires runner verification plus target URL-file and signed attestation-file binding. |
|
||||
| FED-M1-06 | done | Update `mosaic doctor`: report current tier, required services, actual health per service, pgvector presence, overall green/yellow/red. Machine-readable JSON output flag for CI use. | #460 | sonnet | feat/federation-m1-doctor | FED-M1-04 | 6K | Shipped in PR #475 as `mosaic gateway doctor`. Probes lifted to @mosaicstack/storage; structural TierConfig breaks dep cycle. |
|
||||
| FED-M1-07 | done | Integration test: gateway boots in `federated` tier with docker-compose `federated` profile; refuses to boot when PG unreachable (asserts fail-fast); pgvector extension query succeeds. | #460 | sonnet | feat/federation-m1-integration | FED-M1-04 | 8K | Shipped in PR #476. 3 test files, 4 tests, gated by FEDERATED_INTEGRATION=1; reserved-port helper avoids host collisions. |
|
||||
| FED-M1-08 | done | Integration test for migration script: seed a local PGlite with representative data (tasks, notes, users, teams), run migration, assert row counts + key samples equal on federated PG. | #460 | sonnet | feat/federation-m1-migrate-test | FED-M1-05 | 6K | Shipped in PR #477. Caught P0 in M1-05 (camelCase→snake_case) missed by mocked unit tests; fix in same PR. |
|
||||
| FED-M1-09 | done | Standalone regression: full agent-session E2E on existing `standalone` tier with a gateway built from this branch. Must pass without referencing any federation module. | #460 | sonnet | feat/federation-m1-regression | FED-M1-07 | 4K | Clean canary. 351 gateway tests + 85 storage unit tests + full pnpm test all green; only FEDERATED_INTEGRATION-gated tests skip. |
|
||||
| FED-M1-10 | done | Code review pass: security-focused on the migration script (data-at-rest during migration) + tier detector (error-message sensitivity leakage). Independent reviewer, not authors of tasks 01-09. | #460 | sonnet | feat/federation-m1-security-review | FED-M1-09 | 8K | 2 review rounds caught 7 issues: credential leak in pg/valkey/pgvector errors + redact-error util; missing advisory lock; SKIP_TABLES rationale. |
|
||||
| FED-M1-11 | done | Docs update: `docs/federation/` operator notes for tier setup; README blurb on federated tier; `docs/guides/` entry for migration. Do NOT touch runbook yet (deferred to FED-M7). | #460 | haiku | feat/federation-m1-docs | FED-M1-10 | 4K | Shipped: `docs/federation/SETUP.md` (119 lines), `docs/guides/migrate-tier.md` (147 lines), README Configuration blurb. |
|
||||
| FED-M1-12 | done | PR, CI green, merge to main, close #460. | #460 | sonnet | feat/federation-m1-close | FED-M1-11 | 3K | M1 closed. PRs #470-#480 merged across 11 tasks. Issue #460 closed; release tag `fed-v0.1.0-m1` published. |
|
||||
|
||||
**M1 total estimate:** ~74K tokens (over-budget vs 20K PRD estimate — explanation below)
|
||||
|
||||
|
||||
@@ -5,28 +5,17 @@
|
||||
This checklist is an acceptance contract for documentation and examples. It does not authorize
|
||||
schema, runtime, systemd, role, profile, or live-fleet changes. An item is complete only when its
|
||||
named artifact exists, is linked from the fleet documentation entry point, and its evidence is
|
||||
recorded in the M0 task/PR.
|
||||
recorded in the M5 closure report and linked deferral evidence.
|
||||
|
||||
## M0 baseline acceptance
|
||||
|
||||
- [ ] `docs/PRD.md` states the roster as desired-state SSOT; generated environment, systemd,
|
||||
tmux, and heartbeat artifacts as non-authoritative projections; and fail-closed handling of
|
||||
unsupported or quarantined legacy input.
|
||||
- [ ] `docs/PRD.md` defines the required classes and authority boundary: `validator` certifies but
|
||||
does not merge; `merge-gate` remains sole approve-to-land/merge authority; `team-leader`
|
||||
capacity is lease-bounded; `interaction` is request/status only; instance names such as Tess
|
||||
and Ultron remain configurable.
|
||||
- [ ] `docs/PRD.md` defines local lifecycle semantics for `enabled`, persisted desired state, and
|
||||
observed state, including stopped-state preservation through migration, apply, and reboot.
|
||||
- [ ] `docs/PRD.md` defines the generated-env/local-override boundary, explicitly denies arbitrary
|
||||
command overrides in M1–M5, and requires key-name/hash-only quarantine diagnostics.
|
||||
- [ ] `docs/PRD.md` identifies the M1–M5 local-tmux scope and excludes remote reconciliation,
|
||||
connector mutation, secret references, arbitrary commands/channels, gateway convergence, and
|
||||
UI configuration storage.
|
||||
- [ ] `docs/TASKS.md` contains the complete M0–M5 one-card/one-PR dependency DAG for #758 with
|
||||
agent tier, branch, dependency, estimate, and evidence expectations.
|
||||
- [ ] `docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md` classifies every current shipped
|
||||
fleet example, profile, and service preset before M1 implementation starts.
|
||||
- [x] `docs/PRD.md` states the roster as desired-state SSOT; generated environment, systemd, tmux, and heartbeat artifacts as non-authoritative projections; and fail-closed handling of unsupported or quarantined legacy input.
|
||||
- [x] `docs/PRD.md` defines the required classes and authority boundary: `validator` certifies but does not merge; `merge-gate` remains sole approve-to-land/merge authority; `team-leader` capacity is lease-bounded; `interaction` is request/status only; instance names such as Tess and Ultron remain configurable.
|
||||
- [x] `docs/PRD.md` defines local lifecycle semantics for `enabled`, persisted desired state, and observed state, including stopped-state preservation through migration, apply, and reboot.
|
||||
- [x] `docs/PRD.md` defines the generated-env/local-override boundary, explicitly denies arbitrary command overrides in M1–M5, and requires key-name/hash-only quarantine diagnostics.
|
||||
- [x] `docs/PRD.md` identifies the M1–M5 local-tmux scope and excludes remote reconciliation, connector mutation, secret references, arbitrary commands/channels, gateway convergence, and UI configuration storage.
|
||||
- [x] `docs/TASKS.md` contains the complete M0–M5 one-card/one-PR dependency DAG for #758 with agent tier, branch, dependency, estimate, and evidence expectations.
|
||||
- [x] `docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md` classifies every current shipped fleet example, profile, and service preset before M1 implementation starts.
|
||||
|
||||
## Required documentation IA for M1–M5
|
||||
|
||||
@@ -72,15 +61,18 @@ recorded in the M0 task/PR.
|
||||
|
||||
## Cross-cutting evidence gates
|
||||
|
||||
- [ ] Every retained or migrated YAML/JSON example, profile, and service preset validates through the
|
||||
same executable schema and shared baseline-plus-`roles.local` resolver used by the CLI.
|
||||
- [ ] Every retired example/profile/service preset has a replacement link and deprecation note; no
|
||||
unresolved legacy class or tool-policy alias remains silently shipped.
|
||||
- [ ] Documentation examples contain no secret values, arbitrary command override, or product-hardcoded
|
||||
Tess/Ultron identity.
|
||||
- [ ] CLI snippets distinguish local fleet desired-state commands from the separate gateway-backed
|
||||
`mosaic agent` catalog.
|
||||
- [ ] Migration, quarantine, lifecycle, status, and troubleshooting documentation state that values of
|
||||
legacy sensitive keys are never printed.
|
||||
- [ ] M5 release review verifies links, schema/example validation, and that all checklist rows have
|
||||
owner/evidence or an explicit approved deferral.
|
||||
- [x] Every retained or migrated YAML/JSON example, profile, and service preset validates through the same declared executable production parser/resolver path recorded by the disposition inventory; versioned v1 fixtures are not forced through the v2 compiler.
|
||||
- [x] Every retired example/profile/service preset has a replacement link and deprecation note; no unresolved legacy class or tool-policy alias remains silently shipped.
|
||||
- [x] Documentation examples contain no secret values, arbitrary command override, or product-hardcoded Tess/Ultron identity.
|
||||
- [x] CLI snippets distinguish local fleet desired-state commands from the separate gateway-backed mosaic agent catalog.
|
||||
- [x] Migration, quarantine, lifecycle, status, and troubleshooting documentation state that values of legacy sensitive keys are never printed.
|
||||
- [x] M5 documentation validation verifies required IA paths, local file and heading-fragment links, the canonical roster through the production compiler/resolver, and fenced/canonical-example safety checks.
|
||||
- [ ] FCM-M5-001 does not deterministically assert owner/evidence/deferral metadata for every checklist row. Closure and deferral reports provide human-reviewable evidence only; broader assertion coverage remains unclaimed.
|
||||
|
||||
## Held downstream gates
|
||||
|
||||
These unchecked items are intentionally outside FCM-M5-001 and are not authorized by this checklist:
|
||||
|
||||
- [ ] FCM-M4-002 executes and evidences live cutover, canary, stopped-state preservation, and rollback.
|
||||
- [ ] FCM-M5-002 completes independent exact-head review and issues the validator certificate.
|
||||
- [ ] The exact PR head reaches terminal-green CI after independent review.
|
||||
|
||||
+16
-12
@@ -8,11 +8,11 @@ Generated environment files are rebuildable projections, not an operator-editabl
|
||||
| Layer | Responsibility |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. |
|
||||
| Projection writer | Renders deterministic `fleet/agents/<name>.env.generated` from the roster. |
|
||||
| Optional local data | Reads a strict, data-only `fleet/agents/<name>.env.local`; it cannot shadow generated keys. |
|
||||
| systemd | Starts the launcher with `env -i` and fixed bootstrap data. It does not preload either environment file. |
|
||||
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
|
||||
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
|
||||
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
|
||||
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
|
||||
| runtime launch | Derives the fixed `mosaic yolo <runtime>` argument array from validated roster data, then seeds the runtime contract. |
|
||||
| runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
|
||||
|
||||
The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
|
||||
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
|
||||
@@ -20,7 +20,7 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf
|
||||
|
||||
## Generated and local files
|
||||
|
||||
`<name>.env.generated` is complete, deterministic, and written only by Mosaic. Its ordered keys are:
|
||||
<name>.env.generated is complete, deterministic, and written only by Mosaic. Its ordered keys are:
|
||||
|
||||
```dotenv
|
||||
MOSAIC_AGENT_NAME=<roster name>
|
||||
@@ -33,12 +33,12 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||
```
|
||||
|
||||
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. `mosaic fleet add`
|
||||
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add
|
||||
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
|
||||
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
|
||||
it has no generated-launch adapter and cannot be added through this path.
|
||||
|
||||
`<name>.env.local` is optional and may contain only non-secret machine data:
|
||||
<name>.env.local is optional and may contain only non-secret machine data:
|
||||
|
||||
- `MOSAIC_RUNTIME_BIN`
|
||||
- `MOSAIC_HEARTBEAT_RUN_DIR`
|
||||
@@ -52,9 +52,9 @@ private, non-symlink paths. Violations fail closed before tmux interaction.
|
||||
|
||||
## Legacy input and diagnostics
|
||||
|
||||
A legacy `<name>.env` is input only during projection generation. Roster-owned keys are regenerated;
|
||||
A legacy <name>.env is input only during projection generation. Roster-owned keys are regenerated;
|
||||
valid allowed local data can move to `.env.local`; invalid legacy input is privately retained at
|
||||
`<name>.env.quarantine`. Neither legacy nor quarantine files are launch authority.
|
||||
<name>.env.quarantine. Neither legacy nor quarantine files are launch authority.
|
||||
|
||||
Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command
|
||||
text, credentials, or other values.
|
||||
@@ -62,12 +62,16 @@ text, credentials, or other values.
|
||||
## Launch and stop behavior
|
||||
|
||||
The launcher obtains the agent's socket only from the validated generated projection. It creates or
|
||||
checks the exact `=<agent-name>` tmux target; it never uses an ambient socket or fuzzy session match.
|
||||
checks the exact =<agent-name> tmux target; it never uses an ambient socket or fuzzy session match.
|
||||
The same strict parser runs before exact-stop behavior. A fresh native Pi heartbeat remains authoritative;
|
||||
the shell sidecar only provides fallback state when the native marker is stale or absent.
|
||||
|
||||
`mosaic fleet comms-block <role>` can inspect the role's resolved Fleet-Comms block. It is a read-only
|
||||
inspection tool and fails loudly for an unknown role or missing roster.
|
||||
mosaic agent comms-block <exact-member> can inspect that exact roster member's resolved Fleet-Comms
|
||||
block. It is a read-only inspection tool and fails loudly for an unknown exact member or missing roster.
|
||||
On Linux, the installed roster, TOOLS contract, and executable helper are opened through a held
|
||||
descriptor chain rooted at `/`; every managed path component uses no-follow traversal, and content plus
|
||||
execute validation stay bound to the same opened file. Systems without Linux `/proc/self/fd` support
|
||||
fail closed rather than falling back to pathname revalidation.
|
||||
|
||||
## Current M2 boundary
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user