Compare commits

..
Author SHA1 Message Date
Jason Woltje c0afff19c7 docs(ci): record final verification limits
ci/woodpecker/pr/ci Pipeline was successful
2026-08-10 15:42:42 -05:00
Jason Woltje 177406e2d1 docs(ci): capture next-lane merge blocker 2026-08-10 15:23:25 -05:00
Jason Woltje d405831ac5 fix(ci): allow reviewed merges into next 2026-08-10 15:22:54 -05:00
Jason Woltje e878dbd472 test(ci): cover next-lane PR merges 2026-08-10 15:22:25 -05:00
Jason Woltje f31813b19e docs(ci): record framework activation
ci/woodpecker/pr/ci Pipeline was successful
2026-08-10 15:08:55 -05:00
Jason Woltje bf380b3718 docs(ci): record queue guard verification 2026-08-10 14:41:39 -05:00
Jason Woltje ca24d8e70f fix(ci): separate push queue clearance from merge readiness 2026-08-10 14:36:01 -05:00
Jason Woltje ceb898ce2c test(ci): preserve GitHub merge readiness 2026-08-10 14:35:33 -05:00
Jason Woltje 865e67cd35 test(ci): define purpose-aware queue readiness 2026-08-10 14:34:22 -05:00
Jason Woltje b09c9b157b docs(ci): plan purpose-aware queue guard 2026-08-10 14:33:39 -05:00
Jason Woltje 03786d6803 docs(ci): design purpose-aware queue guard 2026-08-10 14:32:19 -05:00
Jason Woltje 00eb216480 docs(agents): consolidate project guidance 2026-08-10 14:27:59 -05:00
shaggy 8c27024d0e Merge pull request 'fix(gateway): gate FederationModule on tier === 'federated' (#1138)' (#1140) from fix/1138-conditional-federation into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 07:09:51 +00:00
shaggy (mosaic-dev box) 406e40584d test(gateway): raise module-graph import timeout for CI load robustness (#1138)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-10 01:34:24 -05:00
shaggy (mosaic-dev box) 677aeb0c93 fix(gateway): restore daemon config discovery (#1138)
ci/woodpecker/pr/ci Pipeline failed
2026-08-10 00:31:58 -05:00
shaggy (mosaic-dev box)andClaude Haiku 4.5 bd0ef2ab25 fix(gateway): anchor remaining config loads (#1138)
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-08-09 23:54:53 -05:00
shaggy (mosaic-dev box) 2d5a8c81ec fix(gateway): anchor config discovery and isolate env tests (#1138) 2026-08-09 23:11:09 -05:00
shaggy (mosaic-dev box) 884d527cc8 fix(gateway): anchor dotenv discovery to module (#1138) 2026-08-09 22:21:17 -05:00
shaggy 87daa12976 Merge pull request 'P2 — web SPA data layer + same-origin auth' (#1144) from feat/webui-p2-data-auth into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 01:52:01 +00:00
shaggy (mosaic-dev box) b82a51da80 fix(gateway): load dotenv before federation tier gate (#1138) 2026-08-09 20:50:37 -05:00
shaggy (mosaic-dev box) 90cf286a09 fix(web): reject protocol-relative auth callbacks
ci/woodpecker/pr/ci Pipeline was successful
2026-08-09 20:43:27 -05:00
shaggy (mosaic-dev box) 0aef432052 docs(scratchpad): record P2 remediation evidence 2026-08-09 20:21:53 -05:00
shaggy 41a16cc916 Merge pull request 'fix(docker): gateway image — git in runner, MOSAIC_ROOT workspace dir, scripts/ in builder, EXPOSE 14242' (#1142) from fix/gateway-runner-image into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 01:21:30 +00:00
shaggy (mosaic-dev box) e16c08aa9f test(web): align jsdom abort signals with Node 2026-08-09 20:20:30 -05:00
shaggy (mosaic-dev box) a34e92cf39 fix(gateway): harden workspace repository cloning
ci/woodpecker/pr/ci Pipeline was successful
2026-08-09 20:12:39 -05:00
shaggy (mosaic-dev box) a4861c221f docs(scratchpad): record WebUI P2 verification 2026-08-09 20:02:22 -05:00
shaggy (mosaic-dev box) 46d68e1ff4 feat(web): add same-origin SPA authentication 2026-08-09 20:00:22 -05:00
shaggy c3496334a5 Merge pull request 'feat(web): P1 — Vite + React Router skeleton beside Next (Phase P RFC, increment 1/6)' (#1143) from feat/webui-p1-vite-skeleton into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 00:51:02 +00:00
shaggy 6f29d00149 Merge pull request 'fix: break-C — install-hooks no-ops without git; web image builds @mosaicstack/web' (#1141) from fix/break-c-hooks-and-web-image into next
ci/woodpecker/push/publish Pipeline was canceled
2026-08-10 00:50:19 +00:00
shaggy (mosaic-dev box)andClaude Fable 5 068d0f9b1c feat(web): P1 Vite skeleton beside Next — entry, router, guards, vitest 3
ci/woodpecker/pr/ci Pipeline was successful
First increment of the approved Phase P RFC (webui-mission). Adds a Vite + React
Router SPA scaffold coexisting with the Next app: index.html with the theme
anti-flash script, src/main.tsx entry, the v1 parity route table under Guest/Auth
guard shells, and a dev proxy (/api, /socket.io ws) to the gateway on 14242 so the
SPA is same-origin in dev. vitest bumped to v3 (vite 8 pairing); existing specs
pass unchanged. Next remains the served app until the P5 cutover.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 19:02:55 -05:00
shaggy (mosaic-dev box)andClaude Fable 5 13cd673d50 fix(docker): gateway runner needs git + MOSAIC_ROOT workspace dir; EXPOSE actual port 14242
ci/woodpecker/pr/ci Pipeline was successful
WorkspaceService shells out to git at runtime and roots workspaces at
$MOSAIC_ROOT/.workspaces — the runner image had no git binary and no
workspace directory. EXPOSE said 4000 but main.ts defaults to 14242.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 18:03:42 -05:00
shaggy (mosaic-dev box)andClaude Fable 5 620cc608e0 fix(docker): copy scripts/ into web builder — prepare runs install-hooks.mjs on install
ci/woodpecker/pr/ci Pipeline was successful
The layer-cached install copies only manifests and packages/, so the
root prepare script could not be found and pnpm install exited 1
before the git-absent guard could even run.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 18:03:18 -05:00
shaggy (mosaic-dev box)andClaude Fable 5 91e692e3e7 fix: break-C — install-hooks no-ops without git; web image builds @mosaicstack/web
ci/woodpecker/pr/ci Pipeline was canceled
install-hooks.mjs hard-failed (exit 1) in environments without a git
binary — e.g. the docker image builds, which have no git and no repo.
Hook installation is meaningless there; skip with a warning instead.

docker/web.Dockerfile filtered @mosaic/web, but the package is named
@mosaicstack/web, so the image build compiled nothing.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 17:58:13 -05:00
shaggy (mosaic-dev box) b4753a75cd fix(gateway): gate FederationModule on tier federated (#1138)
ci/woodpecker/pr/ci Pipeline was successful
CaService hard-requires STEP_CA_URL/provisioner config at construction, so an
unconditional FederationModule import makes every standalone/local boot die at
DI time. Gate the module on loadConfig().tier === federated, matching the
documented intent of the federation compose profile (must not start in
non-federated dev).

Verified in mosaic-dev box: standalone tier boots to "Gateway listening on
port 14242" with bootstrap/socket.io/auth surfaces responding; federated tier
path unchanged.
2026-08-09 17:26:28 -05:00
velmaandmos-dt-0 24bbd40dc7 docs: WebUI fleet Claude bridge — Task 0 decision plan (#1131)
ci/woodpecker/push/publish Pipeline failed
Docs-only plan PR. FRED_APPROVED_REF=0629361ca39a4dd7fb3e575d11c64bea9e545dae (review 147). Merged by fred (orchestrator) via API: pr-merge.sh policy predates the next lane (main-only hardcode) — wrapper fix tracked separately.

Co-authored-by: Velma <[email protected]>
2026-08-09 10:28:41 +00:00
mos-dt-0 4df478cdd1 Merge pull request 'chore(sync): merge main → next (B1) — resolve 8 conflicts, restore next current' (#1041) from sync/b1-main-into-next into next
ci/woodpecker/push/publish Pipeline failed
chore(sync): merge main → next (B1) — restore next current, resolve 8 conflicts (#1041)

Brings next current with main incl the RM-03 guard fix (482/5); preserves next's 10 in-flight commits. Closes #1040.
2026-08-03 10:17:06 +00:00
coder-mos2 b8844e1ff0 fix(sync): close local queue semantic merge gap
ci/woodpecker/pr/ci Pipeline was successful
2026-08-02 23:16:09 -05:00
coder-mos2 906ad8dc30 wip(sync): merge main into next with combined resolutions 2026-08-02 23:11:22 -05:00
coder-mos1andmos-dt-0 5916aeefd6 chore(release): @mosaicstack/mosaic 0.0.49 — ship RM-03 guard fix to release channel (#1036)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline failed
Co-authored-by: coder-mos1 <[email protected]>
2026-08-02 20:09:11 +00:00
coder-mos1andmos-dt-0 58b971aba3 fix(rm-03): make CI queue guard fail on asserted non-readiness (#1032)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: coder-mos1 <[email protected]>
2026-08-01 18:55:26 +00:00
coder-mos1andmos-dt-0 f4fd5967fc RM-61: prove ci-postgres teardown discrimination (#1033)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: coder-mos1 <[email protected]>
2026-08-01 14:54:04 +00:00
mos-dt-0andMos f65e9ea656 docs(remediation): mission-state snapshot at the RM-01 seam (#1028)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: mos-dt-0 <[email protected]>
2026-08-01 01:08:11 +00:00
mos-dt-0andMos f58b3699a6 RM-01: reproducible checkout — the pre-push gate fails on code, not environment (#1027)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: mos-dt-0 <[email protected]>
2026-08-01 00:50:12 +00:00
jason.woltje 193331544d fix(wizard): honor MOSAIC_GATEWAY_SKIP_NPM_INSTALL — unblock install.sh --dev gateway testing (#698)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-10 01:30:10 +00:00
jason.woltje 495f73bfdb fix(wizard): avoid rerunning completed setup steps (#692)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 18:44:35 +00:00
jason.woltje b96cc7982a fix(wizard): report gateway failures before success summary (#691)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 18:14:40 +00:00
jason.woltje 0883fb91ec fix(wizard): resolve skills sync script path (#690)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 17:35:19 +00:00
jason.woltje 56787fabf1 fix(gateway): disable Redis consumers on local tier (#689)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 17:17:24 +00:00
jason.woltjeandClaude Opus 4.8 940ae3cc41 feat(installer): prefer npm next lane (#688)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
--next now prefers a fast npm @next install (CLI + gateway from the Gitea registry) and falls back to source build at next if the dist-tag is unavailable. Registry lane gated to non-dev, non-explicit-ref next installs; CLI/gateway prerelease versions must share a pipeline suffix. Adds tools/install-next-lane.test.sh (wired into CI). PR-event CI 1635 fully green + review-of-record APPROVE (functional install test, head 2fd7cfc3).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 07:14:24 +00:00
jason.woltjeandClaude Opus 4.8 c25a551c28 ci(#462): add durable next publish pipeline (#687)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Durable @next integration-line publish: on next pushes, compute <patch+1>-next.<pipeline#> prerelease versions (in-CI, uncommitted) and publish @mosaicstack/* under the next dist-tag; gateway image sha-only on next. Strict guardrails: next-only, never writes latest, never tags from next; main path unchanged. PR-event CI 1631 fully green + review-of-record APPROVE (head b1a887a2). Guardrails independently verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 05:45:09 +00:00
jason.woltjeandClaude Opus 4.8 94d6538061 feat(installer): add next integration lane (#686)
ci/woodpecker/push/ci Pipeline was successful
Add --next installer flag (build-from-source at the next integration branch; MOSAIC_NEXT=1 env equiv; explicit --ref wins). Three-lane install docs (stable @latest / --next prerelease / --dev source) + @next dist-tag pipeline design doc. Green PR-event CI 1626 + review-of-record APPROVE (head 3a5c12a5).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 05:14:32 +00:00
jason.woltjeandClaude Opus 4.8 a3c1ab923c test(#462): add federation M3 integration coverage (#685)
ci/woodpecker/push/ci Pipeline was successful
FED-M3-10 integration tests for the federation M3 verbs (list/get/scope). Test-infra + docs only; green PR-event CI 1623 (all steps incl ci-postgres).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 04:14:56 +00:00
jason.woltjeandClaude Opus 4.8 838701bde2 feat(#462): add federation get verb (#683)
ci/woodpecker/push/ci Pipeline was successful
FED-M3-06 get verb. Trust boundary mirrors M3-05 AND-intersect (note returned only when owned by subject AND on an authorized mission). Reviewed (review-of-record APPROVE, head 80a259b2) + green PR-event CI 1620.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 03:44:54 +00:00
148 changed files with 11652 additions and 3501 deletions
+1
View File
@@ -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
View File
@@ -1 +1 @@
pnpm typecheck && pnpm lint && pnpm format:check
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
+4 -4
View File
@@ -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
+104 -5
View File
@@ -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
+70 -31
View File
@@ -11,48 +11,87 @@
## Project Context
Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo with NestJS gateway, Next.js web dashboard, Pi SDK agent runtime, and plugin architecture for Discord/Telegram.
Mosaic Stack is a self-hosted, multi-user AI agent platform. It is a TypeScript monorepo with a NestJS gateway, Next.js dashboard, Pi SDK agent runtime, and Discord/Telegram plugin architecture.
## Package Map
### Stack
| Package | Purpose | Key Dependencies |
| ------------------ | ------------------------------- | -------------------------------- |
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
| `apps/web` | Next.js dashboard | React 19, Tailwind |
| `packages/types` | Shared TypeScript contracts | class-validator |
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
| `packages/queue` | Valkey task queue + MCP | ioredis |
| `packages/coord` | Mission coordination | @mosaicstack/queue |
| `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander |
| `plugins/discord` | Discord channel plugin | discord.js |
| `plugins/telegram` | Telegram channel plugin | Telegraf |
- **API:** NestJS with Fastify (`apps/gateway`)
- **Web:** Next.js 16 with React 19 (`apps/web`)
- **ORM and database:** Drizzle ORM, PostgreSQL 17, and pgvector (`packages/db`)
- **Authentication:** BetterAuth (`packages/auth`)
- **Agent runtime:** Pi SDK (`apps/gateway`, `packages/mosaic`)
- **Queue:** Valkey 8 (`packages/queue`)
- **Build:** pnpm workspaces and Turborepo
- **CI:** Woodpecker CI
- **Observability:** OpenTelemetry and Jaeger
## Architecture Rules
### Package Map
1. Gateway is the single API surface — all clients connect through it
2. Pi SDK is ESM-only — gateway and CLI must use ESM
3. Socket.IO typed events defined in `@mosaicstack/types` enforce compile-time contracts
4. OTEL auto-instrumentation loads before NestJS bootstrap
5. BetterAuth manages auth tables; schema defined in `@mosaicstack/db`
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
| Package | Purpose | Key Dependencies |
| ------------------ | ----------------------------- | -------------------------------- |
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
| `apps/web` | Next.js dashboard | React 19, Tailwind |
| `packages/types` | Shared TypeScript contracts | class-validator |
| `packages/db` | Drizzle schema and migrations | drizzle-orm, postgres |
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
| `packages/brain` | Structured data layer | @mosaicstack/db |
| `packages/queue` | Valkey task queue and MCP | ioredis |
| `packages/coord` | Mission coordination | @mosaicstack/queue |
| `packages/mosaic` | Unified `mosaic` CLI and TUI | Ink, Pi SDK, commander |
| `plugins/discord` | Discord channel plugin | discord.js |
| `plugins/telegram` | Telegram channel plugin | Telegraf |
## Architecture and Code Conventions
1. Gateway is the single API surface; all clients connect through it.
2. Pi SDK is ESM-only; gateway and CLI code must remain ESM.
3. Use `"type": "module"`, NodeNext module resolution, and `.js` extensions in imports.
4. Keep typed Socket.IO events in `@mosaicstack/types` to enforce client/server contracts.
5. Import OTEL tracing before NestJS bootstrap (`import './tracing.js'`).
6. Use explicit `@Inject()` decorators in NestJS because tsx/esbuild does not emit decorator metadata.
7. Keep DTOs in `*.dto.ts` files at module boundaries.
8. BetterAuth owns authentication tables; their schema is defined in `@mosaicstack/db`.
9. Create a task-specific scratchpad for non-trivial work.
## Development Workflow
Requirements: Node.js 20+, pnpm 10.6.2, and Docker Compose when optional local services are needed.
```bash
docker compose up -d # Infrastructure
pnpm install # Dependencies
pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
pnpm install --frozen-lockfile
pnpm preflight
# Optional local queue service only; do not start the full Compose stack.
docker compose up -d valkey
```
## Repo-Specific Notes
The pre-push hook requires:
- DTOs in `*.dto.ts` files at module boundaries
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
- NodeNext module resolution in all tsconfigs
- Scratchpads are mandatory for non-trivial tasks
```bash
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
```
Software delivery also requires the applicable tests. Common repository commands are:
```bash
pnpm typecheck # TypeScript checks across the workspace
pnpm lint # ESLint across the workspace
pnpm test # Checkout tests and package Vitest suites
pnpm format:check # Prettier check
pnpm build # Build all packages and applications
```
## Database and Local Runtime Safety
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
- PostgreSQL execution is held until KBN-101-00, KBN-101-03, and KBN-101-05 land.
- Do not invoke a migration runner, initialization SQL, or the Compose PostgreSQL service from this checkout.
- Do not start Gateway/Web or run root `pnpm dev` as a local PGlite route. The current dotenv loader can inherit a daemon PostgreSQL DSN; KBN-101-02 must make that path fail closed first.
- Migration artifact generation is offline and does not authorize PostgreSQL access:
```bash
pnpm --filter @mosaicstack/db db:generate
```
## docs/TASKS.md — Schema (CANONICAL)
+3 -44
View File
@@ -1,46 +1,5 @@
# CLAUDE.md — Mosaic Stack
# Claude Compatibility Pointer
## Project
@AGENTS.md
Self-hosted, multi-user AI agent platform. TypeScript monorepo.
## Stack
- **API**: NestJS + Fastify adapter (`apps/gateway`)
- **Web**: Next.js 16 + React 19 (`apps/web`)
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
- **Auth**: BetterAuth (`packages/auth`)
- **Agent**: Pi SDK (`packages/agent`, `packages/mosaic`)
- **Queue**: Valkey 8 (`packages/queue`)
- **Build**: pnpm workspaces + Turborepo
- **CI**: Woodpecker CI
- **Observability**: OpenTelemetry → Jaeger
## Commands
```bash
pnpm typecheck # TypeScript check (all packages)
pnpm lint # ESLint (all packages)
pnpm format:check # Prettier check
pnpm test # Vitest (all packages)
pnpm build # Build all packages
# Database
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: 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
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
- NodeNext module resolution
- Explicit `@Inject()` decorators in NestJS (tsx/esbuild doesn't support emitDecoratorMetadata)
- DTOs in `*.dto.ts` files at module boundaries
- OTEL tracing imported before NestJS bootstrap (`import './tracing.js'`)
- All three gates must pass before push: typecheck, lint, format:check
Do not add project guidance here. Keep `AGENTS.md` authoritative so every agent runtime receives the same instructions.
+27 -1
View File
@@ -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
@@ -201,8 +211,21 @@ git clone [email protected]:mosaicstack/stack.git
cd stack
# 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
# 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
# Optional local queue service only. This does not start PostgreSQL.
docker compose up -d valkey
@@ -230,6 +253,7 @@ 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)
@@ -347,7 +371,9 @@ 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
```
@@ -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 {
+624
View File
@@ -0,0 +1,624 @@
import 'reflect-metadata';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import * as nodeOs from 'node:os';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import * as nodeUrl from 'node:url';
import { MODULE_METADATA } from '@nestjs/common/constants.js';
import { describe, expect, it, vi } from 'vitest';
import type { MosaicConfig } from '@mosaicstack/config';
interface ComposedModuleGraph {
imports: readonly unknown[];
federationModule: unknown;
bootLogLines: readonly string[];
mosaicConfig: MosaicConfig;
resolvedConfigPath: string;
}
type StorageTier = 'local' | 'standalone' | 'federated';
interface ModuleGraphFixture {
tempRoot: string;
anchor: string;
homePath: string;
cwdPath: string;
monorepoRootEnvPath: string;
gatewayLocalEnvPath: string;
daemonEnvPath: string;
monorepoRootConfigPath: string;
gatewayLocalConfigPath: string;
}
interface ModuleGraphFixtureOptions {
rootEnvMode?: 'present' | 'absent';
rootTier?: StorageTier;
rootEnvContents?: string;
redactionMarker?: string;
gatewayLocalTier?: StorageTier;
gatewayLocalEnvContents?: string;
daemonEnvContents?: string;
inheritedTier?: StorageTier;
expectedProcessTier?: string;
setup?: (fixture: ModuleGraphFixture) => Promise<void>;
}
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
const MONOREPO_ROOT_DOTENV_LABEL = 'monorepo-root .env';
const DAEMON_DOTENV_LABEL = 'daemon .env';
function configJson(tier: StorageTier): string {
if (tier === 'local') {
return JSON.stringify({
tier,
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
queue: { type: 'local', dataDir: '.mosaic/queue' },
memory: { type: 'keyword' },
});
}
return JSON.stringify({
tier,
storage: { type: 'postgres', url: 'postgresql://fixture.invalid/mosaic' },
queue: { type: 'bullmq' },
memory: { type: tier === 'federated' ? 'pgvector' : 'keyword' },
});
}
function snapshotProcessEnv(): Record<string, string | undefined> {
return { ...process.env };
}
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
for (const key of Object.keys(process.env)) {
if (!(key in snapshot)) {
delete process.env[key];
}
}
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) {
delete process.env[key];
continue;
}
process.env[key] = value;
}
}
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
const relativePath = relative(tempRoot, path);
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
true,
);
}
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
expectPathUnderTempRoot(path, tempRoot);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, contents, 'utf8');
}
interface ConfigModuleProvider {
provide: string;
useFactory: () => MosaicConfig;
}
function isConfigModuleProvider(value: unknown): value is ConfigModuleProvider {
if (typeof value !== 'object' || value === null) {
return false;
}
if (!('provide' in value) || typeof value.provide !== 'string') {
return false;
}
return 'useFactory' in value && typeof value.useFactory === 'function';
}
function singleBootLogLine(bootLogLines: readonly string[]): string {
expect(bootLogLines).toHaveLength(1);
const [bootLogLine] = bootLogLines;
if (bootLogLine === undefined) {
throw new Error('Expected a single boot log line');
}
return bootLogLine;
}
function expectBootLogLine(
bootLogLines: readonly string[],
tier: StorageTier,
source: string,
): void {
const bootLogLine = singleBootLogLine(bootLogLines);
expect(bootLogLine).toContain(`storage tier=${tier}`);
expect(bootLogLine).toContain(`source=${source}`);
}
async function loadModuleGraphFromDotenv(
options: ModuleGraphFixtureOptions,
): Promise<ComposedModuleGraph> {
const originalEnv = snapshotProcessEnv();
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-module-'));
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
try {
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
const homePath = join(tempRoot, 'home');
const cwdPath = join(tempRoot, 'ambient', 'parent', 'cwd');
const fixture: ModuleGraphFixture = {
tempRoot,
anchor,
homePath,
cwdPath,
monorepoRootEnvPath: resolve(anchor, '../../..', '.env'),
gatewayLocalEnvPath: resolve(anchor, '..', '.env'),
daemonEnvPath: join(homePath, '.config', 'mosaic', 'gateway', '.env'),
monorepoRootConfigPath: resolve(anchor, '../../..', 'mosaic.config.json'),
gatewayLocalConfigPath: resolve(anchor, '..', 'mosaic.config.json'),
};
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
for (const path of Object.values(fixture)) {
expectPathUnderTempRoot(path, tempRoot);
}
await mkdir(anchor, { recursive: true });
await mkdir(cwdPath, { recursive: true });
if ((options.rootEnvMode ?? 'present') === 'absent') {
if (
options.rootEnvContents !== undefined ||
options.rootTier !== undefined ||
options.redactionMarker !== undefined
) {
throw new Error('Expected no root env fixture values when rootEnvMode is absent');
}
} else {
if (options.rootEnvContents === undefined && options.rootTier === undefined) {
throw new Error('Expected rootTier or rootEnvContents');
}
const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`;
const rootFixtureWithMarker = options.redactionMarker
? `${rootFixture}BETTER_AUTH_SECRET=${options.redactionMarker}\n`
: rootFixture;
await writeFixture(fixture.monorepoRootEnvPath, rootFixtureWithMarker, tempRoot);
}
if (options.daemonEnvContents !== undefined) {
await writeFixture(fixture.daemonEnvPath, options.daemonEnvContents, tempRoot);
}
if (options.gatewayLocalEnvContents !== undefined) {
await writeFixture(fixture.gatewayLocalEnvPath, options.gatewayLocalEnvContents, tempRoot);
} else if (options.gatewayLocalTier !== undefined) {
await writeFixture(
fixture.gatewayLocalEnvPath,
`MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`,
tempRoot,
);
}
process.env['HOME'] = homePath;
delete process.env['MOSAIC_STORAGE_TIER'];
delete process.env['DATABASE_URL'];
delete process.env['VALKEY_URL'];
delete process.env['MOSAIC_GATEWAY_HOME'];
await options.setup?.(fixture);
if (options.inheritedTier !== undefined) {
process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier;
}
vi.resetModules();
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
vi.doMock('node:url', () => ({
...nodeUrl,
fileURLToPath: (url: string | URL): string => {
const actualPath = nodeUrl.fileURLToPath(url);
if (
actualPath.endsWith('/apps/gateway/src/env.ts') ||
actualPath.endsWith('/apps/gateway/src/env.js')
) {
return join(anchor, 'env.ts');
}
return actualPath;
},
}));
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
if (options.inheritedTier === undefined) {
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
} else {
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier);
}
const envModule = await import('./env.js');
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(
options.expectedProcessTier ?? options.rootTier,
);
const { AppModule } = await import('./app.module.js');
const { FederationModule } = await import('./federation/federation.module.js');
const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule);
if (!Array.isArray(imports)) {
throw new Error('AppModule imports metadata is not an array');
}
const { ConfigModule, MOSAIC_CONFIG } = await import('./config/config.module.js');
const providers: unknown = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, ConfigModule);
if (!Array.isArray(providers)) {
throw new Error('ConfigModule providers metadata is not an array');
}
const configProvider = providers
.filter(isConfigModuleProvider)
.find((provider: ConfigModuleProvider): boolean => provider.provide === MOSAIC_CONFIG);
if (!configProvider) {
throw new Error('MOSAIC_CONFIG provider factory not found');
}
return {
imports,
federationModule: FederationModule,
bootLogLines: consoleInfoSpy.mock.calls.map((args: readonly unknown[]): string =>
args.map((value: unknown): string => String(value)).join(' '),
),
mosaicConfig: configProvider.useFactory(),
resolvedConfigPath: envModule.resolveGatewayConfigPath(),
};
} finally {
cwdSpy?.mockRestore();
vi.doUnmock('node:url');
vi.doUnmock('node:os');
vi.resetModules();
consoleInfoSpy?.mockRestore();
restoreProcessEnv(originalEnv);
await rm(tempRoot, { recursive: true, force: true });
}
}
describe('AppModule federation gating', (): void => {
it('loads dotenv before tracing and AppModule evaluation', async (): Promise<void> => {
const mainSource = await readFile(new URL('./main.ts', import.meta.url), 'utf8');
const envImportIndex = mainSource.indexOf("import './env.js';");
const tracingImportIndex = mainSource.indexOf("import './tracing.js';");
const appModuleImportIndex = mainSource.indexOf("import { AppModule } from './app.module.js';");
expect(envImportIndex).toBeGreaterThan(-1);
expect(envImportIndex).toBeLessThan(tracingImportIndex);
expect(envImportIndex).toBeLessThan(appModuleImportIndex);
});
it(
'ignores ambient cwd/.env and cwd/../.env files',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
await writeFixture(
join(fixture.cwdPath, '.env'),
'MOSAIC_STORAGE_TIER=federated\n',
fixture.tempRoot,
);
await writeFixture(
resolve(fixture.cwdPath, '..', '.env'),
'MOSAIC_STORAGE_TIER=federated\n',
fixture.tempRoot,
);
},
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'ignores an ambient cwd/mosaic.config.json federated config',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
await writeFixture(
join(fixture.cwdPath, 'mosaic.config.json'),
configJson('federated'),
fixture.tempRoot,
);
},
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'ignores an ambient cwd/../../mosaic.config.json federated config',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
await writeFixture(
resolve(fixture.cwdPath, '../..', 'mosaic.config.json'),
configJson('federated'),
fixture.tempRoot,
);
},
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'anchored gateway-local config wins monorepo-root config and registers FederationModule',
async (): Promise<void> => {
let gatewayLocalConfigPath = '';
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
gatewayLocalConfigPath = fixture.gatewayLocalConfigPath;
await writeFixture(
fixture.gatewayLocalConfigPath,
configJson('federated'),
fixture.tempRoot,
);
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
},
});
expect(graph.resolvedConfigPath).toBe(gatewayLocalConfigPath);
expect(graph.mosaicConfig.tier).toBe('federated');
expect(graph.imports).toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'resolves the daemon-installed GATEWAY_HOME/mosaic.config.json ahead of gateway-local and monorepo-root configs',
async (): Promise<void> => {
let daemonConfigPath = '';
const graph = await loadModuleGraphFromDotenv({
rootEnvMode: 'absent',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
const externalGatewayHome = join(fixture.tempRoot, 'external-gateway-home');
daemonConfigPath = join(externalGatewayHome, 'mosaic.config.json');
await writeFixture(daemonConfigPath, configJson('federated'), fixture.tempRoot);
await writeFixture(
fixture.gatewayLocalConfigPath,
configJson('standalone'),
fixture.tempRoot,
);
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
process.env['MOSAIC_GATEWAY_HOME'] = externalGatewayHome;
process.env['DATABASE_URL'] = 'postgresql://fixture.invalid/mosaic';
},
});
expect(graph.resolvedConfigPath).toBe(daemonConfigPath);
expect(graph.mosaicConfig.tier).toBe('federated');
expect(graph.imports).toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'logs mosaic.config.json when anchored config and env tiers are both federated',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'federated',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
await writeFixture(
fixture.monorepoRootConfigPath,
configJson('federated'),
fixture.tempRoot,
);
},
});
expect(graph.imports).toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'logs standalone from a monorepo-root .env DATABASE_URL fallback',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootEnvContents: 'DATABASE_URL=fixture-database-url\n',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'attributes an invalid monorepo-root dotenv tier to the default',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
expectedProcessTier: 'invalid',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'local', 'default');
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'attributes DATABASE_URL fallback to daemon .env ahead of inherited local tier',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootEnvMode: 'absent',
daemonEnvContents: 'DATABASE_URL=fixture-database-url\n',
inheritedTier: 'local',
expectedProcessTier: 'local',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'daemon .env wins over monorepo-root and gateway-local tier values',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
gatewayLocalTier: 'federated',
daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n',
expectedProcessTier: 'standalone',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
gatewayLocalTier: 'federated',
daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n',
inheritedTier: 'standalone',
expectedProcessTier: 'standalone',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment');
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'gateway-local .env configures the tier and source when the monorepo-root .env is absent',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootEnvMode: 'absent',
gatewayLocalTier: 'federated',
expectedProcessTier: 'federated',
});
expect(graph.imports).toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env');
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'monorepo-root .env wins over gateway-local tier values',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'standalone',
gatewayLocalTier: 'federated',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it.each(['local', 'standalone'] as const)(
'does not register FederationModule for the %s tier',
async (tier): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({ rootTier: tier });
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'registers FederationModule when federated tier is supplied by the anchored monorepo root .env',
async (): Promise<void> => {
const redactionMarker = 'redaction-fixture-marker';
const graph = await loadModuleGraphFromDotenv({
rootTier: 'federated',
redactionMarker,
});
expect(graph.imports).toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL);
expect(singleBootLogLine(graph.bootLogLines)).not.toContain(redactionMarker);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'MOSAIC_CONFIG provider ignores an ambient cwd/mosaic.config.json config',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
await writeFixture(
join(fixture.cwdPath, 'mosaic.config.json'),
JSON.stringify({
tier: 'federated',
storage: {
type: 'postgres',
url: 'postgresql://ambient-attacker.invalid/mosaic',
enableVector: true,
},
queue: { type: 'bullmq' },
memory: { type: 'pgvector' },
}),
fixture.tempRoot,
);
},
});
expect(graph.mosaicConfig.tier).toBe('local');
expect(graph.mosaicConfig.storage).not.toEqual(
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'MOSAIC_CONFIG provider resolves from the anchored monorepo-root mosaic.config.json',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
await writeFixture(
fixture.monorepoRootConfigPath,
configJson('federated'),
fixture.tempRoot,
);
},
});
expect(graph.mosaicConfig.tier).toBe('federated');
expect(graph.mosaicConfig.storage).toEqual(
expect.objectContaining({ url: 'postgresql://fixture.invalid/mosaic' }),
);
},
MODULE_IMPORT_TIMEOUT_MS,
);
});
+11 -1
View File
@@ -26,6 +26,16 @@ import { WorkspaceModule } from './workspace/workspace.module.js';
import { QueueModule } from './queue/queue.module.js';
import { FederationModule } from './federation/federation.module.js';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { loadConfig } from '@mosaicstack/config';
import { resolveGatewayConfigPath } from './env.js';
// Federation (step-ca client, enrollment, federation verbs) is only wired for
// tier 'federated' — CaService hard-requires STEP_CA_* at construction, which
// must not gate standalone/local boots (docker-compose.federated.yml: the
// federation profile "must not start in non-federated dev"). The gateway
// entrypoint loads env.ts before evaluating this module so dotenv-backed tier
// configuration is visible here.
const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'federated';
@Module({
imports: [
@@ -53,7 +63,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
QueueModule,
ReloadModule,
WorkspaceModule,
FederationModule,
...(federationEnabled ? [FederationModule] : []),
],
controllers: [HealthController],
providers: [
@@ -72,13 +72,13 @@ const mockChatGateway = {
broadcastSessionInfo: vi.fn(),
};
function buildService(): CommandExecutorService {
function buildService(redis: typeof mockRedis | null = mockRedis): 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,
@@ -131,6 +131,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 };
@@ -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))
@@ -443,14 +446,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,
+15 -5
View File
@@ -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(() => {});
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { Global, Module } from '@nestjs/common';
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
import { resolveGatewayConfigPath } from '../env.js';
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
@@ -8,7 +9,7 @@ export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
providers: [
{
provide: MOSAIC_CONFIG,
useFactory: (): MosaicConfig => loadConfig(),
useFactory: (): MosaicConfig => loadConfig(resolveGatewayConfigPath()),
},
],
exports: [MOSAIC_CONFIG],
+133
View File
@@ -0,0 +1,133 @@
import { config } from 'dotenv';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { detectFromEnv, loadConfig } from '@mosaicstack/config';
type TierSource =
| 'process environment'
| 'daemon .env'
| 'monorepo-root .env'
| 'gateway-local .env'
| 'default';
type BootSource = TierSource | 'mosaic.config.json';
export interface GatewayDotenvPaths {
daemonEnv: string;
monorepoRootEnv: string;
gatewayLocalEnv: string;
}
const here = dirname(fileURLToPath(import.meta.url));
export function resolveGatewayDotenvPaths(
anchor: string = here,
homeBase: string = homedir(),
): GatewayDotenvPaths {
return {
daemonEnv: join(homeBase, '.config', 'mosaic', 'gateway', '.env'),
monorepoRootEnv: resolve(anchor, '../../..', '.env'),
gatewayLocalEnv: resolve(anchor, '..', '.env'),
};
}
export function resolveGatewayConfigPath(anchor: string = here): string {
// GATEWAY_HOME is daemon-created 0700; its env override adds no authority because env can set MOSAIC_STORAGE_TIER.
const gatewayHome = resolve(
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
);
const daemonConfig = join(gatewayHome, 'mosaic.config.json');
const gatewayLocalConfig = resolve(anchor, '..', 'mosaic.config.json');
const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json');
if (existsSync(daemonConfig)) {
return daemonConfig;
}
if (existsSync(gatewayLocalConfig)) {
return gatewayLocalConfig;
}
if (existsSync(monorepoRootConfig)) {
return monorepoRootConfig;
}
return monorepoRootConfig;
}
export function loadGatewayEnv(anchor: string = here, homeBase: string = homedir()): void {
const { daemonEnv, monorepoRootEnv, gatewayLocalEnv } = resolveGatewayDotenvPaths(
anchor,
homeBase,
);
const inheritedTier = process.env['MOSAIC_STORAGE_TIER'];
let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment';
const inheritedDatabaseUrl = process.env['DATABASE_URL'];
let databaseUrlSource: TierSource =
inheritedDatabaseUrl === undefined ? 'default' : 'process environment';
function loadAnchoredDotenv(
path: string,
sourceLabel: Exclude<TierSource, 'process environment' | 'default'>,
): void {
if (!existsSync(path)) {
return;
}
const beforeTier = process.env['MOSAIC_STORAGE_TIER'];
const beforeDatabaseUrl = process.env['DATABASE_URL'];
config({ path, quiet: true });
if (
beforeTier === undefined &&
process.env['MOSAIC_STORAGE_TIER'] !== undefined &&
tierSource === 'default'
) {
tierSource = sourceLabel;
}
if (
beforeDatabaseUrl === undefined &&
process.env['DATABASE_URL'] !== undefined &&
databaseUrlSource === 'default'
) {
databaseUrlSource = sourceLabel;
}
}
// Load .env from daemon config dir (global install / daemon mode) first.
// It takes precedence over file-based local-dev configuration.
loadAnchoredDotenv(daemonEnv, 'daemon .env');
// Load .env from the anchored monorepo root, then fill any remaining values
// from apps/gateway/.env when present.
loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env');
loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env');
const envOnlyTier = detectFromEnv().tier;
const configPath = resolveGatewayConfigPath(anchor);
const anchoredConfigExists = existsSync(configPath);
const resolvedTier = loadConfig(configPath).tier;
const configuredTier = process.env['MOSAIC_STORAGE_TIER'];
const databaseUrlDeterminesTier = envOnlyTier === 'standalone' && configuredTier !== 'standalone';
const recognizedTierDeterminesTier =
(configuredTier === 'federated' ||
configuredTier === 'standalone' ||
configuredTier === 'local') &&
configuredTier === envOnlyTier;
let source: BootSource;
if (anchoredConfigExists) {
source = 'mosaic.config.json';
} else if (databaseUrlDeterminesTier && databaseUrlSource !== 'default') {
source = databaseUrlSource;
} else if (recognizedTierDeterminesTier && tierSource !== 'default') {
source = tierSource;
} else {
source = 'default';
}
console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`);
}
loadGatewayEnv();
@@ -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 } };
}
}
+15 -5
View File
@@ -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');
+15 -8
View File
@@ -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.
+9 -3
View File
@@ -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.
+164
View File
@@ -0,0 +1,164 @@
import 'reflect-metadata';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import * as nodeOs from 'node:os';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import * as nodeUrl from 'node:url';
import type { MosaicConfig } from '@mosaicstack/config';
import type * as MosaicStorage from '@mosaicstack/storage';
import { describe, expect, it, vi, type MockInstance } from 'vitest';
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
function snapshotProcessEnv(): Record<string, string | undefined> {
return { ...process.env };
}
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
for (const key of Object.keys(process.env)) {
if (!(key in snapshot)) {
delete process.env[key];
}
}
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) {
delete process.env[key];
continue;
}
process.env[key] = value;
}
}
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
const relativePath = relative(tempRoot, path);
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
true,
);
}
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
expectPathUnderTempRoot(path, tempRoot);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, contents, 'utf8');
}
interface BootstrapPreflightResult {
capturedConfig: MosaicConfig | undefined;
}
async function runBootstrapPreflight(
anchoredConfigContents: string,
ambientConfigContents: string,
): Promise<BootstrapPreflightResult> {
const originalEnv = snapshotProcessEnv();
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-main-preflight-'));
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
let exitSpy: MockInstance<typeof process.exit> | undefined;
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
let capturedConfig: MosaicConfig | undefined;
try {
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
const homePath = join(tempRoot, 'home');
const cwdPath = join(tempRoot, 'ambient', 'cwd');
const monorepoRootConfigPath = resolve(anchor, '../../..', 'mosaic.config.json');
await mkdir(anchor, { recursive: true });
await mkdir(cwdPath, { recursive: true });
await writeFixture(monorepoRootConfigPath, anchoredConfigContents, tempRoot);
await writeFixture(join(cwdPath, 'mosaic.config.json'), ambientConfigContents, tempRoot);
process.env['HOME'] = homePath;
process.env['BETTER_AUTH_SECRET'] = 'fixture-secret';
delete process.env['MOSAIC_STORAGE_TIER'];
delete process.env['DATABASE_URL'];
delete process.env['VALKEY_URL'];
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
const exitMock = vi.fn<typeof process.exit>();
exitSpy = vi.spyOn(process, 'exit').mockImplementation(exitMock);
vi.resetModules();
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
vi.doMock('node:url', () => ({
...nodeUrl,
fileURLToPath: (url: string | URL): string => {
const actualPath = nodeUrl.fileURLToPath(url);
if (
actualPath.endsWith('/apps/gateway/src/env.ts') ||
actualPath.endsWith('/apps/gateway/src/env.js')
) {
return join(anchor, 'env.ts');
}
return actualPath;
},
}));
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
vi.doMock('./tracing.js', () => ({}));
const preflightSentinel = new Error('preflight-capture-sentinel');
vi.doMock('@mosaicstack/storage', async () => {
const actual = await vi.importActual<typeof MosaicStorage>('@mosaicstack/storage');
return {
...actual,
detectAndAssertTier: vi.fn((config: MosaicConfig): Promise<void> => {
capturedConfig = config;
throw preflightSentinel;
}),
};
});
await import('./main.js');
await vi.waitFor((): void => {
expect(exitSpy).toHaveBeenCalled();
});
return { capturedConfig };
} finally {
cwdSpy?.mockRestore();
exitSpy?.mockRestore();
consoleInfoSpy?.mockRestore();
vi.doUnmock('@mosaicstack/storage');
vi.doUnmock('./tracing.js');
vi.doUnmock('node:url');
vi.doUnmock('node:os');
vi.resetModules();
restoreProcessEnv(originalEnv);
await rm(tempRoot, { recursive: true, force: true });
}
}
describe('main bootstrap preflight config anchoring', (): void => {
it(
'passes the anchored monorepo-root config to detectAndAssertTier, not an ambient cwd config',
async (): Promise<void> => {
const anchoredConfig = JSON.stringify({
tier: 'local',
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
queue: { type: 'local', dataDir: '.mosaic/queue' },
memory: { type: 'keyword' },
});
const ambientConfig = JSON.stringify({
tier: 'federated',
storage: {
type: 'postgres',
url: 'postgresql://ambient-attacker.invalid/mosaic',
enableVector: true,
},
queue: { type: 'bullmq' },
memory: { type: 'pgvector' },
});
const { capturedConfig } = await runBootstrapPreflight(anchoredConfig, ambientConfig);
expect(capturedConfig?.tier).toBe('local');
expect(capturedConfig?.storage).not.toEqual(
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
);
},
MODULE_IMPORT_TIMEOUT_MS,
);
});
+3 -15
View File
@@ -1,18 +1,5 @@
#!/usr/bin/env node
import { config } from 'dotenv';
import { existsSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { homedir } from 'node:os';
// Load .env from daemon config dir (global install / daemon mode).
// Loaded first so monorepo .env can override for local dev.
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
if (existsSync(daemonEnv)) config({ path: daemonEnv });
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
config({ path: resolve(process.cwd(), '../../.env') });
config(); // Also load apps/gateway/.env if present (overrides)
import './env.js';
import './tracing.js';
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
@@ -26,6 +13,7 @@ import { mountAuthHandler } from './auth/auth.controller.js';
import { mountMcpHandler } from './mcp/mcp.controller.js';
import { McpService } from './mcp/mcp.service.js';
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
import { resolveGatewayConfigPath } from './env.js';
async function bootstrap(): Promise<void> {
const logger = new Logger('Bootstrap');
@@ -37,7 +25,7 @@ async function bootstrap(): Promise<void> {
// Pre-flight: assert all external services required by the configured tier
// are reachable. Runs before NestFactory.create() so failures are visible
// immediately with actionable remediation hints.
const mosaicConfig = loadConfig();
const mosaicConfig = loadConfig(resolveGatewayConfigPath());
try {
await detectAndAssertTier(mosaicConfig);
} catch (err) {
@@ -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.',
});
});
});
+65 -6
View File
@@ -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();
@@ -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
+30
View File
@@ -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>
+7 -2
View File
@@ -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",
@@ -19,6 +21,7 @@
"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 +31,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"
+57
View File
@@ -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 -3
View File
@@ -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,
+29
View File
@@ -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');
});
});
+2 -1
View File
@@ -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()],
});
+30
View File
@@ -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');
});
});
+23
View File
@@ -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;
}
}
+52
View File
@@ -0,0 +1,52 @@
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';
describe('chat socket', () => {
let disconnectHandler: (() => void) | undefined;
beforeEach(() => {
disconnectHandler = undefined;
ioMock.mockReset();
const mockSocket = {
on: vi.fn((event: string, handler: () => void) => {
if (event === 'disconnect') disconnectHandler = handler;
return mockSocket;
}),
offAny: vi.fn(() => mockSocket),
disconnect: vi.fn(() => mockSocket),
};
ioMock.mockReturnValue(mockSocket);
});
afterEach(() => {
destroySocket();
});
it('creates one same-origin /chat namespace socket until it disconnects', () => {
const first = getSocket();
const second = getSocket();
expect(first).toBe(second);
expect(ioMock).toHaveBeenCalledOnce();
expect(ioMock).toHaveBeenCalledWith('/chat', {
withCredentials: true,
autoConnect: false,
transports: ['websocket', 'polling'],
});
disconnectHandler?.();
getSocket();
expect(ioMock).toHaveBeenCalledTimes(2);
});
});
+1 -3
View File
@@ -1,12 +1,10 @@
import { io, type Socket } from 'socket.io-client';
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
let socket: Socket | null = null;
export function getSocket(): Socket {
if (!socket) {
socket = io(`${GATEWAY_URL}/chat`, {
socket = io('/chat', {
withCredentials: true,
autoConnect: false,
transports: ['websocket', 'polling'],
-48
View File
@@ -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();
});
});
-53
View File
@@ -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';
}
}
+19
View File
@@ -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>,
);
+49
View File
@@ -0,0 +1,49 @@
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 { 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: <Placeholder title="Chat" /> },
{ 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);
}
+135
View File
@@ -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');
});
});
+23
View File
@@ -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 />;
}
+152
View File
@@ -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&apos;t have an account?{' '}
<Link to="/register" className="text-blue-400 hover:text-blue-300">
Sign up
</Link>
</p>
</div>
);
}
+116
View File
@@ -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',
});
});
});
+91
View File
@@ -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>
);
}
+9
View File
@@ -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>
);
}
+58
View File
@@ -0,0 +1,58 @@
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';
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);
},
);
});
+14
View File
@@ -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);
});
});
+23
View File
@@ -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,
},
});
}
+24
View File
@@ -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 },
},
},
});
+12
View File
@@ -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/**'],
},
});
+17 -4
View File
@@ -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"]
+3 -1
View File
@@ -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
@@ -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.
+11
View File
@@ -195,6 +195,17 @@ pnpm format:check && pnpm typecheck && pnpm lint
A pre-push hook enforces this mechanically.
### CI Publish Channels
Woodpecker `.woodpecker/publish.yml` keeps stable and integration-line artifacts separate:
| Source | npm packages | Gateway image |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `main` push/manual or release tag | committed package versions published to Gitea npm without changing the dist-tag workflow | `gateway:sha-<short>` plus `gateway:latest` on `main`, and the release tag on tag events |
| `next` push/manual | CI-computed prereleases, `<target-stable>-next.<CI_PIPELINE_NUMBER>`, published with `npm publish --tag next` | `gateway:sha-<short>` only |
`next` never publishes npm `latest` or Docker `latest`. The next npm publish step verifies that `@mosaicstack/mosaic@next` resolves to the computed prerelease before the pipeline can pass.
---
## Adding New Agent Tools
+12 -2
View File
@@ -175,8 +175,18 @@ Or use the direct URL:
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
```
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
non-interactive use:
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`.
Install lanes:
| Lane | Command | Source |
| ------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | npm `@mosaicstack/mosaic@latest` + `main` |
| Prerelease integration | `bash tools/install.sh --next` | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | Build-from-source at the requested ref |
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` still wins and uses the source path.
Flags for non-interactive use:
```bash
--yes # Accept all defaults
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
# CI Queue Guard Purpose Semantics
- **Issue:** #1146
- **Target branch:** `next`
## Problem
`ci-queue-wait.sh` treats any result other than terminal success as asserted non-readiness. That is correct for merge readiness, but incorrect for the pre-push queue guard: a terminal failure or an empty status set means no pipeline is queued or running, so the queue is clear.
## Design
Make final-state handling purpose-sensitive while preserving the existing provider and payload safeguards:
- `--purpose push`
- wait while state is `pending`;
- return success for `terminal-success`, `terminal-failure`, and `no-status`;
- continue rejecting `malformed`, `unknown`, and unrecognized states.
- `--purpose merge`
- return success only for `terminal-success`;
- continue rejecting `terminal-failure`, `no-status`, malformed, unknown, and unrecognized states.
- `--require-status` remains authoritative: `no-status` fails for either purpose when it is supplied.
Diagnostics will explicitly distinguish a queue-clear push result from successful CI so callers cannot mistake an old failure for a green pipeline.
## Testing
Extend the process-level tri-state regression harness with separate push and merge assertions:
1. Push passes for terminal success, terminal failure, and no status.
2. Push still fails for pending, malformed, and unknown states.
3. `--require-status` makes push/no-status fail.
4. Merge behavior remains fail-closed except for terminal success.
5. Existing provider-unavailable audit behavior remains unchanged.
@@ -0,0 +1,208 @@
# CI Queue Guard Purpose Semantics Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Make the pre-push CI queue guard pass when no pipeline is queued or running while preserving fail-closed merge readiness.
**Architecture:** Keep provider lookup and tri-state classification unchanged. Make only the final state dispatch purpose-sensitive: push treats valid non-pending states as queue-clear, while merge continues to require terminal success. Preserve `--require-status`, malformed-payload rejection, unknown-state rejection, and audited provider-unavailable behavior.
**Tech Stack:** Bash, process-level shell regression harnesses, Gitea/GitHub status APIs.
---
### Task 1: Freeze Purpose-Specific State Semantics
**Files:**
- Modify: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh`
- Test: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh`
**Step 1: Add failing push assertions**
Change push expectations so `terminal-failure` and `no-status` require exit 0 plus an explicit `queue-clear` diagnostic. Add a `--require-status` assertion that keeps push/no-status non-zero.
**Step 2: Add failing merge assertions**
Invoke the same harness with `MOSAIC_TEST_PURPOSE=merge` and assert terminal failure and no status remain non-zero while terminal success remains zero.
**Step 3: Add unknown-state coverage**
Add a stub payload with a syntactically valid but unsupported status value and assert both purposes reject it.
**Step 4: Run the focused test and verify RED**
Run:
```bash
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
```
Expected: failures showing push terminal-failure and no-status returned exit 3 instead of exit 0 or lacked `queue-clear` diagnostics.
**Step 5: Commit the failing tests**
```bash
git add packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
git commit -m "test(ci): define purpose-aware queue readiness"
```
### Task 2: Implement Purpose-Sensitive Final-State Dispatch
**Files:**
- Modify: `packages/mosaic/framework/tools/git/ci-queue-wait.sh:458-481`
- Test: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh`
- Test: `packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh`
**Step 1: Implement push queue-clear behavior**
For `no-status`, retain the existing `--require-status` failure. Otherwise, return success for push with an explicit diagnostic such as:
```text
[ci-queue-wait] queue-clear state=no-status purpose=push branch=<branch>; no queued or running CI.
```
For `terminal-failure`, return success only for push with the same queue-clear wording. Merge must continue returning asserted non-readiness.
**Step 2: Preserve malformed and unknown rejection**
Keep `malformed`, `unknown`, and unrecognized states non-zero for both purposes.
**Step 3: Run focused tests and verify GREEN**
Run:
```bash
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh
```
Expected: both scripts exit 0 and report their regression suites passed.
**Step 4: Commit implementation**
```bash
git add packages/mosaic/framework/tools/git/ci-queue-wait.sh
git commit -m "fix(ci): separate push queue clearance from merge readiness"
```
### Task 3: Verify, Review, and Document Evidence
**Files:**
- Modify: `docs/scratchpads/1146-ci-queue-purpose.md`
**Step 1: Run shell syntax and focused regressions**
```bash
bash -n packages/mosaic/framework/tools/git/ci-queue-wait.sh
bash -n packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh
```
**Step 2: Run repository quality gates**
```bash
pnpm preflight
pnpm typecheck
pnpm lint
pnpm test
pnpm format:check
```
Expected: every command exits 0.
**Step 3: Obtain independent review**
Request review of the exact branch head. Remediate all blocking findings and rerun focused and baseline gates.
**Step 4: Record evidence and commit**
Update the scratchpad with test output, review result, and residual risk, then commit it:
```bash
git add docs/scratchpads/1146-ci-queue-purpose.md
git commit -m "docs(ci): record queue guard verification"
```
### Task 4: Keep the Merge Wrapper Aligned with the `next` Lane
**Files:**
- Modify: `packages/mosaic/framework/tools/git/pr-merge.sh:97-101`
- Test: `packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh`
**Step 1: Write the failing regression**
Run the exact-head merge regression with its Gitea fixture targeting `next` and confirm the current wrapper rejects it because it only permits `main`.
**Step 2: Allow only documented integration targets**
Permit `main` and `next`; reject every other target. Do not alter exact-head pinning, queue-guard invocation, provider selection, or merge method enforcement.
**Step 3: Run focused merge regressions**
```bash
bash packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh
bash packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh
bash packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh
```
Expected: all pass, including a Gitea merge fixture targeting `next`.
**Step 4: Commit**
```bash
git add packages/mosaic/framework/tools/git/pr-merge.sh packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh
git commit -m "fix(ci): allow reviewed merges into next"
```
### Task 5: Activate and Deliver Through `next`
**Files:**
- Installed output: `~/.config/mosaic/tools/git/ci-queue-wait.sh`
**Step 1: Activate through the canonical installer**
From the reviewed worktree, run the framework installer in sync-only keep mode so operator files remain protected:
```bash
MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep MOSAIC_SKIP_SKILLS_SYNC=1 \
bash packages/mosaic/framework/install.sh
```
**Step 2: Verify installed/source parity**
```bash
cmp -s \
packages/mosaic/framework/tools/git/ci-queue-wait.sh \
~/.config/mosaic/tools/git/ci-queue-wait.sh
```
Expected: exit 0.
**Step 3: Run mandatory pre-push queue guard**
```bash
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B fix/1146-ci-queue-purpose
```
Expected: branch-absent or queue-clear success.
**Step 4: Push and open a PR against `next`**
```bash
git push -u origin fix/1146-ci-queue-purpose
~/.config/mosaic/tools/git/pr-create.sh \
-t "fix(ci): make queue guard purpose-sensitive" \
-b "Closes #1146" \
-B next \
-H fix/1146-ci-queue-purpose \
-i 1146
```
**Step 5: Complete reviewed integration**
Wait for exact-head terminal-green CI, obtain the required review, merge via the Mosaic wrapper, verify merged CI, and close #1146. Do not bypass any gate.
-58
View File
@@ -1,58 +0,0 @@
# RM-02 / PR #1030 — PRE-REGISTERED RE-REVIEW: the merge-base anchor round
**Subject head (exact):** `fbb61912981abb250d289ec7aafd4316db6fdb11`
**Supersedes:** the second NO-GO at `32b490a7` (D-45/D-46/D-47). That verdict is VOID.
**Registered by:** `mos-remediation` **before any reviewer read the diff.** **Reviewer:** `rev-974`.
## What the orchestrator already reproduced (do not re-spend the review here)
- Derived boundary `f4fd5967` **equals** an independently computed `git merge-base HEAD origin/main`.
- Emptying `criteria`/`gates`/`proseClaims`/`compatibilityScenarios` now fails with
_"population must be non-empty and anchored before evaluation"_.
- The both-directions bootstrap statement is present in `gate-history.mjs` with the Builds 12 residual.
## A — attack the ANCHOR (highest value)
| id | check |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **A1** | `targetRef` is the literal `refs/remotes/origin/main`. **Who controls that ref in the environment where the gate runs?** Can a PR author influence it — a `remote.origin.url` rewrite, a crafted `refs/remotes/origin/main` in their own clone, a push to a fork's `main`? If the gate trusts a locally-writable ref, the anchor moved from the manifest into git config. |
| **A2** | **Absent/stale target:** if `refs/remotes/origin/main` is missing, stale, or the fetch is shallow, does it fail CLOSED (line 57 suggests it does) or silently derive a narrower range? |
| **A3** | **Delayed introduction — the round-2 attack, re-run.** Commit a gate change BEFORE adding the manifest. Does it now stay in range and fail the own-tree read? |
| **A4** | **Branch from an old main:** branch from a point far behind `origin/main`, so merge-base is old. Does the range widen correctly (safe) or include unrelated main commits that cannot carry manifests (a new false-red)? Over-inclusion is the natural failure mode of this fix. |
| **A5** | Is the **bootstrap residual** recorded as a **tracked dependency on Builds 12**, not prose? (D-19's third mandatory move.) |
## P — the non-empty/anchored precondition
| id | check |
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **P1** | Does the precondition run **before** every quantified check, on **every** path — or only the ones the author enumerated? Find a quantified check that still runs before its precondition. |
| **P2** | **Seven required gate IDs anchored to canonical sources** — can that anchor list itself be emptied, shrunk, or pointed elsewhere? An anchor list the author edits is D-45 again, one level in. |
| **P3** | The author reports finding and REMOVING a **production CLI fixture-profile bypass** in their own pre-commit review. **Verify it is gone from the shipped CLI path**, that the remaining relaxation is test-support only and non-executable in production, and that the CLI rejection is tested. This was self-disclosed — confirm it independently. |
## Q — clauses quantified over the population (D-47)
| id | check |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Q1** | `gateRefs` **exactly spans** every registered gate. Can a gate be added WITHOUT a corresponding ref — i.e. does adding a gate to the population escape the clause? |
| **Q2** | Deleting `gateRefs`, or shrinking gates and refs **together**, must fail. Shrink-together is the D-46 shape; verify it is a genuine control and not a regression guard mislabelled. |
| **Q3** | Do the population controls **iterate all seven gates** and mutate evidence-subject and comparison-type **per gate**, or only a representative one? |
| **Q4** | Honest labelling: author claims delayed-introduction, empty-populations, fixture-profile bypass and removable gateRefs as **genuine red-first**, and shrink-together/exact-span as **regression guards**. Verify each claim against pre-fix code (D-8). |
## C — head, CI, integrity
| id | check |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **C1** | Head `fbb61912` on git and provider; `Closes #1029` present. |
| **C2** | Exact-head CI terminal-green, full `-f json`, counts stated incl. `clone`; machine-checked with `verify-terminal-green.py --expect-commit fbb61912981abb250d289ec7aafd4316db6fdb11`. |
| **C3** | Nothing weakened: `32b490a7``fbb61912` removes/relaxes no existing case, test, or assertion. |
## Reviewer instruction
Verdict bound to `fbb61912981abb250d289ec7aafd4316db6fdb11`, evidence enumerated, posted durably under
your own identity. If a check is unrunnable, **say so**.
**A1 and P2 are the ones I most want answered** — both ask whether the fix moved the author's control
point again rather than removing it, which is now the mission's named first-class principle and has
recurred three times. **Attack outside this set.**
**No one dispatching this review may state a conclusion on an open check (D-39).**
-67
View File
@@ -1,67 +0,0 @@
# RM-02 / PR #1030 — PRE-REGISTERED RE-REVIEW: the four-blocker hardening pass
**Subject head (exact):** `32b490a712a5e8e77f13c40c60099b51feb38994`
**Supersedes:** the NO-GO at `83d2ecb2` (D-44, four silent-defeat paths). That verdict is VOID.
**Registered by:** `mos-remediation` **before any reviewer read the diff.** **Reviewer:** `rev-974`.
## Framing — attack the FIX, not the original bug
Every round on this mission, **the remediation introduced the next hole**: the commit-binding fix
shipped a type confusion; the type-strict fix was clean but the registry around it had four defeats.
So the highest-value checks here are **not** "was each blocker fixed" — the orchestrator already
reproduced three of the four attacks as dead — but **"is the NEW mechanism itself defeatable?"**
Blocker 1's fix replaced an author-settable field with a **derivation**. A derivation has inputs. **The
question is who controls them.**
## H — the new derivation (highest value)
| id | check |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **H1** | `deriveHistoryBoundary` = parent of the first first-parent commit introducing `gates/gates.manifest.json`. **Are its inputs author-controlled?** Can a PR author influence the derived value by adding, moving, renaming, deleting-and-recreating, or symlinking that path — or by adding a SECOND manifest earlier in history? If the derivation is gameable, blocker 1 is not fixed, only relocated. |
| **H2** | **First-parent traversal:** what happens on a merge commit, an octopus merge, a squash, a rebase that reorders, or a branch where the introducing commit is not on the first-parent chain? Does the boundary silently move, or fail closed? |
| **H3** | **Shallow/partial clone:** the branch previously needed an unshallow fix (`e8959975` "unshallow gate replay history"). If history is shallow, does the derivation fail CLOSED or silently derive a wrong/empty boundary? |
| **H4** | **Path deletion:** if `gates/gates.manifest.json` is absent at HEAD, or was deleted and re-added, what is derived? Fail closed, or a boundary that excludes the deletion window? |
| **H5** | Are the **three registered seam controls (HEAD, HEAD^, introduction) genuinely RED-FIRST** — do they fail against the PRE-fix code for their own stated reason? A control that was always green is a regression guard, not a must-fail control (D-8). |
## S — the recursive schema closure
| id | check |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **S1** | **Is closure COMPLETE or enumerated?** The author lists the objects they closed. Find one they did **not** — any nested object anywhere in the manifest — and inject an unknown key there. If it is accepted, the fix covers instances, not the class. |
| **S2** | **Wrong-type, not just unknown-key:** `outputPattern` as a number/array/object/null rather than misspelled. Does the closed schema type-check values, or only key names? |
| **S3** | **Empty/degenerate values:** `outputPattern: ""` — does an empty regex match everything and silently neuter the assertion the same way a typo did? The author claims an empty-pattern control; verify it is bound. |
| **S4** | Confirm the registered typo/wrong-type/empty-pattern controls are **genuine red-first** against pre-fix code, and that anything labelled a regression guard is honestly labelled as one. |
## E — provider evidence (blocker 4)
| id | check |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **E1** | Global validation runs **before** per-commit filtering **on every path**, including the HEAD-only path the author calls out. Is there any code path that reaches per-commit assessment without it? |
| **E2** | **Duplicate identity by another key:** the fix makes pipeline NUMBER globally unique. Can two records still collide on a different identity dimension — same commit + different number, same URL, same step-id — and certify the wrong subject? |
| **E3** | "Exactly one gate-verify step per record" — what if a record has zero, or two with different outcomes? Fail closed? |
## C — clauses, integrity, CI
| id | check |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **C1** | The three new criteria (`RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, `RM02-HISTORY-BOUNDARY`) are **checked criteria with bidirectionally bound cases**, not prose. Orchestrator observed caseRefs 1 / 3 / 3 — verify the bindings actually run and can fail. |
| **C2** | **B1/B2 satisfied in substance:** does `RM02-EVIDENCE-SUBJECT-BINDING` express D-38 generally ("does this gate bind its evidence to its subject?") or only the one pipeline-number instance? Same for D-40 vs the one typo instance. **A clause that only covers its originating instance is not a clause.** |
| **C3** | **Nothing weakened:** diff `83d2ecb2``32b490a7` removes/relaxes no existing case, test, or assertion. |
| **C4** | Exact-head CI terminal-green, full `-f json`, counts stated **including `clone`**; machine-checked with `verify-terminal-green.py --expect-commit 32b490a712a5e8e77f13c40c60099b51feb38994`. |
| **C5** | `Closes #1029` still present (D-38c). |
## Reviewer instruction
Verdict bound to `32b490a712a5e8e77f13c40c60099b51feb38994`, evidence enumerated, posted durably under
your own identity. If a check is unrunnable, **say so** — never substitute a passing variant.
**H1 and S1 are the two I most want answered**, because they ask whether each fix closed a **class** or
an **instance**. Attack outside this set: four of the last five blockers on this PR came from mutating
something nobody had registered.
**Environment note:** `pnpm gate:verify` fails on `checkout-preflight` with outcome 42 (bubblewrap
class) in at least one worktree. If you hit it, report it as an environment limitation — do not treat it
as a finding, and do not let it mask one.
**No one dispatching this review may state a conclusion on an open check (D-39).**
-59
View File
@@ -1,59 +0,0 @@
# RM-02 / PR #1030 — PRE-REGISTERED RE-REVIEW: honest narrowing + (d)-strict
**Subject head (exact):** `e910a45ab3578a9c77ad3eeaa52b31b72611f6c6`
**Supersedes:** the third NO-GO at `fbb61912` (D-45/D-46/D-47) and the D-52 stop. Those are VOID.
**Registered by:** `mos-remediation` before any reviewer read the diff. **Reviewer:** `rev-974`.
## Already reproduced by the orchestrator (do not re-spend here)
- The required narrowing sentence is **verbatim** in `gates/required-gates.baseline.json` and in
`scripts/gate-inventory-claim-control.mjs`: _"Detects accidental and incompetent inventory drift
within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier
consistently — that guarantee requires RM-60's external boundary."_
- **The overclaim control FIRES**: clean ⇒ exit 0; injecting "protected, independently anchored" into
the baseline purpose ⇒ **exit 84**, `INVENTORY_CLAIM_OVERSTATED`.
- CI #2201 at the exact head, 10/10 incl. `clone`; machine check bound, `exempted_steps 0`, no anomalies.
## N — the narrowing (D-52's ruling is the wording)
| id | check |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **N1** | ★ Is the narrowed claim **honest EVERYWHERE**, or only where the control looks? Find any place — docs, comments, observations, manifest prose, CLI output — that still implies the baseline is protected or independently anchored. **The control only checks the files it checks.** |
| **N2** | ★ Can the **overclaim control itself** be defeated? It is PR-controlled like everything else. Weaken/skip/reword it so an overstatement passes. If it can be trivially neutered, D-52 is re-armed one level down — the exact recursion this PR keeps producing. |
| **N3** | Does the control detect **semantic** overclaim or only the literal strings it greps? An artifact could assert protection in different words and pass. |
| **N4** | Is the narrowed claim **TRUE**? Verify the baseline genuinely catches accidental/inconsistent drift — not just that it says so. A narrowed claim that is still overstated is D-48 again. |
## H — (d)-strict history removal
| id | check |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **H1** | Is the verifier **structurally incapable** of reporting history-provenance-verified, or merely not currently doing so? The manifest says "structurally incapable" — test the claim. |
| **H2** | ★ **TRAP 1 re-check:** nothing emits the unverifiable state as an OBSERVATION (which exits 0). Confirm no path yields exit 0 while implying provenance was considered. |
| **H3** | ★ **TRAP 2 re-check:** the exclusion is **enforced**, not merely declared in `coverageBoundary.excluded`. Re-enable history provenance without a trustworthy anchor — does a registered case go RED? |
| **H4** | The residual is stated **correctly** — no local git state is trustworthy because PR code executes before the gate — **NOT** the D-48 wording ("compromised main"). |
## B3 / R — evidence-subject and renderer
| id | check |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **B1** | Subject is on the **EVIDENCE side** and compared **at consumption**, not `gate.evidenceSubject === gate.id` metadata-vs-itself. Mutate the evidence-side subject **per gate**. |
| **B2** | Can evidence still be moved or misbound while every gate satisfies its check? |
| **R1** | Renderer now covers the **final success stdout/stderr** paths. |
## C — integrity + CI
| id | check |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| **C1** | Head `e910a45a` on git + provider; `Closes #1029` present. |
| **C2** | Exact-head CI terminal-green, `-f json`, counts incl. `clone`; machine-checked with `--expect-commit e910a45ab3578a9c77ad3eeaa52b31b72611f6c6`. |
| **C3** | Nothing weakened `fbb61912``e910a45a`. Red-first labels honest (D-8). |
| **C4** | **NO fourth local anchor was invented.** Blocker 1 and blocker 2's adversarial guarantee both track RM-60. |
## Reviewer instruction
Verdict bound to `e910a45ab3578a9c77ad3eeaa52b31b72611f6c6`, evidence enumerated, posted durably.
Unrunnable ⇒ **say so**. **N1 and N2 are the sharp ones** — this PR's history is that every fix
relocates the defect one level down, and N2 asks whether the control that enforces honesty is itself
honest. **Attack outside the set: six for six so far.**
**No one dispatching this review may state a conclusion on an open check (D-39).**
-71
View File
@@ -1,71 +0,0 @@
# RM-02 / PR #1030 — PRE-REGISTERED ACCEPTANCE CHECKS (post-rebase, keystone)
**Subject head (exact):** `83d2ecb2243f1b0987ef2bc14de47f444285a684`
**Rebased onto:** `main` @ `f4fd5967fc5d4cbc72d680b199d88224aa855131` (post-RM-61)
**Registered by:** `mos-remediation` **BEFORE the reviewer read the diff.** **Reviewer:** `rev-974`
(author ≠ reviewer; branch commits are `f10-coder`'s and `coder-mos1`'s).
**Prior reviews 63 / 65 are SUPERSEDED; this head carries NO live review of any kind.**
RM-02 is the **keystone**: it is the anti-inert-gate registry every downstream gate is measured against.
A defect that survives here propagates into every gate this mission ships.
## ★ CRUX — the one non-mechanical change, made by the author, that turned a red green
The rebase was clean. One further commit was **not** purely mechanical and the author disclosed it
unprompted (`83d2ecb2` — advance registry activation seam):
gates/gates.manifest.json
- "activationCommit": "f65e9ea656ec466e12640bf6ab5d46fe07ff160c"
+ "activationCommit": "f4fd5967fc5d4cbc72d680b199d88224aa855131"
Stated rationale: after the rebase, `pnpm gate:verify` correctly failed because the newly-merged
pre-registry RM-61 commit was treated as _prospective_ and required a manifest that could not exist.
`activationCommit` sets the lower bound of `activationCommit..HEAD` — **the set of commits required to
carry own-tree registry provenance** (`gate-history.mjs:314``listProspectiveCommits`). Advancing it
**shrinks that set**.
**Determine, do not assume — and I have deliberately formed and stated no verdict (D-39):**
| id | check |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **A1** | **Is the advance JUSTIFIED?** Can a pre-registry commit on `main` (e.g. `f4fd5967`) satisfy the provenance requirement at all, or is requiring it impossible-by-construction? If impossible, is advancing the seam the _correct_ remedy, or does a correct remedy exist that does not move an author-controlled field? |
| **A2** | **Is the advance MINIMAL?** Was it advanced exactly to the new parent baseline, or beyond it? Does any commit that _should_ remain covered fall outside the new range? |
| **A3** | **Are all 7 branch commits still PROSPECTIVE under the new seam?** Enumerate them and show each is still required to carry provenance. The author claims "own-tree provenance over every registry-era branch commit" — verify the claim, do not accept it. |
| **A4** | ★★ **IS THE SEAM ITSELF CONSTRAINED?** The only validation found is `merge-base --is-ancestor activationCommit head` (`gate-history.mjs:288`). **A commit is its own ancestor.** Determine what `activationCommit = HEAD` does: is the prospective range empty, does the per-commit loop iterate zero times, and does the history check therefore PASS VACUOUSLY? If so, the anti-inert-gate registry contains an author-settable field that can inert its own history check. **Run it. Report the observed exit and whether any failure is raised.** |
| **A5** | **If A4 shows vacuity is reachable, is there a registered MUST-FAIL negative control for it?** RM-02's own founding rule is that a gate with no proven failure path manufactures evidence. Does the registry hold a case that goes RED when the seam is over-advanced? If not, that is a hole in the registry's coverage of itself. |
| **A6** | **Self-verification shape (charter / D-19 / D-39b):** the field was advanced by the change's own author to make the author's gate pass. Independent of whether the value is correct, determine whether the _mechanism_ permits an audited party to relax its own audit, and whether that needs a constraint, an owner, or an escalation. |
## Registry substance — the clauses this PR must now carry
| id | check |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **B1** | **D-38 clause present and enforced:** the registry asks of every gate _"does this gate BIND its evidence to the subject under review?"_ Ruled in-scope for THIS PR by Mos. Verify it exists as a checked clause, not prose. |
| **B2** | **D-40 clause present and enforced:** _"discriminator and comparison inputs must be TYPE-STRICT."_ Also ruled in-scope for THIS PR. `== 0` matching `False`/`0.0` is the banked instance; the clause is the general form. |
| **B3** | **D-42's clause is CORRECTLY ABSENT** — provider-side negative control tracks separately (Mos: landing it now would give the registry a clause with nothing to satisfy it, going red on its own clause). Confirm its absence is deliberate and recorded, not forgotten. |
| **B4** | **The four founding clauses still hold** (`MISSION.md`): every check proven **right** (red for its own stated reason), the set **covers**, no two criteria **conflict**, and criterion evolution **retains original text + restatement + reason**. |
| **B5** | **Nothing was weakened, skipped, or deleted by the rebase.** Diff `f9746b23``83d2ecb2` and confirm no registered case, test, or assertion was removed or relaxed to make the rebase land. |
| **B6** | **`coverageBoundary.excluded[]` is honest in BOTH directions** (charter principle 4): what it does NOT cover is stated beside what it DOES, and the gap is tracked (`trackedBy: RM-54`) rather than implied-fixed. |
| **B7** | **The RM-02 execution boundary** (`gate-history.mjs`, DOES / DOES NOT, owner RM-60, xref RM-59) states its limits in both directions and names a tracked owner — not a documented gap with no owner. |
## Head + CI (re-run these; do not carry them forward from the author's report)
| id | check |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **C1** | Head `83d2ecb2…` on **both** git and provider; `Closes #1029` present in the body (delivery-issue rule, D-38c). |
| **C2** | Exact-head CI terminal-green by **full `-f json` scan**, counts stated **including `clone`**. Note the count changed 9 → **10** (this PR adds `gate-verify`) — confirm the new step is real, not a miscount. |
| **C3** | **Machine-check it, do not assert it:** `verify-terminal-green.py --expect-commit 83d2ecb2243f1b0987ef2bc14de47f444285a684`. Report exit, `exempted_steps`, `commit == expected_commit`. |
| **C4** | `exempted_steps=0` is expected here — `ci-postgres` succeeded, so the #1000 exemption was **not needed**. Confirm the exemption is present-but-unused rather than silently inapplicable. |
## Reviewer instruction
Verdict bound to `83d2ecb2243f1b0987ef2bc14de47f444285a684`, evidence enumerated per check, posted
durably under your own identity. If a check is unrunnable, **say so** — never substitute a variant that
passes. Scan CI from `-f json`, never default text (D-33); state your counts.
**A4 is the check I most want an answer to and the one I have least confidence about.** Attack outside
this set as well: every blocker found on this mission so far came from mutating something nobody had
registered a check for.
**No one dispatching this review may state a conclusion on an open check (D-39).** Observations may be
relayed to you; verdicts may not. The ruling is yours.
-42
View File
@@ -1,42 +0,0 @@
# RM-03 / PR #1032 — PRE-REGISTERED RE-REVIEW (post-freshening)
**Subject head (exact):** `868b9b871a5118762aa5b8aafecd2f0592f7ab5c`
**Rebased onto:** `main` @ `f4fd5967` **Registered by:** `mos-remediation` before the reviewer read the
diff. **Reviewer:** `rev-974`.
**Why this exists:** review 66 APPROVED and the merge-gate GO were bound to `78ec47cd`; `main` drifted
under a long hold and the PR went `mergeable=false` (**D-50 — a GO has a shelf life**). Both verdicts
are VOID at the new head. Jason's merge approval is **standing** and executes once this re-gates.
## What the orchestrator already verified (do not re-spend here)
- `git range-diff 78ec47cd...868b9b87`: commits **25 are `=` (patch-equivalent)**; only commit 1 differs.
- That single difference is the `test:framework-shell` chain, resolved as a **UNION** — main's
`test-terminal-green-contract.sh` **and** RM-03's `tristate`, `github-checks`, `merge-queue-branch`,
`merge-head-pin`, plus the pre-existing `branch-absent`. **Nothing dropped from either side.**
- CI #2199 at the exact head: 9 children, 9 success incl. `clone`; machine check exit 0, bound, no anomalies.
- `mergeable` is now **true**.
## Checks
| id | check |
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **R1** | ★ **THE GUARD CAN FAIL.** RM-03 exists because the queue guard is ZERO-INFORMATION (D-23) — it returns pass for every possible input. Prove the rebased head's guard **actually fails** on asserted non-readiness, not merely that its tests pass. This is the whole deliverable. |
| **R2** | ★ **NO QUEUE-GUARD SEMANTIC MOVED IN THE REBASE.** Confirm the range-diff equivalence independently. A queue-guard semantic resolved quietly re-breaks the thing this PR fixes. |
| **R3** | **Union is complete in BOTH directions:** no RM-03 suite lost to main's version, no main suite (esp. `test-terminal-green-contract.sh`) lost to RM-03's. Enumeration count reconciles. |
| **R4** | **Tri-state is real** (`verified` / `written-unverified` / `failed`) — P-WRAPPER-001. Verify an indeterminate result cannot present as a pass; that is the exact defect (`state=unknown exit 0`). |
| **R5** | The guard's **exit-asserting non-null-case** tests are genuine — each fails for its own stated reason against pre-fix code, not merely present (D-8). |
| **R6** | `test:framework-shell` is **runnable to completion in CI** (canonical env). Known: it exits 97 on some hosts via a Bash 5.2.15 `BASH_LINENO` assertion — if you hit that, report it as an environment limitation, do not treat it as a finding and do not let it mask one. |
| **R7** | Exact-head CI terminal-green, full `-f json`, counts stated incl. `clone`; machine-checked with `--expect-commit 868b9b871a5118762aa5b8aafecd2f0592f7ab5c`. |
| **R8** | `Closes #1019` present in the body (D-38c delivery-issue rule). |
| **R9** | Nothing weakened by the rebase: no test, case, or assertion removed or relaxed to make it land. |
## Reviewer instruction
Verdict bound to `868b9b871a5118762aa5b8aafecd2f0592f7ab5c`, evidence enumerated, posted durably under
your own identity. If a check is unrunnable, **say so**.
**R1 is the deliverable and R2 is the risk.** Attack outside this set. **Do not cite the queue guard's
own green as evidence of anything** — it remains inert until this very PR lands (D-23).
**No one dispatching this review may state a conclusion on an open check (D-39).**
@@ -1,40 +0,0 @@
# RM-61 / PR #1033 — PRE-REGISTERED RE-REVIEW ACCEPTANCE CHECKS
**Subject head (exact):** `033b2ffb46674b2c0bcc5197273c109b461f62d9`
**Supersedes:** review 67 / comment 20403 @ `e7b29219` (NO GO). That verdict is VOID — the head moved.
**Registered by:** `mos-remediation` (orchestrator). **Reviewer:** `rev-974` (author ≠ reviewer).
**Registered BEFORE the reviewer read the diff.** Any head move after this file is committed voids the
re-review and requires re-registration.
## Scope discipline
The prior review passed AC1AC8 and still found a blocker, because the registered set tested whether the
signature DISCRIMINATES and never tested whether the evidence was BOUND TO ITS SUBJECT (Finding 3 /
coverage-failure-mode-2, D-17 class). This set therefore carries the binding property as a first-class
check, and **RR7 explicitly invites the reviewer to attack outside the set** — a registered set is
protection against retrofitting only, never a ceiling on scrutiny.
## Checks
| id | check | verdict form |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
| **RR1** | The full 12-case contract harness passes at the exact head: `bash test-terminal-green-contract.sh` | ⇒0, and states 12 cases |
| **RR2** | ★CRUX — the ORIGINAL ATTACK IS DEAD. Take the real `#2188` record, mutate ONLY `commit` to an unrelated 40-hex value, verify against the true expected head | ⇒1, `exempted_steps == 0`, anomaly naming expected vs actual |
| **RR3** | Missing `--expect-commit` cannot be defaulted, inferred, or skipped | ⇒2 (not 0, not 1) |
| **RR4** | Malformed expected commit (short SHA, non-hex, empty) is rejected — no silent normalisation into a pass | ⇒2 |
| **RR5** | A record with the `commit` key ABSENT (not merely different) is rejected — fail-closed on missing, not just on mismatch | ⇒1, `exempted_steps == 0` |
| **RR6** | The genuine artifact still passes when correctly bound: real `#2188` + its true head | ⇒0, `exempted_steps == 1`, exactly one `WP-K8S-1000-CI-POSTGRES-TEARDOWN` |
| **RR7** | ★RED-FIRST, PROVED RETROACTIVELY. The four new cases must FAIL against the OLD verifier at `e7b29219` — otherwise they do not test what they claim (D-8 class). Run the new cases against the previous implementation | new cases ⇒≠0 under `e7b29219` |
| **RR8** | AC2 OF THE PRIOR SET DID NOT REGRESS: both REAL controls stay terminal red — `#2189` (`ci-postgres` exit 1) and `#2191` (exit 137, `test` exit 61) | both ⇒1, `exempted_steps == 0` |
| **RR9** | Still NO fetch / trigger / retry / re-roll / sleep / network of any kind in the verifier or harness. The coin flip must remain removed, not codified (D-21) | grep ⇒ no such call sites |
| **RR10** | The doc/baseline changes REQUIRE the current provider PR head to be passed — they must not merely mention it. Check `merge-gate.md`, `CI-CD-PIPELINES.md`, `woodpecker/README.md` state it as a requirement a gate operator cannot satisfy by omission | reviewer judgement, quote the lines |
| **RR11** | Exemption remains bound to #1000 and retires with it; signature conjunction unchanged and not widened by this fix | diff-scoped, ⇒ no widening |
| **RR12** | Case-sensitivity: an uppercase-hex record commit against a lowercase expected head must NOT silently pass by accident of comparison. State which way it resolves and whether it fails closed | state the observed behaviour |
## Reviewer instruction
Report the verdict **bound to `033b2ffb46674b2c0bcc5197273c109b461f62d9`** and state each check's
observed result, including counts read from `pipeline-status.sh -f json` (never default text — it omits
`clone`, D-33). If any check is unrunnable, **say so** — never substitute a passing variant. Attack
outside this set and report anything it finds; RR7 and RR12 exist because the last blocker was found
exactly that way.
-60
View File
@@ -1,60 +0,0 @@
# RM-61 / PR #1033 — PRE-REGISTERED ACCEPTANCE CHECKS: `exit_code` TYPE-STRICTNESS (D-40)
**Registered by:** `mos-remediation` (orchestrator) — **BEFORE the fix was pushed and before any
reviewer read a diff.** At registration time the fix existed only as an unpushed local commit
(`6e7a336d`) on coder-mos1's machine which **I have not read**. There is therefore no diff for these
checks to have been retrofitted to.
**Subject head:** TBD — binds to the NEW head once coder-mos1 pushes. The prior head
`033b2ffb46674b2c0bcc5197273c109b461f62d9` and its review 69 / pipeline #2193 go VOID on that push;
that cost was accepted deliberately by Mos's BLOCKING ruling on D-40.
**Ruling being enforced (Mos, 2026-08-01):** `exit_code` must be accepted ONLY as a real integer.
`false`, `0.0`, `"0"`, and `null` must all fail to satisfy the exemption. Wrong-ACCEPT is the
disqualifying direction; this hole sits inside the load-bearing discriminator.
## ⚠ Read this before writing the tests — RED-FIRST HERE IS NOT UNIFORM
Two of the four near-miss values **already block** at `033b2ffb`. Demanding "all four observed RED
first" would be demanding an impossible red for two of them, and the predictable response to an
impossible demand is a fudge — weakening something real to manufacture the red. So state it precisely:
| value | behaviour at `033b2ffb` (pre-fix) | what the new case is |
| ------- | --------------------------------- | ------------------------- |
| `false` | **WRONGLY EXEMPTS** (exit 0) | **genuine RED-FIRST** |
| `0.0` | **WRONGLY EXEMPTS** (exit 0) | **genuine RED-FIRST** |
| `"0"` | correctly blocks (exit 1) | **regression guard** only |
| `null` | correctly blocks (exit 1) | **regression guard** only |
Claiming red-first for `"0"` or `null` would be a false claim about your own evidence. Say which is
which. **Do not weaken anything to make a green case go red** (D-8).
## Checks
| id | check | verdict form |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| **TS1** | ★RED-FIRST. `exit_code: false` on the real #2188 record is **observed wrongly exempting at `033b2ffb`** (exit 0, `exempted_steps 1`), then blocks after the fix | pre-fix ⇒0/exempt 1 · post-fix ⇒1/exempt 0 |
| **TS2** | ★RED-FIRST. Same for `exit_code: 0.0` | pre-fix ⇒0/exempt 1 · post-fix ⇒1/exempt 0 |
| **TS3** | REGRESSION GUARD. `exit_code: "0"` blocked before AND after — state honestly that it was already green | both ⇒1, `exempted_steps 0` |
| **TS4** | REGRESSION GUARD. `exit_code: null` blocked before AND after | both ⇒1, `exempted_steps 0` |
| **TS5** | ★NOT OVER-TIGHTENED. The genuine artifact — real #2188, real integer `exit_code: 0`, correctly bound head — still passes | ⇒0, `exempted_steps 1`, exactly one `WP-K8S-1000-CI-POSTGRES-TEARDOWN` |
| **TS6** | The strictness sits on the value the EXEMPTION rests on, not on a cosmetic sibling. Show the guarded comparison is the one feeding `exemption_applies` | reviewer quotes the line |
| **TS7** | `bool` is excluded EXPLICITLY, not incidentally. A bare `isinstance(x, int)` still admits `True`/`False` — verify the `not isinstance(x, bool)` clause exists | ⇒ clause present; `true` also blocks |
| **TS8** | Negative control unaffected: a genuine failed step with a real integer non-zero exit still blocks | ⇒1, `exempted_steps 0` |
| **TS9** | NO REGRESSION ON THE PRIOR ROUND'S BINDING WORK: mutated record commit ⇒1; `--expect-commit` omitted ⇒2; record `commit` absent ⇒1 | ⇒1 / ⇒2 / ⇒1 |
| **TS10** | Signature conjunction NOT widened elsewhere by this fix — `POD_NOT_FOUND` / name / type / state untouched | diff-scoped ⇒ no widening |
| **TS11** | Still no fetch / trigger / retry / re-roll / sleep / network | grep ⇒ no call sites |
| **TS12** | Full harness passes at the new head; **state the case count** (12 previously, expected 16 — confirm the actual number rather than the expected one) | ⇒0, count stated |
## Reviewer instruction
Verdict bound to the NEW head, evidence enumerated per check, CI counts from `pipeline-status.sh -f json`
(never default text — it omits `clone`, D-33). If a check is unrunnable, **say so**; never substitute a
passing variant.
**Attack outside this set and report what you find.** Both blockers on this PR so far — the missing
commit binding, and this type confusion — were found outside the registered set, by mutating a field
nobody had registered a check for. That is now the expectation, not a bonus.
**Nobody dispatching this review may state a conclusion on an open check here (D-39).** If you are sent
an observation, it is an observation; the ruling is yours.
-65
View File
@@ -23,68 +23,3 @@ Merged PR #868 (`b79336a8`) shipped a file that FAILS `pnpm format:check` ⇒ th
### **D-4 / P-LIFECYCLE + hygiene — a dispatched agent silently IGNORED an in-message context reset.**
planner-sol was at 64.3%/372k; the brief asked it to reset first; it began work on dirty context anyway. Only an out-of-band `/new` driven by the orchestrator guaranteed clean state. Confirms the postmortem thesis: **instructions are not enforcement.** Reset must be a mechanical pre-dispatch step, not a request.
<!-- board-roll: 2 entries rolled from BOARD.md -->
### **D-7 / P-FLEET-001 — stale-GC-on-disk: shared 30G /tmp hit 100% ENOSPC, degrading two seats.**
~5.2G was session scratch dead 8-9 days (this session's own footprint: 88K). Same missing capability as orphaned-tmux-session GC, applied to disk — not a quota or discipline problem. Resolved manually by Mos (lead coordinator) after independent verification; `/tmp` now 79%. **The gap IS the finding:** the authority to reap exists, the deterministic reaper does not. Folded into RM-50 with explicit requirements (mechanical liveness, age threshold, dry-run, audit event per reap — never a heuristic sweep). Refusing to unilaterally delete another session's scratch was correct doctrine; the fix is a reaper, not braver agents.
### **D-6 / P-QUEUE-001 — the mandated queue guard returned PASS on an UNKNOWN state, live, today.**
Running the required `ci-queue-wait.sh --purpose push` before pushing produced `state=unknown ... exit 0` — the exact defect at `ci-queue-wait.sh:282-288` that PR #1023 is parked on. It also evaluated `branch=main` rather than the branch being pushed. The mission's own required pre-push gate passed me on an indeterminate result. Third independent live instance of the class.
<!-- board-roll: 1 entry rolled from BOARD.md -->
### **D-8 / P-CONFORMANCE-001 — a PRE-REGISTERED acceptance check that was not runnable as written.**
PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv/lib` dir when the glob is unmatched — it did not test what it claimed. rev-974 ran it exactly as written, caught it, re-ran the intended assertion at an explicit path, and **disclosed** rather than silently substituting a working fixture and reporting PASS. **Pre-registration protects a check from being retrofitted to the implementation; it does not make the check correct.** An unverified gate appeared inside the mechanism built to catch unverified gates. Hard requirement on RM-02: the registry must self-verify that every registered case runs AND can fail — presence is not evidence.
<!-- board-roll: Decisions-log narrative rolled from BOARD.md 2026-08-01 (D-43: meet the
byte budget by ROLLING OUT, never by rewording what stays) -->
### Decisions log — full record in [`TASKS.md`](./TASKS.md)
All 44 findings (D-1…D-6 in `BOARD-LEDGER.md`, D-7…D-43 + D-38c in `TASKS.md`) and every ruling with its
rationale live there. **Not duplicated here** — a second copy is a second thing to go stale, which this
board had done three times in one night (gate list, capability registry, DECISION-1 status), and three
more times by the next rotation seam (RM-61 "building", "nothing implemented yet", DECISION-1/2/3
"must be ruled"). The rulings a fresh seat needs are items 47 above; they are **not** repeated here,
because that repetition is what went stale.
<!-- board-roll: Sequencing section rolled verbatim from BOARD.md 2026-08-01 (D-43: meet the
byte budget by ROLLING OUT, never by rewording what stays). Canonical: MISSION.md + TASKS.md §5 -->
### Sequencing — see [`MISSION.md`](./MISSION.md)
Builds 1-5, the cross-cutting retirements, and DECISION-1's corrected wire-in target are stated once in
the charter and `TASKS.md` §5. **Not repeated here** — the previous copy of DECISION-1's status on this
board is one of the six stale restatements below.
<!-- board-roll: capability/seat-identity bullets rolled verbatim from BOARD.md 2026-08-01
(D-43: roll out, never reword). Authoritative home: TASKS.md D-11 / D-11a / D-11b. -->
- Capability is **per-path** (D-11b → **superseded in part by D-13/D-15**): a token file is **necessary,
not sufficient**. Three layers — token file (raw-API), `tea` login (tea paths), **repository permission**
(writes). Before dispatch, assert `permissions.push == true` **as that seat**, not token existence and
not a 200 on a read. Mos owns provisioning; escalate missing pairs.
- Seat identity (D-11a): token identity AND `git config user.name`/`user.email` must BOTH be set and
agree. Exporting `MOSAIC_GIT_IDENTITY` alone does NOT fix commit authorship.
<!-- board-roll: D-26 gate-restatement rationale rolled verbatim from BOARD.md 2026-08-01 -->
### Why the board must not restate the delivery gates (D-26)
Restating the gate from memory is how the merge-gate step went missing from mission setup twice,
once inside the correction for it (**D-26**).
<!-- board-roll: Fleet seats rolled verbatim from BOARD.md 2026-08-01 (D-43: roll out, never
reword). NOTE: all these seats are UNMANAGED per D-41; `mosaic fleet ps` is live truth. -->
## Fleet seats
- mos-remediation — project orchestrator (Claude, /src/mosaic-stack, socket `mosaic-fleet`) — ACTIVE
- planner-opus — adversarial planner (robustness), Opus 5, socket `default` — DELIVERED, idle
- planner-sol — adversarial planner (pragmatic), gpt-5.6-sol, socket `default` — DELIVERED, idle
- rev-974 — mosaicstack reviewer identity (id 16, write:repository) — idle, on call
- Mos (mos-claude) — lead coordinator, socket `default` — relay path to Jason
+73 -65
View File
@@ -1,85 +1,93 @@
# mos-remediation — LIVE BOARD (keep < 8 KB)
**Phase:** EXECUTING — **RM-02 keystone is the front**.
**Updated:** 2026-08-05 — successor seat live. D-56 state correction applied (lost round-5 dispatch,
4 idle days); round-5 review then ran same day: verdict id 88 REQUEST_CHANGES @ `e910a45a`,
remediation dispatched to `coder-mos2`. Review dispatches now always leave a durable PR comment.
⚠ That was a **MANUAL pane respawn** (prior seat ~803k tokens): it validates the checkpoint+rehydration
**design**, NOT a lifecycle **mechanism** — P-LIFECYCLE rotation does not exist yet (**D-41 / RM-62**).
**Phase:** EXECUTING — P0 open. RM-01 MERGED; RM-02 (keystone gate registry) is next.
**Updated:** 2026-07-31 (mos-remediation orchestrator; seat active on `mosaic-fleet`).
## Head
- Charter + 15 decisions + 4-build plan: `MISSION.md`. Backlog + all findings: `TASKS.md`.
- Planning DONE (58 tasks, P0P5). **DECISION-1/2/3 all RULED by Mos 2026-07-31** (`TASKS.md` §5) —
nothing is waiting on a decision. D-2's availability _target_ is Jason-pending and non-blocking.
- **Executing, not planning.** RM-01 is MERGED; three lanes are live (see In-flight).
- Orchestrator seat `mos-remediation` LIVE, owns the mission, resumed across the rotation seam
2026-08-01 and re-attested to Mos from the files. Residency attestation: PASS.
- Mission charter + 15 decisions + 4-build plan: PERSISTED (`docs/remediation/MISSION.md`).
- HOLD lifted for this workstream (Jason 2026-07-31). Nothing implemented yet — planning first.
- Orchestrator seat `mos-remediation` is LIVE and owns the mission. Residency attestation: PASS.
- **TASK-0 DONE** — checkout repaired, all three gates green HONESTLY (no `--no-verify`), branch pushed.
- **TASK-1 DONE** — both planners delivered independently on clean context; reconciled into `TASKS.md`
(58 tasks across P0P5, 7 convergences, 7 adjudicated disagreements, 3 escalated decisions).
- **NEXT ACTION IS NOT MINE:** DECISION-1/2/3 (`TASKS.md` §5) must be ruled before P0 dispatch.
RM-01 is dispatchable immediately regardless — it depends on nothing and blocks everything.
## In-flight
| Task | Owner | State |
| ------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| RM-01 checkout | — | **MERGED** `f58b3699` (#1027) |
| RM-03 queue guard | Jason (re-sync) | ✅ **MERGED** `58b971ab` (#1032), #1019 closed. ⚠ **NOT DELIVERED**: installed guard still the broken one (291 lines / 0 `ASSERTED_NOT_READY` vs main 482 / 5). Re-sync via `mosaic upgrade`, then **prove it blocks a KNOWN-RED pipeline****D-51** |
| RM-02 registry ★key | coder-mos2 | **ROUND-5 VERDICT id 88 REQUEST_CHANGES @ `e910a45a` (2026-08-05T22:37Z, live).** 2 blockers left: renderer accepts manifest-controlled `COMPATIBILITY `-prefixed provenance-success payloads; overclaim control is lexical-not-semantic vs docs claims. Shrink-together + evidence-subject CONFIRMED CLOSED — do not weaken. Remediation dispatched to `coder-mos2` 22:5xZ (PR comment + verified tmux); context guard: checkpoint at 85%. ⚠ **MERGE PRECONDITIONS (D-55/D-55f, measured):** poster=f10-coder (1 of 3 authors); (1) explicit `-m squash` (repo default `merge` discards ALL authorship); (2) **merger ≠ f10-coder** (self-merge suppresses the trailer generator → all 3 lost); (3) **the EXECUTING pr-merge.sh must contain `MergeMessageField` — merging #1066 is NOT sufficient** (deployed wrapper `08a65e85` = 0 refs, physically cannot emit; D-51's merged≠effective class inside the remedy; Mos-as-merger verifies wrapper sha256 before ANY trailer-dependent merge and refuses on `08a65e85`); (4) ✅ **CLOSED ON MEASUREMENT** — N=2 supplied non-poster trailers BOTH land, order preserved, poster line appended last (fixture prmerge-trailer-fixture#2`39db9d13`, #1030's exact shape; N=3+/poster-dup-dedup/Co-committed-by absence remain unmeasured but #1030 needs N=2). Merge decision resolved: all-land ⇒ merge as planned, three authors preserved. Trade in body; **branch MUST NOT be deleted**. ACs @ `dde38717` |
| RM-61 CI exemption | — | ✅ **MERGED** `f4fd5967` (#1033). #1034 closed; **#1000 stays OPEN** (retirement trigger). Exemption is on `main` |
| RM-59 / RM-60 | Jason (infra) | tracked deps; RM-60 option **B** |
| #1023 queue attempt | Jason | SUPERSEDED-PENDING-JASON — live REQUEST_CHANGES, do **not** merge |
### For the incoming orchestrator — read this before acting
1. **Lane state lives in the In-flight table above — this item does NOT restate it.** It went stale
three times in one session by duplicating that table (D-26's class). Read the table. ⚠ **And
re-derive any board claim from the provider before load-bearing use (D-43)** — the board is
sole-written and has no independent verifier.
2. **`docs/remediation/TASKS.md` is authoritative**, not the newest voice in a chat. It holds 58 findings
(D-1…D-6 in `BOARD-LEDGER.md`, D-7…D-55c + D-38c in TASKS.md), every ruling with its rationale, and the
requirements each finding placed on RM-02/RM-34/RM-50/RM-55.
3. **`MISSION.md` carries the first-class principles** — read them there, they are not listed here.
Two added 2026-08-01: **the anchor must live outside the audited party's authority** (D-19/D-25/D-45,
third arrival) and **no universally-quantified check may pass over an empty set** (D-44/D-46).
4. **Seat identity:** `export MOSAIC_GIT_IDENTITY=<seat>` is stripped by a context reset (**D-34**) —
every dispatch/rehydration brief must re-export it, or the seat cannot use its credentials.
5. **Scan CI from `-f json`, never default text** — text mode omits `clone` (**D-33**). State counts.
6. **The queue guard is zero-information until RM-03 merges** (**D-23**) — never cite its green.
7. **The bounded CI re-roll used on RM-02 was a one-time stopgap, NOT policy.** A per-PR free re-roll is
D-21 normalisation. Do not repeat it; RM-61 is the fix.
## Delivery gates — REFERENCE, do not restate
Canonical: `~/.config/mosaic/fleet/roles.local/merge-gate.md` (verdict authority) +
`~/.config/mosaic/fleet/roles/validator.md`. Order and the freeze/zero-information rules: `MISSION.md`
and `KICKSTART.md`. **Read them there** (why: **D-26**, in `BOARD-LEDGER.md`).
| Task | Owner | State |
| ----------------------------------- | --------------- | ------------------------------------------------------------------------- |
| RM-01 reproducible checkout | — | **MERGED** `f58b3699` (PR #1027) — rev-974 APPROVE + CI #2172 8/8 green |
| RM-02 gate registry ★keystone | unassigned | **READY** — depends only on RM-01; not held by RM-03 |
| RM-03 queue guard (3 defects) | — | HOLD — #1023 SUPERSEDED-PENDING-JASON |
| RM-59 close D-19 residual risk | — | BLOCKED by RM-12/RM-21/RM-25 (spine + executor) — tracked edge, not prose |
| `remediation/state` snapshot → main | mos-remediation | opening at this mission seam |
## Fleet seats
Roster rolled verbatim to [`BOARD-LEDGER.md`](./BOARD-LEDGER.md); live truth is `mosaic fleet ps`.
- mos-remediation — project orchestrator (Claude, /src/mosaic-stack, socket `mosaic-fleet`) — ACTIVE
- planner-opus — adversarial planner (robustness), Opus 5, socket `default` — DELIVERED, idle
- planner-sol — adversarial planner (pragmatic), gpt-5.6-sol, socket `default` — DELIVERED, idle
- rev-974 — mosaicstack reviewer identity (id 16, write:repository) — idle, on call
- Mos (mos-claude) — lead coordinator, socket `default` — relay path to Jason
## Gate status
- Delivery gates active: author≠reviewer, diff-blind pre-registered checks, CI-green, merged-PR completion.
- Freeze: LIFTED for this workstream only.
- Git identity: orchestrator runs `MOSAIC_GIT_IDENTITY=mos-dt-0` INTERIM. Mos ruled gate-16 HOLDS (author≠reviewer is what
- Git identity: `MOSAIC_GIT_IDENTITY=mos-dt-0` INTERIM. Mos ruled gate-16 HOLDS (author≠reviewer is what
gate-16 protects; rev-974 reviews, mos-dt-0 never self-reviews). Dedicated identity TRACKED, Mos provisions.
- Capability + seat identity (**D-11b / D-11a**, incl. the false-NEGATIVE twin): authoritative in
`TASKS.md`. Short form — **assert the DIFFERENTIAL as that seat** (authenticated `push:true` vs
unauthenticated `push:false`); a single endpoint can be true for anyone or 403 for an unrelated
scope. Full text rolled to [`BOARD-LEDGER.md`](./BOARD-LEDGER.md).
- ⚠ **LIVE HAZARD (D-37) — one shared `.git/config` re-identifies EVERY worktree at once.** Every seat,
including `rev-974`'s review worktree, currently authors as **`coder-mos1`**; `MOSAIC_GIT_IDENTITY`
does **not** override it. **STANDING ORDER: commit with explicit
`git -c user.name=<seat> -c user.email=<seat>@…`, and NOBODY rewrites the shared config mid-flight.**
Real fix authorised, Mos owns it, sequenced at a quiet seam. **#1024 implicated.** Detail: D-37.
- Standing worker-brief doctrine (mandatory in EVERY brief): re-export `MOSAIC_GIT_IDENTITY` (**D-34**);
commit early/WIP (**D-31**); don't weaken a RED test to pass; if a check is unrunnable SAY SO, never
substitute; `agent-send -f` never `-m`; artifacts off shared `/tmp`; scan CI from `-f json` (**D-33**);
**relay observations into an open review, NEVER your own conclusion on an open check (D-39)**; the
**author never adjudicates their own PR's blocker status** — surface evidence, prepare the fix, hold.
- Capability check (D-11b): before dispatching seat X to provider Y, verify
`~/.config/mosaic/secrets/gitea-tokens/gitea-<Y>-<X>.token` exists. Token-file set = authoritative
capability registry. Mos owns provisioning; escalate missing pairs to him.
- Seat identity (D-11a): token identity AND `git config user.name`/`user.email` must BOTH be set and
agree. Exporting `MOSAIC_GIT_IDENTITY` alone does NOT fix commit authorship.
- Standing worker-brief doctrine (accreted, mandatory in every brief): don't weaken a RED test to make
it pass; if a check is unrunnable as written SAY SO, never silently substitute; `agent-send -f` never
`-m`; heavy artifacts off shared `/tmp`.
- Remote control: native `/remote-control` NOT wired in this runtime. Path is **Mos-relay**
(Jason ↔ mos-claude via Discord ↔ mos-remediation via agent-send). Not a blocker.
## Decisions log — full record in [`TASKS.md`](./TASKS.md)
## Sequencing (from MISSION.md)
All 58 findings (D-1…D-6 in `BOARD-LEDGER.md`, D-7…D-55c + D-38c in `TASKS.md`) and every ruling with
its rationale live there. **Not duplicated here.** The history of _why_ this board must not restate —
six stale copies across two seams — is rolled verbatim into [`BOARD-LEDGER.md`](./BOARD-LEDGER.md).
1. Spine + choke-point service (MACP wiring @ mosaic_orchestrator.py::run_single_task) + PG/Redis
**CONTESTED — see DECISION-1.** Both planners independently reject this wire-in point: that
controller is `"enabled": false` and references a dispatcher that does not exist here. Charter text
left UNCHANGED pending Mos/Jason ruling; do not treat it as settled.
2. Rotation daemon (finish Mission Control Plane, reuse packages/coord)
3. Comms service (envelope→service→PG/Redis→adapters)
4. Hygiene + conformance harness
Cross-cutting retirements: flat-file tracking, 3 MACP islands, silent MOSAIC BYPASS.
## Dogfood evidence — live failure classes, not hypotheticals
> Newest first. Oldest entries roll to `BOARD-LEDGER.md` via `board-roll.sh` when this file
> exceeds its 8 KB cap. Keystone detail is duplicated in `TASKS.md` §1a, so rolling loses nothing.
<!-- BOARD-ROLL:START -->
### **D-8 / P-CONFORMANCE-001 — a PRE-REGISTERED acceptance check that was not runnable as written.**
PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv/lib` dir when the glob is unmatched — it did not test what it claimed. rev-974 ran it exactly as written, caught it, re-ran the intended assertion at an explicit path, and **disclosed** rather than silently substituting a working fixture and reporting PASS. **Pre-registration protects a check from being retrofitted to the implementation; it does not make the check correct.** An unverified gate appeared inside the mechanism built to catch unverified gates. Hard requirement on RM-02: the registry must self-verify that every registered case runs AND can fail — presence is not evidence.
### **D-7 / P-FLEET-001 — stale-GC-on-disk: shared 30G /tmp hit 100% ENOSPC, degrading two seats.**
~5.2G was session scratch dead 8-9 days (this session's own footprint: 88K). Same missing capability as orphaned-tmux-session GC, applied to disk — not a quota or discipline problem. Resolved manually by Mos (lead coordinator) after independent verification; `/tmp` now 79%. **The gap IS the finding:** the authority to reap exists, the deterministic reaper does not. Folded into RM-50 with explicit requirements (mechanical liveness, age threshold, dry-run, audit event per reap — never a heuristic sweep). Refusing to unilaterally delete another session's scratch was correct doctrine; the fix is a reaper, not braver agents.
### **D-6 / P-QUEUE-001 — the mandated queue guard returned PASS on an UNKNOWN state, live, today.**
Running the required `ci-queue-wait.sh --purpose push` before pushing produced `state=unknown ... exit 0` — the exact defect at `ci-queue-wait.sh:282-288` that PR #1023 is parked on. It also evaluated `branch=main` rather than the branch being pushed. The mission's own required pre-push gate passed me on an indeterminate result. Third independent live instance of the class.
<!-- BOARD-ROLL:END -->
## Decisions log
- 2026-07-31 — Mission set up by Mos post-postmortem (15/15 decided). Dogfood posture active.
- 2026-07-31 — Mos: stale `.mosaic/orchestrator/mission.json` is RESIDUE of the disabled Python
orchestrator rail that this plan RETIRES. Do NOT invest in it; do NOT build on that rail. The 0/0
milestone banner is cosmetic. (Supersedes any plan to repair it.)
- 2026-07-31 — Mos: planners must be dispatched with GUARANTEED clean context, not requested-clean.
Prior default-socket planner sessions predate this mission; dirty context is the indicted hygiene.
- 2026-07-31 — mos-remediation: worker briefs forbid all git ops and restrict each worker to a single
named output file, so two planners can share one checkout without a branch race (M2-era incident doctrine).
+2 -12
View File
@@ -25,18 +25,8 @@ mechanically until Build 3 (rotation) makes it automatic.
## Standing invariants (never violate)
- **North star:** deterministic-right-answer → code/gate; LLM only for judgment.
- **Delivery gates — REFERENCE the canonical files, never restate them:**
`~/.config/mosaic/fleet/roles.local/merge-gate.md` (verdict authority) and
`~/.config/mosaic/fleet/roles/validator.md` (validator role). Order:
independent review (author ≠ reviewer, `rev-974`; pre-registered diff-blind checks committed before the
diff is read) → remediation → **CI terminal-green at the exact head by full step scan**
**merge-gate verdict `GO`/`NO-GO`/`HOLD`**, commit-bound and **void the instant the head moves**, posted
durably with enumerated evidence under its own minted identity → **coordinator head-pinned merge**.
The queue guard runs but is **zero-information until RM-03 lands** (D-23) and must not be cited as evidence.
**After a `GO`, freeze pushes** — even a doc tweak voids the verdict. The coordinator assigns the gate seat.
- **Query for refutation, never for confirmation.** A subordinate asked to confirm a hypothesis will
agree — the bias is in the question, not the answerer, and agent seats are agreeable by construction.
State the hypothesis as yours, ask for the evidence that KILLS it, and reproduce when it matters.
- **Delivery gates:** author≠reviewer; PRE-REGISTERED diff-blind checks committed before reading the diff;
CI terminal-green; completion = merged PR + closed issue. rev-974 = the mosaicstack reviewer identity.
- **Dogfooding:** every fix validated against its live seed case (MISSION.md lists them).
- **Tracking → DB** (hard cutover); do NOT re-invest in flat-file tracking. jarvis-brain PDA is off-limits.
- **Git identity:** export `MOSAIC_GIT_IDENTITY=<your-seat>` so wrappers author correctly and survive respawn.
+2 -143
View File
@@ -91,99 +91,6 @@ gate/program; the LLM handles only genuine judgment.
> seat in the loop. Residual risk bound to **RM-59** (`depends_on: RM-12, RM-21, RM-25`), where the
> choke-point executor and spine verify from _outside_ the worktree's authority.
### First-class principle — query for refutation, never for confirmation
> **A subordinate asked to confirm a hypothesis will agree. Ask it to refute, with evidence.**
>
> The bias is induced by the **query**, not by the answerer's diligence. _"The DB flaked — please
> confirm"_ and _"confirm or refute this, with the log line that proves it"_ are different instruments,
> and they return different answers to the same question. The first harvests agreement; only the second
> can return **"you are wrong, and here is why."**
>
> This matters most with agent subordinates, which are **agreeable by construction**: fluent, eager to
> be useful, and structurally disinclined to tell the dispatcher their premise is false. A confirmation
> query aimed at one is close to a guaranteed yes — so the discipline cannot rest on the answerer being
> rigorous. **It has to be built into how the question is asked.**
>
> Promoted to the charter by Mos (2026-08-01). Origin: the orchestrator hypothesised that a coincident
> `ci-postgres` failure caused a CI test failure and asked the implementing seat to **confirm or refute**
> it. The seat **refuted it** with the log (`ci-postgres:5432 - accepting connections`, migrations
> completed) and identified the real cause. Reproduction on an identical head then settled it. Had the
> query been phrased for confirmation, the agreement would have been returned, **D-21 would have been
> re-classified on a false premise**, and a banked finding would have been silently corrupted.
>
> **Operationally:** state your hypothesis explicitly, mark it as yours, ask for _evidence that kills
> it_, and say what evidence would change your mind. A hypothesis you cannot describe how to falsify is
> not yet a hypothesis. Where the answer is consequential, **reproduce** rather than accept — two
> independent runs beat one confident report.
### First-class principle — the anchor must live outside the audited party's authority
> **You cannot fix "the author controls X" by deriving X from something the author ALSO controls.**
> Deriving merely **moves** the control point; it does not remove it.
>
> Promoted to the charter by Mos (2026-08-01) on the **THIRD INDEPENDENT ARRIVAL** of the same
> conclusion, each reached while trying to ship something else, each from a different direction:
>
> | arrival | the audited party controls… | found as |
> | ----------------- | ------------------------------------------------------------------- | -------- |
> | manifest | the tree that certifies its own generated state (same-UID, CWE-345) | **D-19** |
> | sandbox | the code that enters the sandbox, before the boundary exists | **D-25** |
> | **registry seam** | **WHEN the tracked path is introduced, hence the derived boundary** | **D-45** |
>
> Round 1 the value was an author-settable **field**, so it was **derived**. Round 2 the derivation
> depended on **when the author introduces the path**. Same authority, new costume. **Three
> impossibility-derivations of one conclusion is not a coincidence to note — it is the strongest
> architectural evidence this mission has produced**, and it is precisely what **forces Builds 12**
> rather than making them a preference.
>
> **Operationally:** anchor to a reference the audited party cannot move. A git **merge-base against
> `main`** is such a reference for a PR author (they own their branch; they do not own `main`).
> **State the bootstrap in BOTH directions (D-19):** that anchor is sound against an author who cannot
> rewrite `main` — the threat in scope — and **NOT** sound against an attacker who can. **That residual
> is what Builds 12 close, and it must be recorded as a tracked dependency, never implied.**
### First-class principle — no universally-quantified check may pass over an empty set
> **"All registered cases ran" is VACUOUSLY TRUE when there are no registered cases.**
> **Non-emptiness and anchoring are PRECONDITIONS asserted before the quantified check runs — not
> properties hoped for after it.**
>
> Promoted by Mos (2026-08-01) after the identical vacuity appeared **twice, at two different levels**:
> `activationCommit = HEAD` emptied the **commit range** (D-44), and an emptied manifest — `criteria`,
> `gates`, `proseClaims`, `compatibilityScenarios` all `[]` — emptied the **registry population**
> (D-46), each yielding **exit 0**. The first was fixed **as an instance**; the principle was never
> extracted, **so it returned one level up.**
>
> **Delete every gate and every criterion TOGETHER and no remaining reference complains — because every
> reference went with them.** A check that quantifies over a population must actually **range** over it,
> and that population must be **provably complete and non-empty**.
>
> **Corollary (same disease):** a clause written for the instance that produced it is not a clause.
> **Do not relabel the originating instances as the general clauses** — quantify over the population.
### First-class principle — redundant observation on evidence-bearing steps
> **Two observers of the same evidence, disagreeing, catch what neither catches alone.** Apply redundancy
> not only to judgement calls but to **evidence gathering itself** — the step everyone assumes is
> mechanical and therefore skips.
>
> Promoted by Mos (2026-08-01) from **D-33**. A seat scanned a pipeline with `-f json` and reported 9
> steps; the orchestrator scanned the same pipeline in the wrapper's default text mode and reported 8.
> **The default output omits `clone`.** Every "full step scan" that night had been 8-of-9 and was stated
> as complete in good faith. No verdict changed — but the _method_ was wrong, invisibly, and **only the
> disagreement between two counts surfaced it.**
>
> The reason it survived: **a summary that resembles an enumeration is more dangerous than one that
> obviously summarises.** A labelled list of named steps with states _looks_ like the artifact, so nobody
> checks it against the record. Compare D-24 — `mergeable` was a _true answer to a different question_;
> this was a _true answer to a smaller one_. Neither is a lie; both pass every sniff test.
>
> **Operationally:** where a step _produces evidence a decision rests on_, have it produced twice by
> different means, and treat **any divergence as a finding rather than as noise to reconcile**. Prefer the
> machine-readable record over the human-readable rendering — _read the artifact, not the summary_ — and
> state the counts observed so a divergence is detectable at all.
## Decision record (authoritative, immutable)
- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.**
@@ -202,31 +109,6 @@ gate/program; the LLM handles only genuine judgment.
| **4. Comms service** | AUTHORITY, INBOX (+ versioning roadmap) | Envelope (comms/v1) → sole-path service → PG/Redis → pluggable adapters (tmux→Matrix/Discord/Slack/Telegram). Version the protocol, not participants. |
| **+ Hygiene & proof** | FLEET, WORKFLOW, CONFORMANCE | One roster-owned socket/host + stale GC; allowlist auto-sync; the conformance harness that fault-injects the failure classes and proves builds 14 hold. |
## Why Builds 12 are necessary — two independent impossibility proofs
**The choke-point executor and PG spine are not a design preference. They are forced.** Twice during
the mission's own first deliveries, work stopped against a security property that **cannot exist** at
the layer that needed it — and both times the only resolution was an authority _outside_ the audited
party's control, which is precisely what Builds 12 provide.
| | the audited party controls… | so what fails | found as |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------- | -------- |
| **Artifact integrity** | the manifest that certifies its own generated tree (same-UID write ⇒ regenerate manifest _and_ marker consistently, CWE-345) | tamper-evidence | **D-19** |
| **Execution integrity** | the code that enters the sandbox (PR-controlled config executes _before_ the boundary exists) | isolation | **D-25** |
Both reduce to one sentence:
> **Self-verification by the audited party is not verification.**
And to one consequence: **the anchor must live outside the audited party's authority.** A local check
cannot defend against an actor who can rewrite the check; a repo cannot grant a capability to
PR-controlled config and simultaneously prevent that config from using it.
**An architecture forced by two independent impossibility proofs is stronger evidence than one argued
for.** Neither proof was sought — both arrived while trying to ship something else, from different
directions (a symlink manifest; a CI sandbox), at different layers. RM-59 and RM-60 are the two tracked
dependencies this creates, and they are the same dependency in different clothes.
## The finding that sets the cost
**Built-but-unwired disease.** `@mosaicstack/macp` is stranded (nothing calls it); `packages/coord` primitives
@@ -279,29 +161,6 @@ orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retir
- **Project orchestrator** `mos-remediation` (this seat) owns the mission; coordinates under Mos (lead).
- **Adversarial task decomposition:** `planner-opus` (robustness) + `planner-sol` (pragmatic) each decompose
the plan independently; orchestrator reconciles into `TASKS.md`/DB tasks. Oppositional by design.
- **Delivery gates — REFERENCE, do not restate.** The authoritative definitions live at
[`~/.config/mosaic/fleet/roles.local/merge-gate.md`](file:///home/hermes/.config/mosaic/fleet/roles.local/merge-gate.md)
(verdict authority) and
[`~/.config/mosaic/fleet/roles/validator.md`](file:///home/hermes/.config/mosaic/fleet/roles/validator.md)
(validator/certificate role). **Read them; do not paraphrase them.** Restating an authoritative source
is lossy every time — see D-26, where a subset restated from memory dropped a security precondition.
**Gate order** (the sequence only; the definitions are in the files above):
1. Independent review, **author ≠ reviewer** (`rev-974` on mosaicstack), with PRE-REGISTERED diff-blind
acceptance checks committed before the diff is read
2. Remediation of findings
3. **CI terminal-green** at the exact full-40 head, by **full step scan**
4. **Merge-gate verdict — `GO` / `NO-GO` / `HOLD`** (class `merge-gate`; "Ultron" is an _instance name_,
display data, never an authority source). **Bound to a commit and VOID the instant the head moves.**
`HOLD` persists until replaced; a `NO-GO` answered by an empty commit must be re-issued as `NO-GO`.
Posted durably on the PR under the gate's own minted identity, **enumerating** its evidence — a bare
"GO — gates verified" is non-conforming.
5. **Coordinator merge**, head-pinned. The gate never merges; it holds `push=False` by design.
**The CI queue guard runs but is ZERO-INFORMATION until RM-03 lands** (D-23: it returns pass for every
possible input). It must not be cited as evidence by any gate, including the coordinator's own merge path.
**Assignment:** the coordinator assigns the merge-gate seat; the orchestrator does not. The orchestrator
owns getting a PR _gate-ready_.
- **Delivery gates (non-negotiable):** author≠reviewer, PRE-REGISTERED diff-blind acceptance checks committed
before reading the diff, CI terminal-green, completion = merged PR + closed issue. rev-974 = mosaicstack reviewer.
- **Compaction survival:** see `KICKSTART.md` in this dir — the resume procedure. Persist typed state, not transcript.
+12 -2090
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,118 @@
# RM-61 — CI contract exemption for #1000 teardown artifact
**Tracking:** RM-61 / issue #1000
**Branch:** `fix/rm-61-ci-contract-exemption`
**Owner:** `coder-mos1`
## Objective
Determine, by red-first provider controls, whether the `ci-postgres` pod-not-found teardown signature discriminates from a real PostgreSQL failure. Only if it discriminates may a named, bounded CI-contract exemption be implemented. The exemption must retire when #1000 is fixed; fixing #1000 is the closure path.
## Pre-registered kill criterion
If an injected real `ci-postgres` failure also yields `pods "wp-svc-<ULID>-ci-postgres" not found` as the service's provider-visible failure, the signature does not discriminate. Option B is unsafe; stop exemption implementation and fall to Option A (#1000).
## Plan
1. Capture full `-f json` records for the 11 supplied observations and state counts.
2. Run one startup-failure control using the real pgvector/PostgreSQL image with an invalid `initdb` argument.
3. Run one post-readiness crash control using real PostgreSQL, `pg_isready`, and a deliberate postmaster kill while a DB-dependent probe is active.
4. Compare the raw `ci-postgres` service record independently of failures in dependent steps.
5. Investigate runner/time/head clustering only as a hypothesis; never encode incidental correlates or retries into policy.
6. If and only if the controls discriminate, implement and test the exact exemption, document its two-way boundary, and track retirement at #1000.
## Budget
No explicit token cap supplied. Working estimate: 20K30K tokens. Limit provider controls to the two pre-registered runs; no retries or re-roll policy.
## Initial evidence
Historical JSON saved locally under `.evidence/rm-61/` (not for commit). Supplied pipelines: 11 total. Child-step counts: five pipelines with 9 children and six with 10 children. Seven contain the `ci-postgres` pod-not-found failure (#2170, #2175, #2180, #2181, #2182, #2187, #2188); four do not (#2158, #2167, #2184, #2186). Every observed workflow reports `agent_id=44`, so the available JSON does not separate clean and artifact runs by runner. This refutes runner identity as a discriminator in the sampled record.
## Progress
- [x] Requirements and kill criterion recorded before control implementation.
- [x] Historical full-JSON records captured.
- [x] Startup-failure control observed terminal.
- [x] Post-readiness crash control observed terminal.
- [x] Discrimination verdict recorded: Option B may proceed.
- [x] Conditional exemption implementation.
## Tests / evidence
### Control 1 — real startup failure
- Commit: `3931b0e29eb834914f7b17e4db7e221481d436fa`
- Pipeline: #2189, exact commit match.
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
- `ci-postgres`: `state=failure`, `exit_code=1`, `error=null`, with a five-second execution window.
- `test`: `state=failure`, `exit_code=1` after the readiness budget expired.
- Pipeline/workflow: terminal `failure`.
This control is red and its service record differs from #1000 (`exit_code=0` plus pod-not-found). It proves the startup-failure direction only. It does not settle the dangerous post-readiness crash/garbage-collection path.
### Control 2 — real post-readiness crash
- Commit: `25ac59715a94dd1b52ef42577472eb44ecc4b446`
- Pipeline: #2191, exact commit match.
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
- Service log proves PostgreSQL reached `database system is ready to accept connections`, the test created the arm table, and the service then killed postmaster PID 7.
- Test log proves a successful `SELECT 1` followed by `Connection refused`; it exited the pre-registered control code 61.
- `ci-postgres`: `state=failure`, `exit_code=137`, `error=null`, with a 203-second execution window.
- `test`: `state=failure`, `exit_code=61`.
- Pipeline/workflow: terminal `failure`.
This is the dangerous post-readiness crash path. Its service record is not pod-not-found and therefore differs from #1000 independently of the dependent test failure.
### Discrimination verdict
Both real failures are provider-visible as process exits (`exit_code=1` startup; `exit_code=137` crash) with no pod-not-found error. The seven observed #1000 artifacts are provider reconciliation misses (`exit_code=0` plus the exact pod-not-found error). The declared kill criterion did not fire, so Option B may proceed with a matcher requiring the full conjunction. This evidence does **not** prove every future Kubernetes failure is distinguishable; it proves these two concrete real-failure classes remain blocking and bounds the exemption to the observed reconciliation shape.
### Unit red-first checkpoint
The nine-case contract harness was written before the verifier. First execution exited 1 because `verify-terminal-green.py` did not exist; no exemption implementation was live. Cases pre-register ordinary green, the exact artifact, both provider controls, near-miss signatures, an independent failure, and a skipped step.
### Control 2 setup attempt — invalid, excluded from evidence
- Commit: `9455cd6a2650b2b7e70f746c07933d96e5cb3d20`
- Pipeline: #2190, exact commit match.
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
- Service log: `/bin/sh: 0: -c requires an argument`.
- Root cause: Woodpecker service `commands` did not become the third `sh -c` argument. PostgreSQL never started, so this run is **not** the post-readiness crash control and provides no discrimination evidence.
- Focused remediation: place the script directly in the third `entrypoint` element and supply `PGPASSWORD` for the marker query. This is a control-fixture correction, not a retry of #1000 and not evidence for either verdict.
## Implementation evidence
- `verify-terminal-green.py` consumes only the full JSON/API record; it performs no fetch, retry, or trigger.
- Exact #2188 record: exit 0, 10 children, 9 success + 1 named exemption.
- Historical set: #2158/#2167/#2184/#2186 pass with no exemption; #2170/#2175/#2182/#2187/#2188 pass with one named exemption; #2180/#2181 remain red because independent failures exist.
- Provider controls: #2189 and #2191 both exit 1 under the verifier; neither is exempted.
- Unit harness: initial 9/9 cases passed after the red-first checkpoint; review remediation expands this to 12 cases with expected-head match/missing/mismatch coverage.
- Test-membership guard: PASS, population 45; 26 enumerated, 19 signed exclusions; all 39 surface paths present.
- Python compile: PASS.
- `pnpm typecheck`: PASS, 45/45 tasks.
- `pnpm lint`: PASS, 25/25 tasks.
- `pnpm format:check`: PASS after moving local evidence outside the repository tree.
- `test:framework-shell`: RM-61 and all preceding suites passed, then the pre-existing wake assertion aborted with exit 97 because this host's Bash 5.2.15 reports `BASH_LINENO [3 5]` where that suite requires `[3 4]`. RM-61 does not modify the wake suite; the command is not fully runnable on this host as written and no substitute result is claimed.
## Independent review
- Review 67 / comment 20403 at exact head `e7b29219e11efd0a19395156ac0b154bec0c3a73`: **REQUEST CHANGES**.
- Blocker: the verifier echoed the pipeline commit but did not bind it to the current PR head; mutating only #2188's commit still returned terminal-green.
- Remediation: require `--expect-commit <full-40>`, add a pipeline anomaly on missing/mismatched record commits, emit expected and observed values, wire both CI documentation and the merge-gate baseline to pass provider PR head, and add match/missing/mismatch tests.
- This binding is not prohibited head-based clustering policy: it proves the evidence belongs to the commit under verdict. Runner/node/time/head correlation remains excluded from the teardown signature itself.
- Review 69 later approved the commit-binding remediation at exact head `033b2ffb46674b2c0bcc5197273c109b461f62d9`; pipeline #2193 was 9/9 success. Before merge-gate, an independent adjudicator found that Python treats JSON `false == 0`, allowing a non-integer exit value to match. The prior gate-ready state was withdrawn. The type-strict set distinguishes genuine red-first controls (`false`, `0.0`, which wrongly exempted) from regression guards (`true`, `"0"`, `null`, which already blocked). Remediation requires the decoded type to be exactly `int` and excludes `bool` explicitly.
## Documentation checklist
- [x] CI contract documented in the canonical framework CI/CD guide.
- [x] Operator command documented in the Woodpecker tool README.
- [x] Merge-gate baseline points to the deterministic verifier and named retirement.
- [x] Tracking and retirement cite issue #1000.
- [x] Both positive and negative guarantee boundaries are stated.
- [x] No API/auth/schema/user-facing navigation change; OpenAPI, user guide, and sitemap are not applicable.
## Risks
The controls establish discrimination for deterministic startup failure and an armed post-readiness postmaster crash on the current Woodpecker Kubernetes provider. They cannot prove that every future Kubernetes failure mode will preserve a non-zero exit before reconciliation. The exact matcher minimizes that residual risk, and issue #1000 remains the mandatory provider-seam closure and retirement trigger.
+65
View File
@@ -0,0 +1,65 @@
# #1146 — CI Queue Guard Purpose Semantics
## Objective
Make the pre-push queue guard wait for queued/running CI without requiring the previous remote head to have successful CI. Preserve fail-closed merge readiness.
## Scope
- `packages/mosaic/framework/tools/git/ci-queue-wait.sh`
- focused queue-guard regression tests
- design and scratchpad documentation
- local framework activation required before the fixed guard can authorize this branch's push
## Plan
1. Freeze purpose-specific behavior in failing process-level tests.
2. Implement the smallest state-dispatch change.
3. Run focused shell tests and repository quality gates.
4. Obtain independent review and remediate findings.
5. Install the reviewed framework source locally, run the mandatory pre-push queue guard, and push.
6. Open a PR against `next`, verify terminal-green CI, and close #1146 after merge.
## Budget
- ASSUMPTION: no explicit token cap was provided.
- Working estimate: 12K tokens.
- Scope reduction: change only final-state dispatch and focused tests; do not redesign provider adapters.
## Progress
- Confirmed source and installed guards are byte-identical.
- Reproduced `terminal-failure` blocking `--purpose push`.
- Root cause: final-state dispatch requires terminal success for both push and merge.
- Design approved: push is queue-clear on valid non-pending states; merge remains fail-closed.
## Tests
- RED confirmed before implementation: the focused tri-state harness reported push `terminal-failure` and `no-status` as `ASSERTED_NOT_READY`.
- GREEN: `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` — all outcome classes passed.
- GREEN: `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh` — 6/6 purpose-aware cases passed.
- GREEN: `bash -n` passed for the changed guard and both focused harnesses.
- GREEN: `pnpm preflight`, `pnpm typecheck`, and `pnpm lint` passed.
- `pnpm test` ran 45/46 workspace test tasks successfully, but the pre-existing Gateway `cross-user-isolation.test.ts` failed during cleanup with PostgreSQL error `28P01` (local `mosaic` password authentication failure). The changed Mosaic framework test task passed within that run.
- GREEN: focused queue and merge shell regressions passed after the wrapper change.
- GREEN: isolated Mosaic Vitest run passed (81 files, 1,514 tests).
- The normal parallel Mosaic Vitest run has an environment-sensitive pre-existing failure in `install-ordering-guard.spec.ts`: the real activation probe changes between two calls while other suites run concurrently. Running the same spec alone and the complete Vitest suite with one fork passes.
- The framework shell suite's pre-existing `version_coupling_unittest.py` also fails locally because the newly installed `mosaic` is now on PATH despite the test injecting a nonexistent PATH; CI's clean image does not have this global CLI. All changed queue/merge harnesses pass.
- GREEN: `pnpm format:check` passed.
- Note: an additional ad hoc Prettier command was not applicable to shell files because Prettier has no shell parser; the repository-wide format check passed using its configured file globs.
## Review
- Independent Codex review of the six-file diff: approved, confidence 0.84, zero blockers/should-fix/suggestions.
- Review confirmed push queue-clear behavior, merge fail-closed behavior, and `--require-status` coverage.
## Risks and Blockers
- Canonical framework activation completed with `MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep MOSAIC_SKIP_SKILLS_SYNC=1 bash packages/mosaic/framework/install.sh`.
- Source and installed queue guards are byte-identical (`cmp` and SHA-256 parity passed).
- The installed pre-push guard now passes for the not-yet-remote feature branch with `queue clear`.
- The required merge wrapper then exposed a second bootstrap defect: `pr-merge.sh` hardcoded `main`, contradicting the documented PR-based `next` integration lane. Tracked as #1149 and fixed in the same delivery branch with a regression fixture targeting `next`.
- Activation emitted the existing manifest-safety warning that six `fleet/run/*.hb*` operator files were touched then restored; no data loss was observed, but this remains a pre-existing framework-manifest defect to report separately.
- The first activation attempt timed out after 600 seconds while copying the 113K-file operator snapshot; the bounded 1,800-second retry completed successfully. It left a partial durable snapshot from the interrupted attempt in the normal backup directory; the completed snapshot is the newer `pre-update-20260810T195317Z` entry.
- Full baseline test completion is blocked by the unrelated local PostgreSQL authentication/cleanup failure described above; CI has its own disposable PostgreSQL service.
- Existing `.mosaic/orchestrator/*` working-tree changes are unrelated and must remain unstaged.
@@ -0,0 +1,38 @@
# Scratchpad — FED-M3-06 get verb
## Objective
Implement `POST /api/federation/v1/get/:resource/:id` for M3 inbound federation reads.
## Scope
- `apps/gateway/src/federation/server/verbs/get.controller.ts`
- `apps/gateway/src/federation/server/verbs/get-query.service.ts`
- Unit coverage for controller pipeline + query service RBAC guardrails
- Register controller/service in `FederationModule`
## Plan
1. Mirror the list verb pipeline: `FederationAuthGuard``FederationScopeService` → read-only query service.
2. Return one `_source: "local"` tagged item on success.
3. Return federation error envelopes:
- `404 not_found` when the resource id does not exist.
- `403 scope_violation` when the row exists but falls outside native RBAC/scope intersection.
- `400 invalid_request` for malformed ids/scope requests.
4. Keep read audit persistence deferred to M4; no body or response persistence in M3.
## Verification Evidence
- Rebased onto `origin/main` at `86e106fcc9a1dfa3a18f7846bb477be128794aad` after M3-05 merged; resolved `FederationModule` by registering both list and get verb controllers/services.
- Review-change coverage added for comment 15971:
- get note access now requires subject ownership AND authorized mission intersection.
- missing federation context returns structured `401 unauthorized` envelope.
- unsupported get resources fail closed with structured denial.
- PGlite regressions cover cross-user note exclusion and subject-note unauthorized-mission exclusion.
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/verbs/__tests__/get.controller.spec.ts src/federation/server/verbs/__tests__/get-query.service.spec.ts` — pass (2 files / 17 tests; re-run after review changes).
- `pnpm --filter @mosaicstack/gateway build` — pass (re-run after review changes).
- `pnpm build` — pass (23 successful tasks before review changes).
- `pnpm typecheck` — pass (41 successful tasks; re-run after review changes).
- `pnpm lint` — pass (23 successful tasks; re-run after review changes).
- `pnpm format:check` — pass (re-run after review changes).
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings after review changes.
@@ -0,0 +1,82 @@
# B1 / @next Durable Publish Pipeline — Design
## Objective
Make `next` a durable integration line that publishes the artifacts required by downstream federation boot tests without manual builds.
Every merge to `next` publishes:
1. **npm prerelease packages** to the Gitea npm registry with dist-tag `next`.
2. **Gateway container image** tagged only as `gateway:sha-<short>`.
The existing stable release behavior remains isolated to `main` / tags.
## Registry verification
Target registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`.
Pre-implementation checks:
- `npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json` returned a dist-tags object (`latest: 0.0.48`).
- `npm view @mosaicstack/mosaic@latest version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/` resolved `0.0.48`.
- `@next` currently returns 404 because no `next` dist-tag exists yet; this is expected before the first next prerelease publish.
Pipeline design includes a post-publish verification that `npm view @mosaicstack/mosaic@next version` resolves to the exact CI-computed prerelease version. If Gitea fails to honor the `next` dist-tag, the pipeline fails closed.
## Version scheme
The prerelease version is computed at publish time only; no `package.json` version changes are committed.
For each non-private `@mosaicstack/*` package:
```text
<target-stable>-next.<CI_PIPELINE_NUMBER>
```
Where:
- `CI_PIPELINE_NUMBER` is Woodpecker's monotonic pipeline number.
- `target-stable` is the package's current committed stable version with the patch component incremented.
- Example: `@mosaicstack/mosaic` `0.0.48` publishes as `0.0.49-next.1626`.
- Example: `@mosaicstack/gateway` `0.0.6` publishes as `0.0.7-next.1626`.
Rationale:
- npm semver sorts `0.0.49-next.1627` above `0.0.49-next.1626`.
- The prerelease does not overtake the future stable `0.0.49`.
- The monotonic pipeline number avoids conflicts across repeated `next` merges.
## Branch and tag guardrails
| Pipeline path | Branch/event | Publishes | Forbidden |
| --------------------- | ------------------------------ | ------------------------------------------------------- | ---------------------- |
| stable npm publish | `main` push/manual or tag | package versions already committed in package manifests | `@next` dist-tag |
| next npm publish | `next` push/manual only | CI-computed prereleases with `--tag next` | `latest` dist-tag |
| gateway image | `main` push/manual or tag | `sha-<short>` + `latest` on main + tag on tag events | next prerelease npm |
| gateway image | `next` push/manual only | `sha-<short>` only | `latest` |
| appservice/web images | `main` push/manual or tag only | existing stable image behavior | next image publication |
The pipeline has explicit branch checks inside the publish commands as a second fail-closed layer beyond Woodpecker `when` clauses.
## Implementation plan
1. Widen `.woodpecker/publish.yml` top-level `when` to include `next` so the publish pipeline runs on next merges.
2. Keep existing `publish-npm` on `main` / tags only.
3. Add `publish-next-npm` for `next` push/manual only:
- configure Gitea npm auth from existing `gitea_token` secret as `NPM_TOKEN`;
- preflight registry dist-tag metadata;
- compute prerelease versions in CI by temporarily editing package manifests in the workspace;
- run `pnpm publish ... --tag next` against non-private `@mosaicstack/*` packages;
- verify `@mosaicstack/mosaic@next` resolves to the computed version.
4. Split image `when` anchors:
- `image_build_when` includes `next` and is used by `build-gateway`;
- `main_image_build_when` keeps appservice/web on main/tags only.
5. Keep gateway next image destinations to `sha-<short>` only; no `latest` on next.
## Risk controls
- Auth/registry failures are fatal.
- No manual image build/push path is introduced.
- No production `latest` tags are touched from `next`.
- No `@latest` npm dist-tags are touched from `next`.
- All changes live in CI config and docs; no runtime source behavior changes.
+34
View File
@@ -0,0 +1,34 @@
# B2 — Fresh-install skills sync path
## Problem
Greenfield wizard on `next` reported:
```text
Skills sync script not found at ~/.config/mosaic/bin/mosaic-sync-skills
Skills: install failed
```
## Diagnosis
The framework install migration removed the legacy `~/.config/mosaic/bin/` directory and now installs framework helper scripts under:
```text
~/.config/mosaic/tools/_scripts/
```
`packages/mosaic/src/stages/finalize.ts` still resolved wizard helper scripts from `mosaicHome/bin`, so wizard-selected skills failed even though `mosaic-sync-skills` was present in the current framework layout.
## Fix
- Resolve framework helper scripts through `tools/_scripts/<name>` first.
- Keep a legacy `bin/<name>` fallback for pre-migration installs.
- Point missing-script warnings at the current `tools/_scripts` layout.
- Update the finalize skills test fixture to model the fresh framework layout.
- Update framework README examples from legacy `bin/` helper paths to `tools/_scripts/`.
## Verification
- Unit: `pnpm --filter @mosaicstack/mosaic test -- finalize-skills`
- Gates: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build`
- Fresh path: ran `packages/mosaic/framework/install.sh` with a temp `MOSAIC_HOME` and `MOSAIC_SYNC_ONLY=1`; verified `tools/_scripts/mosaic-sync-skills` exists, legacy `bin/mosaic-sync-skills` does not, and the script installs a selected fake `lint` skill into Mosaic + Pi runtime skill directories.
@@ -0,0 +1,36 @@
# B3 — Wizard completion ordering
## Problem
The wizard printed the success summary / `Mosaic is ready.` during `finalizeStage`, before the gateway configuration stage had completed its daemon health check. If the gateway health gate later failed, the user could see a success claim followed by a gateway failure.
## Diagnosis
`finalizeStage` handled both mutation work and terminal success messaging. Wizard paths then ran `gatewayConfigStage` and `gatewayBootstrapStage` afterward:
1. finalize writes config, links runtime assets, syncs skills, runs doctor;
2. finalize prints `Installation Summary` + `Mosaic is ready.`;
3. gateway config starts/waits for daemon health;
4. gateway bootstrap runs.
The summary needed to be deferred until after the gateway readiness gates.
## Fix
- `finalizeStage` now returns a `showSummary()` callback and supports `deferSummary`.
- Wizard/quick-start paths call finalize with `deferSummary: true`.
- `showSummary()` is called only after gateway config reports ready and bootstrap completes, or immediately when the caller explicitly skips gateway setup.
- If gateway health/config reports not ready, the wizard returns/aborts without printing the success summary.
- Folded in adjacent runtime install hint fix for Pi: `curl -fsSL https://pi.dev/install.sh | sh`.
## Verification
- Added unified-wizard coverage for summary-after-health and no-summary-on-health-failure.
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard finalize-skills`
- `pnpm format:check`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm build`
- `pnpm test`
- Codex code review: approve.
- Codex security review: one low finding on the requested Pi `curl | sh` install hint; no security finding in the wizard completion-ordering change.
+36
View File
@@ -0,0 +1,36 @@
# B4 — Wizard step deduplication
## Problem
Greenfield wizard testing showed completed wizard steps could be executed again after the menu marked them `[done]`. In practice this made the Providers/API-key flow and Skills flow appear twice in one wizard run.
There was a second related API-key duplication path: when the Providers step was completed with no key, `gatewayConfigStage` still prompted for `ANTHROPIC_API_KEY` during Finish because it only skipped the gateway API-key prompt when `providerKey` was non-empty.
## Diagnosis
- `runMenuLoop` labeled completed sections with `[done]`, but still dispatched the selected step again if the user selected that row.
- Quick Start ran Providers and Skills but did not mark those sections complete in `completedSections`.
- `runFinishPath`/`quickStartPath` defaulted `providerType` to `none` for gateway config, which made it impossible for `gatewayConfigStage` to distinguish:
- provider step completed and user intentionally skipped the key, vs.
- provider step was never run.
## Fix
- Added a shared menu section key helper and a completed-step guard in `runMenuLoop`.
- Completed menu steps now log a skip message instead of re-running their stage.
- Quick Start marks Providers and Skills complete after running them.
- Finish/Quick Start now pass `state.providerType` as-is to gateway config instead of defaulting to `none`.
- `gatewayConfigStage` treats `providerType: 'none'` as an explicit completed provider setup with no key and skips the second gateway API-key prompt.
## Verification
- Added unified wizard regression coverage asserting repeated Providers/Skills menu selections only execute each stage once.
- Added gateway config coverage asserting `providerType: 'none'` does not prompt for a gateway API key and writes no API key env var.
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard gateway-config`
- `pnpm format:check`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm build`
- `pnpm test`
- Codex code review: approve.
- Codex security review: no findings.
@@ -0,0 +1,60 @@
# FED-M3-10 — Federation M3 Integration Tests
## Objective
Add single-gateway gateway integration tests for M3 acceptance #6 and #7.
## Branch / base
- Branch: `feat/federation-m3-integration`
- Base: `origin/next` (`838701bd` after M3-06/#683 merge)
- PR base when unblocked: `next`
## Scope
- Real PostgreSQL via `@mosaicstack/db`.
- Mocked TLS context / Fastify request shim for `FederationAuthGuard`.
- Direct controller calls using the real M3 route contract: `POST /api/federation/v1/list/:resource` with body `{ limit?, cursor? }`.
- Gated by `FEDERATED_INTEGRATION=1`.
- No federation harness dependency.
## Fixture notes
Aligned with the B2 seed design vocabulary:
- `tasks` visibility uses personal `projects` + `missions` chain.
- `notes` are `mission_tasks.notes`; the integration suite asserts subject-only note visibility on an authorized mission.
- Seed includes a second user and unauthorized team/project tasks to prove exclusion from the max-row-cap list result.
- Grants/peers are direct DB fixtures; cert auth still runs through `FederationAuthGuard` using real X.509 certs generated by existing test helpers.
## Current implementation
Added `apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts` covering:
1. M3 #6 — cert missing Mosaic OIDs returns 401 federation `unauthorized` envelope.
2. M3 #6 — valid cert whose grant row is `revoked` returns 403 federation `forbidden` envelope.
3. M3 #7 — active grant with `max_rows_per_query: 2` caps `list tasks`, returns `_truncated` + `nextCursor`, source-tags rows, and excludes other-user / unauthorized-team tasks.
4. Cross-user notes invariant — subject can list their own `mission_tasks.notes` row while another user's note on the same authorized mission is excluded.
5. Unsupported-resource invariant — `list widgets` fails closed with a federation `scope_violation` envelope.
## Verification
- `pnpm --filter @mosaicstack/types build` — PASS.
- `pnpm --filter @mosaicstack/db build` — PASS.
- `pnpm --filter @mosaicstack/storage build` — PASS.
- `pnpm --filter @mosaicstack/brain build` — PASS.
- `pnpm --filter @mosaicstack/queue build` — PASS.
- `pnpm --filter @mosaicstack/config build` — PASS.
- `pnpm --filter @mosaicstack/auth build` — PASS.
- `pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS skipped when `FEDERATED_INTEGRATION` unset (5 skipped).
- `FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS (5 tests) after local `docker compose up -d postgres` + `pnpm --filter @mosaicstack/db db:push`.
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
- `pnpm format:check` — PASS.
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS; approve, no findings.
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS; risk level none, no findings.
## Push / PR
- #683 landed in `next`; branch rebased onto `origin/next` before push.
- CI is serialized; run queue guard before push.
@@ -0,0 +1,40 @@
# Installer `--next` fast npm lane — 2026-06-25
## Scope
Flip `tools/install.sh --next` from source-build-first to fast npm `@next` first, with source fallback.
## Registry reality check
Gitea npm registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`
Verified before implementation:
- `@mosaicstack/mosaic@next` resolves to `0.0.49-next.1633`.
- `@mosaicstack/gateway@next` resolves to `0.0.7-next.1633`.
- `@mosaicstack/gateway` dist-tags include `latest: 0.0.6` and `next: 0.0.7-next.1633`.
- `apps/gateway/package.json` is non-private and has Gitea npm `publishConfig`.
Conclusion: the installer can fast-install both CLI and gateway npm packages for `--next`. The gateway Docker `gateway:sha-<short>` remains the deployment/harness artifact; the npm gateway package is valid for the installer global package path.
## Behavior
- `--next` with no explicit ref:
1. framework archive from `next`;
2. resolve `@mosaicstack/gateway@next` and `@mosaicstack/mosaic@next`;
3. require both resolved versions to share the same `next.<pipeline>` suffix;
4. install the exact resolved package versions;
5. set `MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1` so wizard does not overwrite the prerelease gateway;
6. if either package is missing/unreachable/mismatched/fails, fall back to existing source build at `next`.
- `--dev` remains pure source build.
- explicit `--ref` / `MOSAIC_REF` still wins over `--next` and uses the source path for that exact ref.
## Install detail
The installer writes the scoped npmrc mapping (`@mosaicstack:registry=...`) and then runs npm install without overriding npm's default registry. Passing `--registry=<gitea>` to `npm install` forces public transitive dependencies (for example `@anthropic-ai/sdk`) to resolve from Gitea and breaks the fast path; the scoped npmrc mapping is the correct split-registry behavior.
## Verification notes
- Added `tools/install-next-lane.test.sh` with a fake npm/source harness for exact-version fast install, registry failure source fallback, explicit-ref precedence, and mismatched suffix warning.
- Wired the installer harness into `pnpm test` via `pnpm run test:installer`.
- Real temp-prefix fast install succeeded with `@mosaicstack/[email protected]` and `@mosaicstack/[email protected]`.
@@ -0,0 +1,35 @@
# Scratchpad — installer `--next` lane
## Objective
Add a prerelease installer lane for the permanent `next` integration branch.
## Scope
- `tools/install.sh`
- README/install documentation
- Follow-up design note for future npm `@next` prerelease publishing
## Plan
1. Add `--next` and `MOSAIC_NEXT=1` as source-build shorthand for `next`.
2. Preserve explicit ref precedence: `MOSAIC_REF` and `--ref` win over `--next`.
3. Update installer source display/help text.
4. Document three lanes:
- stable npm `@latest`
- prerelease `--next`
- contributor `--dev --ref X`
5. Run shell and repo gates locally, then hold before push/PR until runner serialization greenlight.
## Verification
- `bash -n tools/install.sh` — pass.
- `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable tools/install.sh` — pass.
- `bash tools/install.sh --check --framework --next` — source display shows `ref: next, --next prerelease lane`.
- `bash tools/install.sh --check --cli --next --ref feature-x` — source display shows explicit ref wins.
- `MOSAIC_NEXT=1 MOSAIC_REF=feature-env bash tools/install.sh --check --cli` — source display shows explicit env ref wins.
- `pnpm install --frozen-lockfile --prefer-offline --store-dir /home/jarvis/.local/share/pnpm/store` — pass (local override for repo `.npmrc` CI store path).
- `pnpm typecheck` — pass (41 successful tasks).
- `pnpm lint` — pass (23 successful tasks).
- `pnpm format:check` — pass.
- `bash tools/e2e-install-test.sh` — attempted; current baseline fails during gateway health after stable registry install because Valkey is unavailable in the clean container. The `tools/install.sh --yes --no-auto-launch` stage itself completed before the downstream gateway verification failure.
@@ -0,0 +1,58 @@
# RM-01 — Reproducible checkout
- Task/ref: RM-01 (`docs/remediation/TASKS.md`, internal mission tracking)
- Objective: make checkout/install/typecheck hooks fail on code rather than environmental residue, for root CI and non-root seats.
- Scope: pnpm store configuration, transactional Husky installation, dependency/generated-state preflight, checkout regression tests, developer documentation.
- Constraints: isolated worktree; no skip-switch fixes; no writes under `/root` or `/tmp`; workers do not edit `docs/remediation/TASKS.md`; author does not review or merge.
- Acceptance: AC1AC8 from the orchestrator dispatch/addendum.
- Plan:
1. Add RED-first tests for missing dependencies, stale/foreign `.next`, and interrupted hook installation.
2. Implement environment-overridable HOME-based pnpm store defaults, deterministic preflight, and transactional hook installation.
3. Run focused tests, install/build/baseline gates, and explicit AC negative controls.
4. Obtain independent review, push after queue guard, open PR, and send evidence to `mos-remediation`.
- Budget: orchestrator estimate 6K/60K; no explicit hard token cap. Keep scope to RM-01 and avoid unrelated cleanup.
- Risks: 97%-full shared `/tmp`; native dependency install size; root-owned fixtures may require Docker for realistic verification.
## Progress / evidence
- Worktree created at `/home/hermes/agent-work/rm-01` from `origin/main` `06e0d403`.
- `/tmp` baseline: 28G used, 889M available (97%); worktree and planned store are on `/home`.
- Root causes confirmed from source: committed `.npmrc` pins `/root`; `prepare` invokes Husky directly; web typecheck includes generated `.next` types without validating ownership/freshness.
## Checkpoint evidence (c45e5e19)
- AC1 IN PROGRESS: non-root `pnpm install --frozen-lockfile --store-dir "$HOME/.local/share/pnpm/store"` exited 0; `pnpm exec turbo run typecheck --force` exited 0 (45/45 uncached). Clean CI-container run not performed.
- AC2 DONE: with `node_modules` absent, `pnpm preflight` exited 42 with `MOSAIC_PREFLIGHT_MISSING_DEPS` and `run pnpm install`; after install it exited 0.
- AC3 DONE: appending `export const x: number = "s"` to `packages/types/src/index.ts` made `pnpm -w typecheck` exit 2 with TS2322; reverting made it exit 0.
- AC4 IN PROGRESS: local `pnpm -w build` exited 0 and `git status --porcelain` showed no generated residue beyond the intended RM-01 source changes. Fresh-clone proof not performed.
- AC5 DONE: non-root install exited 0; `pnpm store path` resolved `/home/hermes/.local/share/pnpm/store/v10`; no `/root` write was attempted.
- AC6 IN PROGRESS: focused failure/rollback tests passed, but final review found a concurrent-install race. Two installers can both observe `.husky/_` absent; after one installs successfully, the losing install's catch path can quarantine the winner's active hooks and restore stale Git config (`scripts/install-hooks.mjs`, activation/catch transaction). A RED regression is committed after the checkpoint.
- AC7 DONE: install/store/worktree were on `/home`; full `pnpm -w build` exited 0; `/tmp` usage changed by 4096 bytes during the build (23,805,173,760 → 23,805,177,856 bytes), not materially.
- AC8 DONE for the implemented path: store resolves under `$HOME`; test/quarantine/build state resolves under the worktree; no implemented component requires a writable path outside `$HOME` or the worktree.
## Continuation evidence
- AC6 DONE: the committed race reproducer was observed RED (`node --test --test-name-pattern='a competing successful installer is not removed by the losing process' scripts/install-hooks.test.mjs`, exit 1/ENOENT), then passed after cleanup became ownership-safe. The losing installer never removes an active hook set or restores Git configuration it did not activate. `pnpm test:checkout` passes 21/21, exit 0, including the original race and a post-rename peer-replacement regression.
- Generated-state remediation: replaced mtime inference with a source/build-input fingerprint, written only after a serialized successful Next build with unchanged inputs. Failed/interrupted/overlapping builds leave no trusted marker. The fingerprint uses Next's own environment loader, covers resolved `NEXT_PUBLIC_*` values, inherited TypeScript configuration, lock/workspace inputs, and rejects symlink inputs.
- Baseline: `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` each exit 0. Local `pnpm test` still exits 97 only at the pre-existing Bash `BASH_LINENO` convention guard (#973/#1003), after checkout tests and package tests pass; this is not reported as a green full-suite result.
- Automated review remediation: resolved findings for peer-hook ownership, stale/failed build markers, build-input changes, expanded environment inputs, inherited TypeScript config, symlink inputs, and overlapping build serialization. Independent PR review remains assigned to rev-974.
- AC1 DONE at `0f706119`: a clean clone created inside `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest` ran the exact acceptance sequence `pnpm install --frozen-lockfile && pnpm -w typecheck`; exit 0 with 45/45 uncached typecheck tasks successful. An earlier bind-mounted clone attempt exited 1 because root in the container rejected the host-owned Git directory; that failed attempt is not counted as evidence.
- AC4 DONE at `0f706119`: in that same fresh clone and CI image, `pnpm -w build` completed 25/25 tasks and the immediately following `git status --porcelain` was empty; combined assertion exit 0.
- Push BLOCKED after the required queue guard: `git push origin fix/rm-01-reproducible-checkout` was rejected by Gitea with `User permission denied for writing` / `pre-receive hook declined`, despite `MOSAIC_GIT_IDENTITY=f10-coder` resolving username `f10-coder` from the provisioned `gitea-mosaicstack-f10-coder.token`.
## Review remediation — restated AC2
- Independent review correctly found that an added symlink under a successfully built `.next` tree passed preflight. The exact reviewer control, `ln -s /etc/hosts apps/web/.next/reviewer-symlink && pnpm preflight`, was observed passing before remediation.
- The original blanket symlink wording conflicts with AC4 because canonical Next `output: 'standalone'` emits legitimate pnpm dependency symlinks. The coordinator independently verified 42 such links and approved the operative restatement: `.next` itself must not be a symlink; descendant symlinks must exactly match the successful build's certified manifest.
- RED-first controls were observed failing together against the prior implementation (exit 1): `.next` root, added, removed, retargeted, tampered-manifest, and canonical-style certified-link cases. The build now publishes the manifest atomically before the existing source certification commit marker; that marker binds the manifest SHA-256. Missing/partial/modified manifests remain untrusted.
- GREEN evidence: the six-case symlink control passes; the exact reviewer-added link exits 43; removing it restores preflight exit 0. The added RED-first build-publication control also proves a symlinked `.next` cannot redirect certification writes outside the checkout. `pnpm test:checkout` passes 23 top-level tests / 29 including subtests. Canonical `pnpm --filter @mosaicstack/web build` and the following `pnpm preflight` both exit 0.
- Threat-model ruling: the manifest detects accidental, independent, stale, and foreign-residue mutation—the class exposed by the five-month-stale `.next` that produced 19 phantom TS2307 errors. It does not defend against a same-UID actor able to rewrite both manifest and marker consistently (CWE-345); no local worktree construction can without an external trust anchor. RM-59 tracks the residual: executor/spine-side attestation outside worktree authority, dependent on RM-12, RM-21, and RM-25.
- AC8 concrete proof at `df7530ae`: a clean clone ran in `ci-base:latest` with Docker `--read-only`; its only writable mounts were `/workspace` (the worktree) and `/home/ci` (`HOME`, with `NPM_CONFIG_STORE_DIR=/home/ci/store`). `pnpm install --frozen-lockfile && pnpm -w typecheck` exited 0 with 45/45 uncached tasks. This proves the implemented checkout path requires no writable location outside `$HOME` and the worktree. An initial fixture attempt failed only because Git required `/workspace` safe-directory setup; it is not counted as evidence.
## Handoff
1. Keep the newly committed RED tests red until implementing: (a) source-fingerprint marker support for valid incremental `.next` output, and (b) ownership-safe concurrent hook activation.
2. The latest automated review rejected oldest-generated-file mtime as a false positive for valid incremental Next output. Use a source-content fingerprint marker written only after successful `next build`; do not continue tuning mtimes.
3. For Husky, generation in an isolated temporary Git repo avoids mutating real `core.hooksPath` during staging. Preserve that design. Fix the losing concurrent process so it never removes a peer's completed hook set or restores stale config.
4. Codex review runs in a read-only sandbox, so its attempts to run the fixture-writing Node tests report opaque test-file failures. The same tests run normally in the worktree.
5. Full `pnpm test` is not green on this host: it exits 97 at the pre-existing Bash `BASH_LINENO` convention guard (#1003), after the changed checkout tests and package tests pass. Do not weaken that gate.
+120
View File
@@ -0,0 +1,120 @@
# RM-03 — CI Queue Guard Repair
- **Task:** RM-03
- **Issue:** #1019
- **Branch:** `fix/rm-03-queue-guard`
- **Owner:** coder-mos1
- **Reviewer:** rev-974 (independent; author != reviewer)
- **Started:** 2026-08-01
## Objective
Repair the mandatory CI queue guard so it reads provider payloads, blocks asserted non-green CI, distinguishes provider unavailability from a real non-green result, and inspects the branch actually being pushed or merged.
## Constraints
- Worktree only: `/home/hermes/agent-work/rm-03`; never mutate `/src/mosaic-stack`.
- JSON payload travels through stdin; never argv. Large payload must remain below no ARG_MAX dependency.
- TDD is mandatory. Every behavior case must be observed red before implementation.
- No bypass flags or hook suppression.
- Do not cite the existing guard's green as evidence; D-23 establishes it is zero-information.
- Gate-ready is a frozen exact head. Any push after a merge-gate verdict voids that verdict.
- No merge: coordinator holds the merge hand pending Jason.
## Design
1. Feed JSON to `python3 -c` on stdin, including pending-context rendering.
2. Classify valid green as `READY`; pending/failure/no-status/malformed/mixed as `ASSERTED_NOT_READY`; provider/credential/transport inability as `CANNOT_ASSERT`.
3. `ASSERTED_NOT_READY` exits nonzero. `CANNOT_ASSERT` emits a loud diagnostic and appends a local JSONL audit record. Push degrades to exit 0; merge holds with distinct retryable exit 75 until provider recovery, then self-clears without manual reset. Inability to write the audit exits nonzero.
4. Derive the current branch when `-B` is omitted. The merge wrapper passes the exact PR head branch, repository, and full commit SHA—not its `main` base—so fork PRs cannot resolve against an adjacent base-repository branch.
## Test matrix
| Case | Required outcome |
| --- | --- |
| success | exit 0; terminal-success |
| pending | nonzero after bounded timeout |
| failure | nonzero |
| no-status | nonzero |
| malformed | nonzero |
| >=150 KiB payload | unchanged classification; never rc126 |
| provider unreachable on push | loud audited CANNOT_ASSERT; degraded exit 0 |
| provider unreachable on merge | loud audited CANNOT_ASSERT; retryable exit 75/HOLD |
| audit unavailable | nonzero |
| implicit push branch | provider URL uses checked-out feature branch |
| merge wrapper | queue guard receives exact PR head branch/repository/full SHA |
## RED-first evidence
Observed against the unmodified `origin/main` implementation before source edits:
- `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` → rc 1 with 15 failed assertions.
- Success payload was reported `state=unknown`.
- Pending, failure, no-status, and malformed payloads each exited 0 and omitted `ASSERTED_NOT_READY`.
- The 160 KiB payload produced rc 141 because Python never consumed the pipe; it did not classify success.
- Provider-unreachable exited 7 with no `CANNOT_ASSERT` audit record.
- Implicit push queried `/branches/main`, not `/branches/fix/rm-03-fixture`.
- Audit-unavailable emitted no audit diagnostic.
- A credential-resolution hard-block mutant was then run before trusting that added case: `credential-unresolvable` returned rc 1 and omitted `CANNOT_ASSERT`; the matrix returned rc 1 with two named assertion failures.
- Review-blocker controls were observed red: structurally invalid `statuses` string and null-entry payloads each exited 0 as `terminal-success`; unsupported-platform discovery exited 1 without diagnostic or audit (seven named assertion failures total).
- After the push/merge asymmetry ruling, merge-side provider unavailability was observed red at rc 0; its registered case required distinct retryable rc 75.
- Aggregate `state=success` with zero contexts was observed red: it exited 0 as `terminal-success`; the registered case requires `no-status`/nonzero.
- Fork/exact-head controls were observed red: `pr-merge.sh` omitted the fork repository and full SHA, and an ignored-arguments mutant re-resolved through `/branches/` instead of the exact fork commit (two named failures).
- GitHub check-run-only success/pending/failure were each misclassified as `no-status`; the RED run had five named failures and proved the Checks API was never queried.
- The first merge-pin control was unrunnable because one `local` declaration referenced a variable before assignment under `set -u`; this was disclosed and corrected rather than counted. The runnable RED then showed Gitea payload `{"Do":"squash"}` lacked `head_commit_id`; a separate GitHub run showed `gh pr merge 123 --squash` lacked `--match-head-commit`.
- A stale-verdict mutant removed the `--expect-head` comparison and was observed red because a moved head reached the provider merge call.
- `bash packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh` initially returned rc 1; captured call was `--purpose merge -B main -t 900 -i 15`.
Logs remain untracked under the worktree as `.mosaic-test-work-red-*.log` and will not be committed.
## Progress
- [x] Mission, remediation charter, task evidence, board, issue #1019, and superseded PR #1023 read.
- [x] Isolated worktree created and identity configured coherently.
- [x] Mutant tests authored and observed red.
- [x] Implementation green.
- [x] Baseline and focused situational gates green; full package suite has an unrelated framework-shell environment abort recorded below.
- [ ] Independent review clean (rev-974 requested changes at `44ffa99a`; bypass remediation committed and awaiting re-review).
- [ ] PR CI terminal-green at exact head by full step scan.
- [ ] Merge-gate verdict issued against frozen head.
## Scope disposition
- The five framework guides are consequential documentation: they define the purpose-aware tri-state contract, including audited push degradation and merge HOLD.
- The agent templates are consequential because they ship the same queue-guard instructions into newly seeded agent contracts; leaving them binary/stale would contradict the repaired tool.
- `pr-merge.sh` is consequential: it must inspect the PR's exact head branch/repository/SHA and enforce the exact-head merge pin.
- `pr-metadata.sh` is consequential only as the normalized source of that head branch/repository/SHA. Its diff is limited to exposing those fields on GitHub and Gitea.
- `test-pr-merge-gitea-empty-uid.sh` changes because exact-head Gitea merges now always use the API path (the only path that can send `head_commit_id`), superseding the prior tea-empty-identity fallback behavior.
## Review remediation
- rev-974 independently proved that the documented `--skip-queue-guard` merge option bypassed an exit-99 guard stub, reached the provider merge payload, printed success, and exited 0 at head `44ffa99a`.
- RED-first reproduction was added to `test-pr-merge-head-pin.sh` before the production fix: `FAIL merge-bypass: --skip-queue-guard reached the provider merge path`, suite rc 1. The test-only commit is `241113e6`.
- Production remediation `37aae650` removes the option from parsing, usage, help, and examples. Every merge-capable path now invokes the queue guard; `--dry-run` alone omits it and has a regression proving that it exits before provider dispatch and creates no merge payload.
- Existing Gitea merge tests now exercise a successful guard response rather than bypassing the guard.
## Risks / boundaries
- The local JSONL audit is durable operational evidence but not tamper-resistant against the same UID. RM-03 does not claim otherwise.
- Push-side audited exit 0 is an explicit owner ruling (Option B), accepted to avoid bricking recovery work; merge-side CANNOT_ASSERT remains retryable exit 75/HOLD. The automated security reviewer continues to flag the deliberate push availability tradeoff.
- Source/deployed-copy equality is owned by RM-02/D-22; this branch changes repository source and its tests only.
## Test evidence
Fresh after rescue checkpoint `b7175012`:
- Focused situational matrix: tri-state, GitHub checks pagination, branch-absent, merge head branch/repository/SHA, exact-head pin, and Gitea exact-head API regressions all passed.
- `bash -n` on the three production shell scripts passed.
- `shellcheck -x -P packages/mosaic/framework/tools/git ...` on all changed shell scripts passed.
- `pnpm typecheck` passed (45/45 Turbo tasks).
- `pnpm lint` passed (25/25 Turbo tasks).
- `pnpm format:check` passed.
- `pnpm --filter @mosaicstack/mosaic test`: Vitest passed 1508/1508 on the confirmation run; framework-shell then aborted at the pre-existing wake coordinate assertion with exit 97: `BASH_LINENO ... probe reported [3 5], expected [3 4] ... (#973)`. This is outside the RM-03 diff and is disclosed rather than substituted or called green.
- The prior package-suite attempt had one transient, out-of-diff `install-ordering-guard.spec.ts` failure (1/1508); its isolated rerun passed 19/19 and the confirmation full Vitest run passed 1508/1508.
- After bypass remediation: all six focused RM-03 queue/merge regressions passed, including bypass refusal and dry-run non-dispatch; shell syntax and source-aware ShellCheck passed; `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` passed.
- Fresh `test:framework-shell` reached and passed every RM-03 test, then again aborted at the unrelated wake coordinate assertion with exit 97; it remains explicitly non-green rather than substituted.
- An ad hoc raw Prettier invocation over `.template` and `.sh` files was unrunnable because no parser is registered for those extensions; it was not used as a substitute for canonical `pnpm format:check`.
## Final evidence
Pending.
+114
View File
@@ -0,0 +1,114 @@
# WebUI Fleet Bridge Planning Scratchpad
**Mode:** Task 0 docs-only decision PR authorized; Task 1 and all executable/runtime work remain blocked.
**Owner:** Velma
**Opened:** 2026-08-09
**Scope:** One enrolled Agent Host launching one native `mosaic claude` OAuth session and streaming it into `apps/web` through `apps/gateway`.
## Objective
Turn the approved direction in `jarvis-brain/docs/scratchpads/MOSAIC-WEBUI-FLEET-BRIDGE.md` into a test-first implementation plan while preserving Fred's harness-home/launcher contract and Scooby's greenfield safety findings.
## Source reconciliation
- Current `origin/main`: `b0f7d26dd9c14d91eaaefc35d6c9fd6618a0bd92`.
- Current `origin/next`: `4df478cdd150fdf8d52ea109f02ade5d85017acd`.
- Branches currently diverge (`main` has 11 unique commits; `next` has 13). `next` contains local-tier Redis fix #689; `main` contains later fleet/shell fixes.
- Fred's three-root harness-home design and promotion stack are not yet fully present on either baseline.
- Therefore neither current SHA is an acceptable implementation pin. Code may begin only from a Fred-certified reconciled SHA containing the required launcher/home contract and safe Gateway startup prerequisites.
- The deployed `mosaic.woltje.com` v0.0.20 UI remains reference-only.
## Verified seams
- Current browser chat uses an in-process Pi SDK session.
- `AgentRuntimeProvider` supports list/tree/stream/send/attach/detach/terminate, but not create/start.
- `InteractionController` enrolls an already-existing runtime session; it cannot launch one.
- Hermes is the only runtime provider registered in Gateway.
- Tmux streaming is explicitly unsupported and remains out of scope.
- `mosaic claude` is the authoritative launcher and accepts Claude's machine-facing stream-json flags.
- Installed discovery version: Claude Code 2.1.226. Target Distrobox version must be independently pinned and certified.
## Non-negotiable dependencies
1. Fred approves the machine-facing launcher/seat-home contract before code.
2. No WebUI/Gateway direct read of lease broker state, daemon socket, or state files (F-V3).
3. No provider OAuth token leaves the Agent Host.
4. No local Gateway/Web startup around the KBN/database hold.
5. Greenfield work runs in a Debian Distrobox with an isolated home.
6. The initial plan PR targets `next`; Fred binds D1D15 on its exact head before issue/PRD/tracker completion or implementation work.
## Reproducible evidence
Run from a clean Stack clone:
```bash
git fetch origin main next
git rev-parse origin/main origin/next
git rev-list --left-right --count origin/main...origin/next
rg -n "interface AgentRuntimeProvider|createSession|streamEvents|terminate" \
packages/types/src/agent packages/agent/src apps/gateway/src/agent
rg -n "AgentService\.prompt|interaction_sessions|createRuntimeTerminationApproval" \
apps/gateway/src packages/db/src/schema.ts
```
Primary inspected source seams:
- `packages/types/src/agent/agent-runtime-provider.ts`
- `packages/agent/src/{runtime-provider-registry,hermes-runtime-provider,matrix-native-runtime-provider,tmux-fleet-runtime-provider}.ts`
- `apps/gateway/src/agent/{runtime-provider-registry.service,interaction.controller,durable-session.repository,durable-session.service}.ts`
- `apps/gateway/src/chat/chat.gateway.ts`
- `packages/mosaic/src/commands/{launch,interaction}.ts`
- `packages/mosaic/src/fleet/generated-env-boundary.ts`
- `packages/db/src/schema.ts`
- `apps/web/src/app/(dashboard)/chat/page.tsx`
Planning-only investigation transcripts are local and intentionally uncommitted:
- `/tmp/velma-plan-stack-surface.txt`
- `/tmp/velma-plan-structure.txt`
- `/tmp/velma-plan-scooby.txt`
- `/tmp/velma-plan-runtime-contract.txt`
## Source findings that constrain the design
- `interaction_sessions.id` is the stable primary key; there are no create/policy/enrollment/state columns.
- `interaction_outbox` has a unique `(session_id, idempotency_key)` index and only `pending | processing | delivered`.
- Baseline `DurableSessionRepository.create()` can replace provider/runtime identity for the same owner; M1 must remove that implicit mutation.
- Baseline termination approval is Redis-backed and currently consumes separately from PostgreSQL; M1 therefore needs durable authorization acceptance before destructive token deletion/dispatch.
- Existing interaction HTTP base is `/api/interaction/:agentName`; the plan extends it rather than inventing a second route family.
- Baseline `launch.ts`/lease launcher still use ambient lookup/literal interpreters. Section 4.1 is non-binding consumer input to Fred's W-F design; W-F's final resolved-launch contract must exist in the certified base before Velma can certify it.
- Root `pnpm test` is not KBN-safe: it includes PGlite migration and framework-shell/lease-broker suites.
## Independent draft review
Seven adversarial review rounds found and drove explicit fixes for:
- a candidate resolved-seat consumer descriptor and threat model, now explicitly non-binding input to Fred's W-F-owned launcher design;
- one active launch per stable conversation, exact pending/failed encodings, durable pre-dispatch reservation, CAS activation, and crash lookup without a migration;
- generation-bound enrollment, command/event revalidation, `SIGHUP` config epochs, and stale-epoch rejection;
- Redis/PostgreSQL exact-stop crash safety via non-destructive verify, durable authorization acceptance, atomic claim/`GETDEL`, and same-operation status reconciliation;
- sequence gaps/reorder limits, deterministic UUIDv5 completion, and Gateway restart fail-closed behavior;
- shared streaming redaction before host ring/transport and again before Gateway persistence/browser;
- D2/D12-selected path-free provenance and a separate safe browser DTO—never raw or hashed path strings;
- exact F-V3 boundary: bridge has no broker API, while Fred's sealed launcher may enforce broker policy internally;
- migration-free focused tests in Tasks 18, with live OAuth, repository transaction, Gateway/Web, and Playwright restricted to the Fred/Scooby-certified Task 9 path;
- candidate private/public commitment and artifact-binding mechanisms that W-F may accept, simplify, replace, or defer;
- JCS event-digest recomputation and equal/different duplicate handling in both accepted and future-buffer states;
- a safe browser presentation DTO for host/workspace/seat/persona labels, readiness, and connection state;
- explicit `OnApplicationBootstrap` create/stop recovery enumeration with no auto-launch;
- all POSIX/Windows/UNC/file/tilde path classes in the streaming redactor and definitive failed-stop response semantics;
- the full 14-column canonical task schema, fake-only Task 5 repository tests, migration-free certified Task 9 DB test, server-owned operation correlations/routes, and exact merged-SHA smoke;
- per-commit independent review, queue guards, exact-head PR review, squash merge, exact merged-next SHA/CI wait, worktree-bound smoke, issue-state readback, and reviewed tracker-closure PR with its own merged-next CI;
- a private DB URL loader captured/exported per session without recording or echoing the credential;
- an attached Task 9 implementation branch, provider-filtered exact issue-state readback, and machine-verified smoke JSON binding source/worktree/deployed SHA;
- a capped, duplicate-key-rejecting, exact-key/type smoke schema so report extensions or JSON boolean/float coercion cannot smuggle data or fake child-count evidence.
The plan remains deliberately **decision-PR-ready, not implementation-ready**. Fred authorized only Task 0's initial two-document PR on `next`. He must still return every Section 2 value, replace all `[FRED-GATE]` entries, certify storage/startup, and provide the W-F-dependent `IMPLEMENTATION_BASE_SHA` before Task 0 closes or Task 1 starts.
## Current status
- Gitea principal verified as `velma`; helper and API wrapper resolution are fail-closed and correct.
- Fred authorized the initial Task 0 docs-only PR in `comms/20260809T094952Z__from-fred__ec0e85.md` and confirmed its `next` lane/W-F descriptor corrections in `comms/20260809T095437Z__from-fred__4ee79d.md`.
- Provisional decisions: D1/D8/D9/D10/D13/D14 approved; D4 tool labels exactly `{Read, Grep, Glob}`; D5/D12 provisional; D11 remains a single-operator seam; D2/D3/D6/D7 and `IMPLEMENTATION_BASE_SHA` are `BLOCKED-ON-W-F`.
- Awaiting exact plan-PR-head review and Fred's complete D1D15 binding contract.
- No source code, database, deployment, or live runtime changes made.
+172
View File
@@ -0,0 +1,172 @@
# WebUI Phase P — P2 Data + Auth Scratchpad
**Task ID:** WEBUI-P2
**Tracking ref:** stacked on PR #1143 (`feat/webui-p1-vite-skeleton`); no separate issue specified in the author brief
**Branch:** `feat/webui-p2-data-auth`
**Started:** 2026-08-09
**Role:** P2 author worker (must not modify `docs/TASKS.md`)
## Original tasking
> Read `/home/jwoltje/briefs/P2-brief.md` and execute it fully in `/home/jwoltje/src/stack-p1` on branch `feat/webui-p2-data-auth`. Run every listed verification gate, commit locally only, and do not push.
Container path mapping:
- Brief: `/home/jwoltje/distrobox-homes/mosaic-dev/briefs/P2-brief.md`
- Repo: `/home/jwoltje/distrobox-homes/mosaic-dev/src/stack-p1`
## Objective
Make the P1 Vite SPA authenticate against the Mosaic Gateway by same-origin relative paths, replace guest-route placeholders with real login/register/SSO callback pages, and enforce guest/authenticated route guards without modifying the parallel Next app tree except for unavoidable shared-library import fixes.
## Scope and invariants
- All SPA HTTP and Socket.IO access remains origin-relative (`/api/...`, `/api/auth/...`, `/chat`).
- No `NEXT_PUBLIC_*`, `VITE_*` origin setting, or hard-coded `http://localhost:14242` under `apps/web/src/`.
- Verify the Gateway BetterAuth mount path from source before choosing auth-client configuration.
- Delete the legacy static SSO-provider discovery module and test; runtime `/api/sso/providers` is canonical.
- Preserve the Next build while adding React Router guest pages and session guards.
- Never push; stage named files only; do not touch the modified `.mosaic/orchestrator/session.lock`.
## Plan
1. Inspect P1 SPA structure, shared libraries, legacy Next auth pages/components, Gateway auth mount, and current test setup.
2. Add/adjust tests first for relative API behavior, auth-client origin configuration, relative Socket.IO singleton behavior, and all guard session states; run focused tests and capture expected RED failures.
3. Implement shared-library relative networking and remove the legacy SSO-provider module/imports.
4. Port login, register, and provider callback pages to React Router and wire routes.
5. Implement session-aware guest/auth guards and satisfy focused tests.
6. Run focused tests, all brief verification gates, invariant searches, and an independent code/security review; remediate and re-run affected gates.
7. Update this scratchpad with evidence, stage named files only, and commit locally.
## Testing strategy
- TDD is required because this increment changes authentication/session behavior.
- Primary situational evidence: jsdom router guard state tests plus guest-page/auth-flow contract tests already present or added as needed.
- Baseline gates: web tests, Vite build, Next build, typecheck, lint, and root format check exactly as listed in the brief.
- Browser automation is not required by the brief for P2; if used, it will be headless only.
## Budget
No explicit user token cap was provided. Working soft cap: **30K tokens**, derived from a multi-file auth/frontend increment with tests, dual builds, review, and remediation. Keep implementation within the brief; no unrelated refactors or dependencies.
## Progress / evidence
- [x] Loaded mission protocol, active MVP manifest/scratchpad, top-level tasks, PRD, relevant frontend/auth/testing/type/review guides, and matching skills.
- [x] Resolved host paths to the Distrobox-mapped repo and brief.
- [x] Confirmed branch `feat/webui-p2-data-auth`, stacked at P1 commit `068d0f9b`.
- [x] Source and Gateway mount inspection complete.
- [x] RED tests observed.
- [x] Implementation complete.
- [x] Independent review complete and findings remediated.
- [x] Verification gates complete.
- [x] Local implementation commit created: `46d68e1f`.
### Source decisions
- Gateway source mounts BetterAuth at `/api/auth/` in `apps/gateway/src/auth/auth.controller.ts`; `packages/auth/src/auth.ts` configures `basePath: '/api/auth'`.
- BetterAuth 1.5.5 defaults its browser client to `/api/auth` when `baseURL` is omitted. `src/lib/auth-client.ts` therefore omits `baseURL`, preserving same-origin behavior without encoding any gateway origin.
- The only `src/app/` edit is the transitional Next provider redirect page. Deleting `src/lib/sso-providers.ts` required that mechanical consumer migration; it now uses the same runtime `/api/sso/providers` discovery and callback sanitizer as the SPA.
- GuestGuard pending behavior intentionally follows the approved brief: if no session object exists (including pending), render `<Outlet />`; only a present session redirects to `/chat`. AuthGuard alone renders the specified pending treatment.
### TDD evidence
- Initial focused run: 9 expected failures, proving absolute API/auth/socket origins, P1 guest placeholders, and missing guard redirects/loading behavior.
- After implementation: focused contract suite 15/15 passed.
- Security remediation RED: `%2F%0A%2F%2Fevil.example` reached `signIn.oauth2` as an external-normalizing callback target before the fix.
- Security remediation GREEN: shared `resolveAuthCallbackURL` unit suite 9/9 plus SPA callback regression 1/1 passed; both SPA and Next consumers use the shared helper.
### Independent review
- Primary Codex wrappers could not run because `jq` is absent; direct Codex fallback then failed authentication with HTTP 401. No review result was claimed from those attempts.
- Independent Claude Sonnet code review found no implementation-scope blocker, one pending-state UX suggestion, and one pre-existing `api.ts` type-assertion suggestion. The pending-state suggestion was rejected because it contradicts the brief's explicit GuestGuard contract; the API cleanup is outside P2's preserve-contract scope.
- Independent Claude Sonnet security review found a high-severity control-character open redirect in the callback prefix check shared by the newly ported SPA logic and transitional Next consumer.
- Remediation centralized WHATWG URL parsing plus exact current-origin comparison in `src/lib/auth-redirect.ts`, returns only path/search/hash, and added the RED-first regression above.
- Fresh code re-review: `approve`, 0 blockers, 0 should-fix findings.
- Fresh security re-review: `low`, 0 critical/high/medium/low findings.
### Documentation checklist disposition
- `docs/PRD.md` exists and P2 aligns to FR-8 / AC-7 authentication requirements.
- No Gateway endpoint, DTO, permission, or API schema changed; existing `/api/auth/*` and `/api/sso/providers` contracts are consumed unchanged, so OpenAPI/API-index updates are not applicable.
- Route paths were already present in the P1 route table; P2 replaces placeholders without changing site-map navigation, so `docs/SITEMAP.md` is unchanged.
- User/admin auth behavior is parity with the still-live Next implementation, not a new workflow. Implementation decisions, failure behavior, testing, and migration compatibility are documented here; no publishing action is in scope.
- Documentation remains in-repo; no generated publishing output was created.
### Final verification evidence
Run fresh after remediation from the locations required by the brief:
- `apps/web: pnpm test` — PASS: `Test Files 9 passed (9)`; `Tests 27 passed (27)`.
- `apps/web: pnpm build:vite` — PASS: `✓ 113 modules transformed`; `✓ built in 565ms`.
- `apps/web: pnpm build` — PASS: `✓ Compiled successfully in 4.6s`; 10/10 static pages generated; dynamic provider/project routes retained.
- `apps/web: pnpm typecheck` — PASS: `tsc --noEmit` exited 0.
- `apps/web: pnpm lint` — PASS: `eslint src` exited 0.
- Root `pnpm format:check` — PASS: `All matched files use Prettier code style!`.
- Invariant scan — PASS: no `NEXT_PUBLIC_*`, `VITE_*`, `GATEWAY_URL`, `http://localhost:14242`, or `sso-providers` references under `apps/web/src`.
- `git diff --check` — PASS.
- All six required gates above were repeated successfully after implementation commit `46d68e1f`, proving the exact committed source tree.
## Risks / blockers
- `.mosaic/orchestrator/session.lock` is modified by the active harness and must remain unstaged.
- P2 changes shared `src/lib/` modules consumed by both Vite and Next, so the Next build is a required compatibility gate.
## Remediation 1 — independent Node 26 verification failure
**Correction received:** 2026-08-09
The orchestrator rejected the P2 verification claim after an independent run under Node 26.4.0 produced 2 failed redirect tests and 2 unhandled errors. React Router passed jsdom's realm-local `AbortSignal` to Node 26's native undici `Request`, which rejects non-native signals. Production guard behavior is correct and must not change.
### Remediation constraints
- Preserve both redirect assertions and all `guards.tsx` behavior; no skips, weakening, or test deletion.
- Add the smallest Vitest environment repair so global `AbortController` / `AbortSignal` are constructors accepted by native undici `Request`.
- Run all six required gates, commit named files locally without pushing, leave `.mosaic/orchestrator/session.lock` untouched, then run `apps/web: pnpm test` as the final worktree action.
- Completion requires zero failed tests, zero test errors, and zero unhandled rejections.
### Remediation plan
1. Reproduce under Node 26.4.0 if an ephemeral matching runtime is available.
2. Add a Vitest `setupFiles` module deriving Node-native abort constructors from `node:util` and register it in `apps/web/vitest.config.ts`.
3. Add a direct regression assertion that a global controller's signal is accepted by native `Request`, while retaining the existing redirect behavior tests unchanged.
4. Run focused Node 22 and Node 26 tests, independent review, all required gates, and local commit(s).
5. After every edit/commit/status check is complete, run `pnpm test` from `apps/web` as the last command.
### Remediation implementation and evidence
- Reproduced under ephemeral Node `v26.4.0`: `Test Files 1 failed (1)`, `Tests 2 failed | 4 passed (6)`, `Errors 2 errors`, with the exact undici `AbortSignal` realm rejection from the remediation brief.
- Added `apps/web/src/test/setup.ts`, registered through `vitest.config.ts#setupFiles`. It derives Node-native abort constructors from the built-in `node:util.transferableAbortController()` and aligns both `globalThis` and jsdom `window`; it does not replace `Request`, `Response`, or `fetch`.
- Explicitly pinned Vitest `isolate: true` so the test-only global constructors cannot leak between test-file environments.
- Added `src/test/setup.spec.ts`, which proves a global controller signal is accepted by Node's native `Request` and that abort propagation remains functional.
- Existing `guards.spec.tsx` and production `guards.tsx` remain unchanged.
- Focused Node 26.4.0 remediation run: `Test Files 2 passed (2)` and `Tests 7 passed (7)`, with zero errors/unhandled rejections.
- Preliminary full Node 26.4.0 run: `Test Files 9 passed (9)` and `Tests 27 passed (27)`, with zero errors/unhandled rejections before the direct setup regression was added.
- No dependency was added; `node:util` is a Node built-in.
### Remediation independent review
- First code review: approve, 0 blockers, 0 should-fix; suggested a direct Request regression and version-neutral comment.
- First security/integrity review: low risk; suggested pinning test isolation explicitly.
- All suggestions were applied.
- Fresh code re-review: approve, 0 blockers, 0 should-fix, no findings.
- Fresh security/integrity re-review: low risk, 0 findings.
### Remediation pre-commit gate evidence
- Node 26.4.0 `pnpm test`: `Test Files 10 passed (10)`; `Tests 28 passed (28)`; zero errors and zero unhandled rejections.
- `pnpm typecheck`: `tsc --noEmit` exited 0.
- `pnpm build:vite`: `✓ 113 modules transformed`; `✓ built in 960ms`.
- `pnpm build` (Next): `✓ Compiled successfully in 4.8s`; static pages generated 10/10.
- `pnpm lint`: `eslint src` exited 0.
- Root `pnpm format:check`: `All matched files use Prettier code style!`.
- Final post-commit non-test gates and final-action Node 26 `pnpm test` remain required before reporting completion.
### Remediation committed verification
- Implementation commit: `e16c08aa` (`test(web): align jsdom abort signals with Node`).
- Post-commit `pnpm typecheck`: `tsc --noEmit` exited 0.
- Post-commit `pnpm build:vite`: `✓ 113 modules transformed`; `✓ built in 323ms`.
- Post-commit `pnpm build` (Next): `✓ Compiled successfully in 4.6s`; static pages generated 10/10.
- Post-commit `pnpm lint`: `eslint src` exited 0.
- Post-commit root `pnpm format:check`: `All matched files use Prettier code style!`.
- Final Node 26 `pnpm test` will be the last worktree action and its verbatim output will be reported to the orchestrator.
+7 -3
View File
@@ -6,11 +6,15 @@
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"test": "turbo run test",
"preflight": "node scripts/preflight.mjs",
"clean:generated": "node scripts/clean-generated.mjs",
"typecheck": "pnpm preflight && turbo run typecheck",
"test:checkout": "node --test scripts/*.test.mjs",
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
"test:installer": "bash tools/install-next-lane.test.sh",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
"prepare": "husky"
"prepare": "node scripts/install-hooks.mjs"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -11,9 +11,37 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
import { createConfigService } from '../../src/config/config-service.js';
import type { SelectOption } from '../../src/prompter/interface.js';
import type { MenuSection, WizardState } from '../../src/types.js';
const gatewayConfigMock = vi.fn();
const gatewayBootstrapMock = vi.fn();
const providerSetupMock = vi.fn();
const skillsSelectMock = vi.fn();
class SequencedMenuPrompter extends HeadlessPrompter {
constructor(
answers: Record<string, string | boolean | string[]>,
private readonly menuChoices: string[],
) {
super(answers);
}
override async select<T>(opts: {
message: string;
options: SelectOption<T>[];
initialValue?: T;
}): Promise<T> {
if (opts.message === 'What would you like to configure?') {
const next = this.menuChoices.shift();
if (!next) throw new Error('No queued menu choice left');
const match = opts.options.find((o) => String(o.value) === next);
if (!match) throw new Error(`Queued menu choice not available: ${next}`);
return match.value;
}
return super.select(opts);
}
}
vi.mock('../../src/stages/gateway-config.js', () => ({
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
@@ -23,6 +51,14 @@ vi.mock('../../src/stages/gateway-bootstrap.js', () => ({
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
}));
vi.mock('../../src/stages/provider-setup.js', () => ({
providerSetupStage: (...args: unknown[]) => providerSetupMock(...args),
}));
vi.mock('../../src/stages/skills-select.js', () => ({
skillsSelectStage: (...args: unknown[]) => skillsSelectMock(...args),
}));
// Import AFTER the mocks so runWizard picks up the mocked stage modules.
import { runWizard } from '../../src/wizard.js';
@@ -44,6 +80,16 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
}
gatewayConfigMock.mockReset();
gatewayBootstrapMock.mockReset();
providerSetupMock.mockReset();
skillsSelectMock.mockReset();
providerSetupMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
state.providerType = 'none';
state.completedSections?.add('providers' satisfies MenuSection);
});
skillsSelectMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
state.selectedSkills = [];
state.completedSections?.add('skills' satisfies MenuSection);
});
// Pretend we're on an interactive TTY so the wizard's headless-abort
// branch does not call `process.exit(1)` during these tests.
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
@@ -98,8 +144,12 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
});
it('does not invoke bootstrap when config stage reports not ready', async () => {
gatewayConfigMock.mockResolvedValue({ ready: false });
it('prints the success summary only after gateway health succeeds', async () => {
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
p.log('Gateway is healthy.');
return { ready: true, host: 'localhost', port: 14242 };
});
gatewayBootstrapMock.mockResolvedValue({ completed: true });
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
@@ -118,6 +168,43 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
skipGatewayNpmInstall: true,
});
const logs = prompter.getLogs();
const healthIndex = logs.findIndex((line) => line.includes('Gateway is healthy.'));
const summaryIndex = logs.findIndex((line) => line.includes('Installation Summary'));
const readyIndex = logs.findIndex((line) => line.includes('Mosaic is ready.'));
expect(healthIndex).toBeGreaterThanOrEqual(0);
expect(summaryIndex).toBeGreaterThan(healthIndex);
expect(readyIndex).toBeGreaterThan(summaryIndex);
});
it('does not claim success when gateway health reports not ready', async () => {
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
p.warn('Gateway did not become healthy within 30 seconds.');
return { ready: false };
});
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'They/Them',
'Your timezone': 'UTC',
});
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
skipGatewayNpmInstall: true,
});
const logs = prompter.getLogs();
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
});
@@ -143,4 +230,34 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
expect(gatewayConfigMock).not.toHaveBeenCalled();
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
});
it('does not re-run completed provider or skills menu steps', async () => {
const prompter = new SequencedMenuPrompter(
{
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'They/Them',
'Your timezone': 'UTC',
},
['providers', 'providers', 'skills', 'skills', 'finish'],
);
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
skipGateway: true,
});
expect(providerSetupMock).toHaveBeenCalledTimes(1);
expect(skillsSelectMock).toHaveBeenCalledTimes(1);
expect(prompter.getLogs()).toEqual(
expect.arrayContaining([
expect.stringContaining('Providers [done] is already complete; skipping.'),
expect.stringContaining('Skills [done] is already complete; skipping.'),
]),
);
});
});
+23 -10
View File
@@ -43,6 +43,16 @@ The installer:
- Runs a health audit
- Detects existing installs and preserves local files (SOUL.md, USER.md, etc.)
### Install lanes
| Lane | Command | Use when | Source |
| ------------------------ | ------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | You want the released framework and CLI | npm `@mosaicstack/mosaic@latest` + `main` |
| Prerelease integration | `bash tools/install.sh --next` | You want the permanent `next` integration lane | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are validating a branch before release | Build-from-source at the requested git ref |
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` wins and uses the source path.
## First Run
After install, open a new terminal (or `source ~/.bashrc`) and run:
@@ -108,8 +118,8 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
├── TOOLS.md ← Machine-level tool reference (generated by mosaic init)
├── STANDARDS.md ← Machine-wide standards
├── guides/ ← Operational guides (E2E delivery, PRD, docs, etc.)
├── bin/ ← CLI tools (mosaic launcher, mosaic-init, mosaic-doctor, etc.)
├── tools/ ← Tool suites: git, orchestrator, prdy, quality, etc.
│ └── _scripts/ ← Framework helper scripts (sync skills, doctor, runtime links)
├── runtime/ ← Runtime adapters + runtime-specific references
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
│ ├── codex/ ← instructions.md, RUNTIME.md
@@ -174,7 +184,9 @@ The installer preserves local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/` by
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: npm @next, source fallback
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)
```
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
@@ -184,10 +196,11 @@ The installer rejects unrecognized flags or positional arguments before making c
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`. Install, wizard finalization, and `mosaic update` automatically reconcile every canonical skill into Claude Code's `~/.claude/skills/` directory.
```bash
mosaic sync # Full canonical catalog sync
mosaic skill list # Show registered, missing, dangling, and foreign entries
mosaic skill register <name> # Register or repair one canonical Claude link
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
mosaic sync # Full canonical catalog sync
~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only
mosaic skill list # Show registered, missing, dangling, and foreign entries
mosaic skill register <name> # Register or repair one canonical Claude link
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
```
Skill names are direct children using `[A-Za-z0-9][A-Za-z0-9._-]*`, not paths. Registration rejects traversal/control characters and never replaces foreign files, directories, or symlinks; unregister removes only links that point inside the canonical Mosaic skill root. After registering during a running Claude Code session, use `/reload-skills` or start a new session.
@@ -197,8 +210,8 @@ M1 lifecycle management targets Claude Code. Pi can discover the canonical Mosai
## Health Audit
```bash
mosaic doctor # Standard audit
~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
mosaic doctor # Standard audit
~/.config/mosaic/tools/_scripts/mosaic-doctor --fail-on-warn # Strict mode
```
## MCP Registration
@@ -209,8 +222,8 @@ sequential-thinking MCP is required for Mosaic Stack. The installer registers it
To verify or re-register manually:
```bash
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking --check
```
### Claude Code MCP Registration

Some files were not shown because too many files have changed in this diff Show More