Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0afff19c7 | ||
|
|
177406e2d1 | ||
|
|
d405831ac5 | ||
|
|
e878dbd472 | ||
|
|
f31813b19e | ||
|
|
bf380b3718 | ||
|
|
ca24d8e70f | ||
|
|
ceb898ce2c | ||
|
|
865e67cd35 | ||
|
|
b09c9b157b | ||
|
|
03786d6803 | ||
|
|
00eb216480 | ||
|
|
8c27024d0e | ||
|
|
406e40584d | ||
|
|
677aeb0c93 | ||
|
|
bd0ef2ab25 | ||
|
|
2d5a8c81ec | ||
|
|
884d527cc8 | ||
|
|
87daa12976 | ||
|
|
b82a51da80 | ||
|
|
90cf286a09 | ||
|
|
0aef432052 | ||
|
|
41a16cc916 | ||
|
|
e16c08aa9f | ||
|
|
a34e92cf39 | ||
|
|
a4861c221f | ||
|
|
46d68e1ff4 | ||
|
|
c3496334a5 | ||
|
|
6f29d00149 | ||
|
|
068d0f9b1c | ||
|
|
13cd673d50 | ||
|
|
620cc608e0 | ||
|
|
91e692e3e7 | ||
|
|
b4753a75cd | ||
|
|
24bbd40dc7 | ||
|
|
4df478cdd1 | ||
|
|
b8844e1ff0 | ||
|
|
906ad8dc30 | ||
|
|
193331544d | ||
|
|
495f73bfdb | ||
|
|
b96cc7982a | ||
|
|
0883fb91ec | ||
|
|
56787fabf1 | ||
|
|
940ae3cc41 | ||
|
|
c25a551c28 | ||
|
|
94d6538061 | ||
|
|
a3c1ab923c | ||
|
|
838701bde2 |
@@ -46,10 +46,6 @@ steps:
|
||||
# [0] of the pnpm chain, so severing that chain would silence it together
|
||||
# with everything it guards; this direct line keeps one instrument running.
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
||||
# Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH
|
||||
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
||||
# joins CI directly rather than the exclusions file.
|
||||
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
||||
|
||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||
|
||||
+104
-5
@@ -1,5 +1,5 @@
|
||||
# Build, publish npm packages, and push Docker images
|
||||
# Runs only on main branch push/tag
|
||||
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
||||
|
||||
variables:
|
||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||
@@ -23,9 +23,21 @@ variables:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
- &main_image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
exclude:
|
||||
- 'packages/mosaic/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
|
||||
when:
|
||||
- branch: [main]
|
||||
- branch: [main, next]
|
||||
event: [push, manual, tag]
|
||||
|
||||
steps:
|
||||
@@ -103,6 +115,84 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
publish-next-npm:
|
||||
image: *node_image
|
||||
# Durable @next integration-line publish. Runs only on next; never writes
|
||||
# the latest dist-tag and never commits the computed prerelease versions.
|
||||
when:
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
environment:
|
||||
NPM_TOKEN:
|
||||
from_secret: gitea_token
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- |
|
||||
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
|
||||
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$CI_PIPELINE_NUMBER" ]; then
|
||||
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
||||
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
|
||||
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||
const roots = ['apps', 'packages', 'plugins'];
|
||||
const updated = [];
|
||||
|
||||
function walk(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const packagePath = path.join(fullPath, 'package.json');
|
||||
if (fs.existsSync(packagePath)) updatePackage(packagePath);
|
||||
walk(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updatePackage(packagePath) {
|
||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
||||
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
||||
if (!stableMatch) {
|
||||
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
||||
}
|
||||
const [, major, minor, patch] = stableMatch;
|
||||
const oldVersion = manifest.version;
|
||||
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
||||
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
||||
}
|
||||
|
||||
for (const root of roots) walk(root);
|
||||
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
||||
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
||||
for (const line of updated) console.log('[publish-next] ' + line);
|
||||
NODE
|
||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
||||
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
||||
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
|
||||
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
|
||||
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
# TODO: Uncomment when ready to publish to npmjs.org
|
||||
# publish-npmjs:
|
||||
# image: *node_image
|
||||
@@ -134,8 +224,17 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next gateway publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||
@@ -146,7 +245,7 @@ steps:
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
when: *main_image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
@@ -172,7 +271,7 @@ steps:
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
when: *main_image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -60,14 +70,6 @@ The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md
|
||||
|
||||
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
|
||||
|
||||
Mosaic also loads its Pi extensions from `~/.config/mosaic/runtime/pi/`. Inside Pi,
|
||||
`/goal set <statement>` starts a bounded persistent loop that checks every turn and successful
|
||||
compaction, requires two evidence-bearing completion reports, and can be inspected or stopped with
|
||||
`/goal status`, `/goal pause`, `/goal resume`, and `/goal cancel`. Controller-owned goal-state
|
||||
entries redact common credential shapes, but Pi's model/tool-call history is separate, so goals and
|
||||
evidence must never contain secrets or raw sensitive output. Mosaic does not install this extension
|
||||
into `~/.pi/agent/extensions/`.
|
||||
|
||||
### TUI & Gateway
|
||||
|
||||
```bash
|
||||
@@ -369,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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
@@ -245,21 +245,9 @@ describe('EnrollmentService.createToken', () => {
|
||||
const after = Date.now();
|
||||
|
||||
const expiresMs = new Date(result.expiresAt).getTime();
|
||||
|
||||
// The property under test is CLAMPING: a 9999s request must come back as 900s.
|
||||
// The gap between clamped and unclamped is 9_099_000 ms, so the tolerance below
|
||||
// only has to exceed CI scheduling jitter — it does not need to be tight to keep
|
||||
// the assertion discriminating. A 5s allowance consumes 0.05% of that margin and
|
||||
// an unclamped result still misses by three orders of magnitude.
|
||||
//
|
||||
// It was 100ms and failed on a loaded agent at 900_106 — 6ms over (#1090). A
|
||||
// wall-clock budget sized to a fast machine is a flake, not a tighter test.
|
||||
const CI_JITTER_MS = 5_000;
|
||||
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + CI_JITTER_MS);
|
||||
// Should be at most 900s from now
|
||||
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
|
||||
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
|
||||
// Explicitly pin the clamp itself, independent of any timing allowance:
|
||||
// unclamped (9999s) would exceed this by ~9_099_000 ms.
|
||||
expect(expiresMs - before).toBeLessThan(1_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { EnrollmentController } from './enrollment.controller.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
import { FederationController } from './federation.controller.js';
|
||||
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
||||
import { GetController } from './server/verbs/get.controller.js';
|
||||
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
|
||||
import { GrantsService } from './grants.service.js';
|
||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
||||
@@ -12,7 +14,13 @@ import { ListController } from './server/verbs/list.controller.js';
|
||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
||||
controllers: [
|
||||
EnrollmentController,
|
||||
FederationController,
|
||||
CapabilitiesController,
|
||||
ListController,
|
||||
GetController,
|
||||
],
|
||||
providers: [
|
||||
AdminGuard,
|
||||
CaService,
|
||||
@@ -23,6 +31,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
FederationGetQueryService,
|
||||
],
|
||||
exports: [
|
||||
CaService,
|
||||
@@ -33,6 +42,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
FederationGetQueryService,
|
||||
],
|
||||
})
|
||||
export class FederationModule {}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createPgliteDb,
|
||||
missionTasks,
|
||||
missions,
|
||||
projects,
|
||||
runPgliteMigrations,
|
||||
teams,
|
||||
users,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import type { FederationScopeQueryFilter } from '../../scope.service.js';
|
||||
import { FederationGetQueryService } from '../get-query.service.js';
|
||||
|
||||
const CREDENTIAL_FILTER: FederationScopeQueryFilter = {
|
||||
resource: 'credentials',
|
||||
subjectUserId: 'user-1',
|
||||
includePersonal: true,
|
||||
teamIds: [],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 25,
|
||||
};
|
||||
|
||||
const SUBJECT_USER_ID = 'fed-m3-06-subject';
|
||||
const OTHER_USER_ID = 'fed-m3-06-other';
|
||||
const TEAM_ID = '06000000-0000-4000-8000-000000000001';
|
||||
const UNAUTHORIZED_TEAM_ID = '06000000-0000-4000-8000-000000000002';
|
||||
const PERSONAL_PROJECT_ID = '06000000-0000-4000-8000-000000000101';
|
||||
const TEAM_PROJECT_ID = '06000000-0000-4000-8000-000000000102';
|
||||
const UNAUTHORIZED_PROJECT_ID = '06000000-0000-4000-8000-000000000103';
|
||||
const PERSONAL_MISSION_ID = '06000000-0000-4000-8000-000000000201';
|
||||
const TEAM_MISSION_ID = '06000000-0000-4000-8000-000000000202';
|
||||
const UNAUTHORIZED_MISSION_ID = '06000000-0000-4000-8000-000000000203';
|
||||
const SUBJECT_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000301';
|
||||
const OTHER_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000302';
|
||||
const SUBJECT_PERSONAL_NOTE_ID = '06000000-0000-4000-8000-000000000303';
|
||||
const SUBJECT_UNAUTHORIZED_NOTE_ID = '06000000-0000-4000-8000-000000000304';
|
||||
|
||||
let dbHandle: DbHandle | undefined;
|
||||
|
||||
function makeService() {
|
||||
return new FederationGetQueryService({} as Db);
|
||||
}
|
||||
|
||||
function makeDbService() {
|
||||
if (!dbHandle) {
|
||||
throw new Error('test DB not initialized');
|
||||
}
|
||||
return new FederationGetQueryService(dbHandle.db);
|
||||
}
|
||||
|
||||
async function seedNotesFixture() {
|
||||
if (!dbHandle) {
|
||||
throw new Error('test DB not initialized');
|
||||
}
|
||||
|
||||
await dbHandle.db.insert(users).values([
|
||||
{
|
||||
id: SUBJECT_USER_ID,
|
||||
name: 'Federation Subject',
|
||||
email: `${SUBJECT_USER_ID}@example.test`,
|
||||
emailVerified: false,
|
||||
},
|
||||
{
|
||||
id: OTHER_USER_ID,
|
||||
name: 'Federation Other',
|
||||
email: `${OTHER_USER_ID}@example.test`,
|
||||
emailVerified: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(teams).values([
|
||||
{
|
||||
id: TEAM_ID,
|
||||
name: 'FED-M3-06 Team',
|
||||
slug: 'fed-m3-06-team',
|
||||
ownerId: SUBJECT_USER_ID,
|
||||
managerId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_TEAM_ID,
|
||||
name: 'FED-M3-06 Unauthorized Team',
|
||||
slug: 'fed-m3-06-unauthorized-team',
|
||||
ownerId: OTHER_USER_ID,
|
||||
managerId: OTHER_USER_ID,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(projects).values([
|
||||
{
|
||||
id: PERSONAL_PROJECT_ID,
|
||||
name: 'FED-M3-06 Personal Project',
|
||||
ownerId: SUBJECT_USER_ID,
|
||||
ownerType: 'user',
|
||||
},
|
||||
{
|
||||
id: TEAM_PROJECT_ID,
|
||||
name: 'FED-M3-06 Team Project',
|
||||
teamId: TEAM_ID,
|
||||
ownerType: 'team',
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_PROJECT_ID,
|
||||
name: 'FED-M3-06 Unauthorized Project',
|
||||
teamId: UNAUTHORIZED_TEAM_ID,
|
||||
ownerType: 'team',
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(missions).values([
|
||||
{
|
||||
id: PERSONAL_MISSION_ID,
|
||||
name: 'FED-M3-06 Personal Mission',
|
||||
projectId: PERSONAL_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: TEAM_MISSION_ID,
|
||||
name: 'FED-M3-06 Team Mission',
|
||||
projectId: TEAM_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_MISSION_ID,
|
||||
name: 'FED-M3-06 Unauthorized Mission',
|
||||
projectId: UNAUTHORIZED_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(missionTasks).values([
|
||||
{
|
||||
id: SUBJECT_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note on team mission',
|
||||
createdAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: OTHER_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
userId: OTHER_USER_ID,
|
||||
notes: 'other user note on team mission',
|
||||
createdAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
||||
missionId: PERSONAL_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note on personal mission',
|
||||
createdAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
||||
missionId: UNAUTHORIZED_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note outside grant-visible missions',
|
||||
createdAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
describe('FederationGetQueryService', () => {
|
||||
beforeAll(async () => {
|
||||
dbHandle = createPgliteDb(`memory://fed-m3-06-get-${Date.now()}`);
|
||||
await runPgliteMigrations(dbHandle);
|
||||
await seedNotesFixture();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await dbHandle?.close();
|
||||
dbHandle = undefined;
|
||||
});
|
||||
|
||||
it('denies sensitive resources in native RBAC for M3 get reads', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'credentials',
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: 'credentials federation get access is not implemented in M3',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows personal memory reads without requiring team lookup', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'memory',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses subject team membership as the native RBAC upper bound for task and note reads', async () => {
|
||||
const service = makeService();
|
||||
const listSubjectTeamIds = vi.fn().mockResolvedValue(['team-1', 'team-2']);
|
||||
(
|
||||
service as unknown as {
|
||||
listSubjectTeamIds: (subjectUserId: string) => Promise<string[]>;
|
||||
}
|
||||
).listSubjectTeamIds = listSubjectTeamIds;
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'tasks',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
||||
});
|
||||
expect(listSubjectTeamIds).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('does not query storage for sensitive get resources even if scope allowed them', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(service.get({ filter: CREDENTIAL_FILTER, id: 'cred-1' })).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'credentials federation get is not implemented',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed for unsupported resources instead of returning undefined', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
...CREDENTIAL_FILTER,
|
||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||
},
|
||||
id: 'row-1',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Unsupported federation get resource: unknown-resource',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not leak another user mission task note through team-scoped get reads', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: OTHER_TEAM_NOTE_ID,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Note is outside the federated scope',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not return subject notes from missions outside the grant-visible project set', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: true,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Note is outside the federated scope',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a subject note only when subject ownership and authorized mission intersect', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: SUBJECT_TEAM_NOTE_ID,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: 'found',
|
||||
item: {
|
||||
id: SUBJECT_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
content: 'subject note on team mission',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not return subject personal notes when includePersonal is false', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Note is outside the federated scope',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'reflect-metadata';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
||||
import type {
|
||||
FederationScopeEvaluationResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from '../../scope.service.js';
|
||||
import { GetController } from '../get.controller.js';
|
||||
import type { FederationGetQueryResult } from '../get-query.service.js';
|
||||
|
||||
const FEDERATION_CONTEXT = {
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
scope: { resources: ['tasks'], max_rows_per_query: 25 },
|
||||
};
|
||||
|
||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
||||
resource: 'tasks',
|
||||
subjectUserId: 'user-1',
|
||||
includePersonal: true,
|
||||
teamIds: ['team-1'],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 25,
|
||||
};
|
||||
|
||||
function makeRequest(): FastifyRequest {
|
||||
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
|
||||
}
|
||||
|
||||
function allowedScope(
|
||||
filter: FederationScopeQueryFilter = TASK_FILTER,
|
||||
): FederationScopeEvaluationResult {
|
||||
return { allowed: true, filter };
|
||||
}
|
||||
|
||||
function makeController(opts?: {
|
||||
scopeResult?: FederationScopeEvaluationResult;
|
||||
queryResult?: FederationGetQueryResult;
|
||||
}) {
|
||||
const scope = {
|
||||
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
|
||||
};
|
||||
const query = {
|
||||
evaluateReadAccess: vi.fn(),
|
||||
get: vi.fn().mockResolvedValue(
|
||||
opts?.queryResult ?? {
|
||||
status: 'found',
|
||||
item: {
|
||||
id: 'task-1',
|
||||
title: 'Federated task',
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||
},
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
controller: new GetController(scope as never, query as never),
|
||||
scope,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GetController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('declares POST /api/federation/v1/get/:resource/:id protected only by FederationAuthGuard', () => {
|
||||
expect(Reflect.getMetadata('path', GetController)).toBe('api/federation/v1/get');
|
||||
expect(Reflect.getMetadata('path', GetController.prototype.get)).toBe(':resource/:id');
|
||||
expect(Reflect.getMetadata('method', GetController.prototype.get)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata('__guards__', GetController)).toEqual([FederationAuthGuard]);
|
||||
});
|
||||
|
||||
it('runs AuthGuard context through ScopeService and returns one local-source tagged row', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
const response = await controller.get('tasks', 'task-1', makeRequest());
|
||||
|
||||
expect(scope.evaluateAccess).toHaveBeenCalledWith({
|
||||
context: FEDERATION_CONTEXT,
|
||||
resource: 'tasks',
|
||||
requestedLimit: 1,
|
||||
nativeRbac: query,
|
||||
});
|
||||
expect(query.get).toHaveBeenCalledWith({ filter: TASK_FILTER, id: 'task-1' });
|
||||
expect(response).toEqual({
|
||||
item: {
|
||||
id: 'task-1',
|
||||
title: 'Federated task',
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||
_source: 'local',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a federation error envelope when auth guard context is missing', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
await expect(
|
||||
controller.get('tasks', 'task-1', {} as unknown as FastifyRequest),
|
||||
).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'unauthorized',
|
||||
message: 'Federation context missing',
|
||||
},
|
||||
},
|
||||
status: 401,
|
||||
});
|
||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||
expect(query.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a federation error envelope when scope evaluation denies access', async () => {
|
||||
const { controller, query } = makeController({
|
||||
scopeResult: {
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'resource_excluded',
|
||||
stage: 'resource_exclusion',
|
||||
statusCode: 403,
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'credentials',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(controller.get('credentials', 'cred-1', makeRequest())).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
expect(query.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when the scoped query layer cannot find the resource id', async () => {
|
||||
const { controller } = makeController({ queryResult: { status: 'not_found' } });
|
||||
|
||||
await expect(controller.get('tasks', 'missing-task', makeRequest())).rejects.toMatchObject({
|
||||
response: { error: { code: 'not_found' } },
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 403 when the resource exists outside the RBAC/scope intersection', async () => {
|
||||
const { controller } = makeController({
|
||||
queryResult: { status: 'denied', reason: 'Task is outside the federated scope' },
|
||||
});
|
||||
|
||||
await expect(controller.get('tasks', 'task-2', makeRequest())).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Task is outside the federated scope',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when the query layer denies an unsupported resource', async () => {
|
||||
const unsupportedFilter: FederationScopeQueryFilter = {
|
||||
...TASK_FILTER,
|
||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||
};
|
||||
const { controller } = makeController({
|
||||
scopeResult: allowedScope(unsupportedFilter),
|
||||
queryResult: {
|
||||
status: 'denied',
|
||||
reason: 'Unsupported federation get resource: unknown-resource',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(controller.get('unknown-resource', 'row-1', makeRequest())).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Unsupported federation get resource: unknown-resource',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects empty ids before evaluating scope', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
await expect(controller.get('tasks', ' ', makeRequest())).rejects.toMatchObject({
|
||||
response: { error: { code: 'invalid_request' } },
|
||||
status: 400,
|
||||
});
|
||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||
expect(query.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Federation get query layer (FED-M3-06).
|
||||
*
|
||||
* Read-only DB adapter used by GetController after FederationAuthGuard and
|
||||
* FederationScopeService have established the subject user, allowed resource,
|
||||
* native-RBAC intersection, and row cap. Audit writes are intentionally
|
||||
* deferred to M4.
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
insights,
|
||||
or,
|
||||
missionTasks,
|
||||
missions,
|
||||
preferences,
|
||||
projects,
|
||||
tasks,
|
||||
teamMembers,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../../../database/database.module.js';
|
||||
import type {
|
||||
FederationNativeRbacEvaluator,
|
||||
FederationNativeRbacRequest,
|
||||
FederationNativeRbacResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from '../scope.service.js';
|
||||
|
||||
export interface FederationGetQueryRequest {
|
||||
readonly filter: FederationScopeQueryFilter;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export interface FederationGetQueryFoundResult<T extends object = Record<string, unknown>> {
|
||||
readonly status: 'found';
|
||||
readonly item: T;
|
||||
}
|
||||
|
||||
export interface FederationGetQueryNotFoundResult {
|
||||
readonly status: 'not_found';
|
||||
}
|
||||
|
||||
export interface FederationGetQueryDeniedResult {
|
||||
readonly status: 'denied';
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
export type FederationGetQueryResult<T extends object = Record<string, unknown>> =
|
||||
| FederationGetQueryFoundResult<T>
|
||||
| FederationGetQueryNotFoundResult
|
||||
| FederationGetQueryDeniedResult;
|
||||
|
||||
type RowObject = Record<string, unknown>;
|
||||
|
||||
function firstRow<T>(rows: T[]): T | undefined {
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
function rowBelongsToAccessibleProjectOrMission(
|
||||
row: { projectId?: string | null; missionId?: string | null },
|
||||
projectIds: readonly string[],
|
||||
missionIds: readonly string[],
|
||||
): boolean {
|
||||
return (
|
||||
(typeof row.projectId === 'string' && projectIds.includes(row.projectId)) ||
|
||||
(typeof row.missionId === 'string' && missionIds.includes(row.missionId))
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FederationGetQueryService implements FederationNativeRbacEvaluator {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async evaluateReadAccess(
|
||||
request: FederationNativeRbacRequest,
|
||||
): Promise<FederationNativeRbacResult> {
|
||||
if (request.resource === 'credentials' || request.resource === 'api_keys') {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `${request.resource} federation get access is not implemented in M3`,
|
||||
details: { resource: request.resource },
|
||||
};
|
||||
}
|
||||
|
||||
if (request.resource === 'memory') {
|
||||
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
|
||||
}
|
||||
|
||||
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
|
||||
return { allowed: true, access: { includePersonal: true, teamIds } };
|
||||
}
|
||||
|
||||
async get<T extends RowObject = RowObject>(
|
||||
request: FederationGetQueryRequest,
|
||||
): Promise<FederationGetQueryResult<T>> {
|
||||
return this.getByResource(request.filter, request.id) as Promise<FederationGetQueryResult<T>>;
|
||||
}
|
||||
|
||||
private async getByResource(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
switch (filter.resource) {
|
||||
case 'tasks':
|
||||
return this.getTask(filter, id);
|
||||
case 'notes':
|
||||
return this.getNote(filter, id);
|
||||
case 'memory':
|
||||
return this.getMemory(filter, id);
|
||||
case 'credentials':
|
||||
case 'api_keys':
|
||||
return { status: 'denied', reason: `${filter.resource} federation get is not implemented` };
|
||||
default:
|
||||
return {
|
||||
status: 'denied',
|
||||
reason: `Unsupported federation get resource: ${String(filter.resource)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.userId, subjectUserId));
|
||||
|
||||
return rows.map((row) => row.teamId);
|
||||
}
|
||||
|
||||
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
|
||||
const clauses = [];
|
||||
if (filter.includePersonal) {
|
||||
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
|
||||
}
|
||||
if (filter.teamIds.length > 0) {
|
||||
// Project team ownership follows TeamsService.canAccessProject: team-owned
|
||||
// rows are authorized through projects.teamId, while ownerId remains the
|
||||
// user who created/bootstrapped the project.
|
||||
clauses.push(
|
||||
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
|
||||
);
|
||||
}
|
||||
|
||||
if (clauses.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
|
||||
if (projectIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({ id: missions.id })
|
||||
.from(missions)
|
||||
.where(inArray(missions.projectId, [...projectIds]));
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
private async getTask(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
const row = firstRow(
|
||||
await this.db
|
||||
.select({
|
||||
id: tasks.id,
|
||||
title: tasks.title,
|
||||
description: tasks.description,
|
||||
status: tasks.status,
|
||||
priority: tasks.priority,
|
||||
projectId: tasks.projectId,
|
||||
missionId: tasks.missionId,
|
||||
assignee: tasks.assignee,
|
||||
tags: tasks.tags,
|
||||
dueDate: tasks.dueDate,
|
||||
metadata: tasks.metadata,
|
||||
createdAt: tasks.createdAt,
|
||||
updatedAt: tasks.updatedAt,
|
||||
})
|
||||
.from(tasks)
|
||||
.where(eq(tasks.id, id))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||
const missionIds = await this.listMissionIds(projectIds);
|
||||
if (!rowBelongsToAccessibleProjectOrMission(row, projectIds, missionIds)) {
|
||||
return { status: 'denied', reason: 'Task is outside the federated scope' };
|
||||
}
|
||||
|
||||
return { status: 'found', item: row as RowObject };
|
||||
}
|
||||
|
||||
private async getNote(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
const row = firstRow(
|
||||
await this.db
|
||||
.select({
|
||||
id: missionTasks.id,
|
||||
missionId: missionTasks.missionId,
|
||||
taskId: missionTasks.taskId,
|
||||
userId: missionTasks.userId,
|
||||
status: missionTasks.status,
|
||||
content: missionTasks.notes,
|
||||
createdAt: missionTasks.createdAt,
|
||||
updatedAt: missionTasks.updatedAt,
|
||||
})
|
||||
.from(missionTasks)
|
||||
.where(eq(missionTasks.id, id))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
if (!row || row.content === null || row.content === '') {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||
const missionIds = await this.listMissionIds(projectIds);
|
||||
|
||||
// mission_tasks rows are user-scoped even when the mission belongs to a team.
|
||||
// Scope-visible missions must intersect with subject ownership; team scope
|
||||
// narrows mission IDs but never widens note reads to another user's rows.
|
||||
if (row.userId !== filter.subjectUserId || !missionIds.includes(row.missionId)) {
|
||||
return { status: 'denied', reason: 'Note is outside the federated scope' };
|
||||
}
|
||||
|
||||
const item = { ...row } as RowObject;
|
||||
delete item['userId'];
|
||||
return { status: 'found', item };
|
||||
}
|
||||
|
||||
private async getMemory(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
const [insightRow, preferenceRow] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
id: insights.id,
|
||||
userId: insights.userId,
|
||||
kind: insights.source,
|
||||
content: insights.content,
|
||||
category: insights.category,
|
||||
relevanceScore: insights.relevanceScore,
|
||||
metadata: insights.metadata,
|
||||
createdAt: insights.createdAt,
|
||||
updatedAt: insights.updatedAt,
|
||||
})
|
||||
.from(insights)
|
||||
.where(eq(insights.id, id))
|
||||
.limit(1)
|
||||
.then(firstRow),
|
||||
this.db
|
||||
.select({
|
||||
id: preferences.id,
|
||||
userId: preferences.userId,
|
||||
kind: preferences.category,
|
||||
key: preferences.key,
|
||||
value: preferences.value,
|
||||
source: preferences.source,
|
||||
mutable: preferences.mutable,
|
||||
createdAt: preferences.createdAt,
|
||||
updatedAt: preferences.updatedAt,
|
||||
})
|
||||
.from(preferences)
|
||||
.where(eq(preferences.id, id))
|
||||
.limit(1)
|
||||
.then(firstRow),
|
||||
]);
|
||||
|
||||
const candidates = [insightRow, preferenceRow].filter(
|
||||
(row): row is NonNullable<typeof row> => row !== undefined,
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
if (!filter.includePersonal) {
|
||||
return { status: 'denied', reason: 'Memory personal rows are outside the federated scope' };
|
||||
}
|
||||
|
||||
const accessible = candidates.find((row) => row.userId === filter.subjectUserId);
|
||||
if (!accessible) {
|
||||
return { status: 'denied', reason: 'Memory row belongs to another subject user' };
|
||||
}
|
||||
|
||||
const item = { ...accessible } as RowObject;
|
||||
delete item['userId'];
|
||||
return { status: 'found', item };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Federation get verb (FED-M3-06).
|
||||
*
|
||||
* POST /api/federation/v1/get/:resource/:id
|
||||
*
|
||||
* Pipeline: FederationAuthGuard attaches the active grant context, then
|
||||
* FederationScopeService enforces grant scope + native RBAC intersection, then
|
||||
* the read-only query layer fetches one local row and tags it with `_source`.
|
||||
* Read audit-log writes are deferred to M4; this controller does not persist
|
||||
* request or response bodies.
|
||||
*/
|
||||
|
||||
import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import {
|
||||
FederationInvalidRequestError,
|
||||
FederationNotFoundError,
|
||||
FederationScopeViolationError,
|
||||
FederationUnauthorizedError,
|
||||
SOURCE_LOCAL,
|
||||
type FederationGetResponse,
|
||||
type SourceTag,
|
||||
} from '@mosaicstack/types';
|
||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
||||
import '../federation-context.js';
|
||||
import { FederationScopeService } from '../scope.service.js';
|
||||
import { FederationGetQueryService } from './get-query.service.js';
|
||||
|
||||
type FederatedRow = Record<string, unknown> & SourceTag;
|
||||
|
||||
function scopeDenyToHttpException(deny: {
|
||||
readonly statusCode: 400 | 403;
|
||||
readonly message: string;
|
||||
}): HttpException {
|
||||
const ErrorClass =
|
||||
deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError;
|
||||
return new HttpException(new ErrorClass(deny.message, deny).toEnvelope(), deny.statusCode);
|
||||
}
|
||||
|
||||
@Controller('api/federation/v1/get')
|
||||
@UseGuards(FederationAuthGuard)
|
||||
export class GetController {
|
||||
constructor(
|
||||
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
|
||||
@Inject(FederationGetQueryService) private readonly query: FederationGetQueryService,
|
||||
) {}
|
||||
|
||||
@Post(':resource/:id')
|
||||
async get(
|
||||
@Param('resource') resource: string,
|
||||
@Param('id') id: string,
|
||||
@Req() request: FastifyRequest,
|
||||
): Promise<FederationGetResponse<FederatedRow>> {
|
||||
if (!request.federationContext) {
|
||||
throw new HttpException(
|
||||
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
|
||||
401,
|
||||
);
|
||||
}
|
||||
if (id.trim().length === 0) {
|
||||
throw new HttpException(
|
||||
new FederationInvalidRequestError('Federation get id must not be empty').toEnvelope(),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const scopeResult = await this.scope.evaluateAccess({
|
||||
context: request.federationContext,
|
||||
resource,
|
||||
requestedLimit: 1,
|
||||
nativeRbac: this.query,
|
||||
});
|
||||
|
||||
if (!scopeResult.allowed) {
|
||||
throw scopeDenyToHttpException(scopeResult.deny);
|
||||
}
|
||||
|
||||
const result = await this.query.get({ filter: scopeResult.filter, id });
|
||||
if (result.status === 'not_found') {
|
||||
throw new HttpException(
|
||||
new FederationNotFoundError('Requested federation resource was not found').toEnvelope(),
|
||||
404,
|
||||
);
|
||||
}
|
||||
if (result.status === 'denied') {
|
||||
throw new HttpException(
|
||||
new FederationScopeViolationError(result.reason, {
|
||||
resource,
|
||||
id,
|
||||
grantId: request.federationContext.grantId,
|
||||
peerId: request.federationContext.peerId,
|
||||
subjectUserId: request.federationContext.subjectUserId,
|
||||
}).toEnvelope(),
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
return { item: { ...result.item, _source: SOURCE_LOCAL } };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
|
||||
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { SessionGCService } from './session-gc.service.js';
|
||||
import { REDIS } from './gc.tokens.js';
|
||||
|
||||
@@ -9,13 +11,17 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||
providers: [
|
||||
{
|
||||
provide: GC_QUEUE_HANDLE,
|
||||
useFactory: (): QueueHandle => {
|
||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||
// On Local tier there is no Redis — skip the ioredis connection entirely.
|
||||
// The Valkey GC sweep is a no-op on Local (no session keys stored there).
|
||||
if (config?.queue?.type === 'local') return null;
|
||||
return createQueue();
|
||||
},
|
||||
inject: [MOSAIC_CONFIG],
|
||||
},
|
||||
{
|
||||
provide: REDIS,
|
||||
useFactory: (handle: QueueHandle) => handle.redis,
|
||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||
inject: [GC_QUEUE_HANDLE],
|
||||
},
|
||||
SessionGCService,
|
||||
@@ -23,9 +29,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||
exports: [SessionGCService],
|
||||
})
|
||||
export class GCModule implements OnApplicationShutdown {
|
||||
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(GC_QUEUE_HANDLE)
|
||||
private readonly handle: QueueHandle | null,
|
||||
) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle.close().catch(() => {});
|
||||
await this.handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,19 @@ describe('SessionGCService', () => {
|
||||
).resolves.toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it('collect() skips Valkey but still demotes only the requested session on local tier', async () => {
|
||||
const localService = new SessionGCService(null, mockLogService as unknown as LogService);
|
||||
|
||||
const result = await localService.collect('local-session');
|
||||
|
||||
expect(result.sessionId).toBe('local-session');
|
||||
expect(result.cleaned.valkeyKeys).toBeUndefined();
|
||||
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
|
||||
'local-session',
|
||||
expect.any(Date),
|
||||
);
|
||||
});
|
||||
|
||||
it('collect() returns sessionId in result', async () => {
|
||||
const result = await service.collect('test-session-id');
|
||||
expect(result.sessionId).toBe('test-session-id');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import type { QueueHandle } from '@mosaicstack/queue';
|
||||
import type { LogService } from '@mosaicstack/log';
|
||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||
@@ -21,7 +21,10 @@ function escapeRedisGlobLiteral(value: string): string {
|
||||
@Injectable()
|
||||
export class SessionGCService {
|
||||
constructor(
|
||||
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||
// Local tier has no Redis; lifecycle cleanup still demotes this session's logs.
|
||||
@Optional()
|
||||
@Inject(REDIS)
|
||||
private readonly redis: QueueHandle['redis'] | null,
|
||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||
) {}
|
||||
|
||||
@@ -29,8 +32,10 @@ export class SessionGCService {
|
||||
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
|
||||
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
||||
* duration, which can cause latency spikes under production key volumes.
|
||||
* Returns an empty population on the Local tier where Redis is disabled.
|
||||
*/
|
||||
private async scanKeys(pattern: string): Promise<string[]> {
|
||||
if (!this.redis) return [];
|
||||
const collected: string[] = [];
|
||||
let cursor = '0';
|
||||
do {
|
||||
@@ -47,12 +52,14 @@ export class SessionGCService {
|
||||
async collect(sessionId: string): Promise<GCResult> {
|
||||
const result: GCResult = { sessionId, cleaned: {} };
|
||||
|
||||
// 1. Valkey: delete all session-scoped keys
|
||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||
const valkeyKeys = await this.scanKeys(pattern);
|
||||
if (valkeyKeys.length > 0) {
|
||||
await this.redis.del(...valkeyKeys);
|
||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||
// 1. Valkey: delete all session-scoped keys (skipped on Local tier).
|
||||
if (this.redis) {
|
||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||
const valkeyKeys = await this.scanKeys(pattern);
|
||||
if (valkeyKeys.length > 0) {
|
||||
await this.redis.del(...valkeyKeys);
|
||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. PG: demote hot-tier agent logs for this session only.
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
|
||||
@Injectable()
|
||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CronService.name);
|
||||
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
|
||||
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
|
||||
|
||||
constructor(
|
||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||
@@ -26,6 +26,12 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// Local tier deliberately has no BullMQ consumers or repeatable jobs.
|
||||
if (!this.queueService.isEnabled()) {
|
||||
this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled');
|
||||
return;
|
||||
}
|
||||
|
||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||
|
||||
@@ -39,7 +45,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
||||
await this.summarization.runSummarization();
|
||||
});
|
||||
this.registeredWorkers.push(summarizationWorker);
|
||||
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
|
||||
|
||||
// M6-005: Tier management repeatable job
|
||||
await this.queueService.addRepeatableJob(
|
||||
@@ -51,7 +57,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
||||
await this.summarization.runTierManagement();
|
||||
});
|
||||
this.registeredWorkers.push(tierWorker);
|
||||
if (tierWorker) this.registeredWorkers.push(tierWorker);
|
||||
|
||||
// Retire any repeatable global GC schedule created by older deployments.
|
||||
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
||||
|
||||
@@ -0,0 +1,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,
|
||||
);
|
||||
});
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -4,7 +4,9 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { api } from './api';
|
||||
|
||||
describe('api', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('fetches the supplied relative path with credentials and a JSON body', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
api<{ ok: boolean }>('/api/projects', {
|
||||
method: 'POST',
|
||||
body: { name: 'Mosaic' },
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/projects',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name: 'Mosaic' }),
|
||||
headers: expect.objectContaining({
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws the gateway JSON error with its statusCode', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ statusCode: 403, message: 'Forbidden' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(api('/api/admin/users')).rejects.toMatchObject({
|
||||
name: 'Error',
|
||||
message: 'Forbidden',
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
||||
|
||||
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
}
|
||||
@@ -25,7 +23,7 @@ export async function api<T>(path: string, init?: ApiRequestInit): Promise<T> {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const res = await fetch(`${GATEWAY_URL}${path}`, {
|
||||
const res = await fetch(path, {
|
||||
credentials: 'include',
|
||||
...rest,
|
||||
headers,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('auth client origin contract', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('uses the same-origin BetterAuth mount at /api/auth', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ session: null, user: null }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { authClient } = await import('./auth-client');
|
||||
await authClient.getSession();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const firstCall = fetchMock.mock.calls.at(0);
|
||||
expect(firstCall).toBeDefined();
|
||||
const requestURL = new URL(String(firstCall?.[0]), window.location.origin);
|
||||
expect(requestURL.origin).toBe(window.location.origin);
|
||||
expect(requestURL.pathname).toBe('/api/auth/get-session');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createAuthClient } from 'better-auth/react';
|
||||
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
||||
|
||||
// The gateway and BetterAuth client both use /api/auth. Omitting baseURL keeps
|
||||
// every browser request on the current origin in development and production.
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242',
|
||||
plugins: [adminClient(), genericOAuthClient()],
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveAuthCallbackURL } from './auth-redirect';
|
||||
|
||||
const CURRENT_ORIGIN = 'https://mosaic.example';
|
||||
|
||||
describe('resolveAuthCallbackURL', () => {
|
||||
it('preserves a canonical same-origin path with search and hash', () => {
|
||||
expect(resolveAuthCallbackURL('/projects?view=active#current', CURRENT_ORIGIN)).toBe(
|
||||
'/projects?view=active#current',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
'chat',
|
||||
'//evil.example',
|
||||
'/..//evil.com',
|
||||
'/..//evil.com/x',
|
||||
'/./..//evil.com',
|
||||
'/../..//evil.com',
|
||||
'/foo/..//evil.com',
|
||||
'/\\evil.example',
|
||||
'/\n//evil.example',
|
||||
'/\r//evil.example',
|
||||
'/\t//evil.example',
|
||||
'https://evil.example/phish',
|
||||
])('falls back to chat for an unsafe callback target %#', (candidate) => {
|
||||
expect(resolveAuthCallbackURL(candidate, CURRENT_ORIGIN)).toBe('/chat');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const DEFAULT_AUTH_CALLBACK_URL = '/chat';
|
||||
|
||||
/**
|
||||
* Return a canonical same-origin path for post-auth navigation.
|
||||
*
|
||||
* Parsing before comparing origins rejects protocol-relative URLs, backslash
|
||||
* variants, and control characters that the WHATWG parser normalizes away.
|
||||
*/
|
||||
export function resolveAuthCallbackURL(candidate: string | null, currentOrigin: string): string {
|
||||
if (!candidate?.startsWith('/')) return DEFAULT_AUTH_CALLBACK_URL;
|
||||
|
||||
try {
|
||||
const expectedOrigin = new URL(currentOrigin).origin;
|
||||
const resolved = new URL(candidate, expectedOrigin);
|
||||
if (resolved.origin !== expectedOrigin || resolved.pathname.startsWith('//')) {
|
||||
return DEFAULT_AUTH_CALLBACK_URL;
|
||||
}
|
||||
|
||||
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
||||
} catch {
|
||||
return DEFAULT_AUTH_CALLBACK_URL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,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'],
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getEnabledSsoProviders, getSsoProvider } from './sso-providers';
|
||||
|
||||
describe('sso-providers', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('returns the enabled providers in login button order', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true');
|
||||
|
||||
expect(getEnabledSsoProviders()).toEqual([
|
||||
{
|
||||
id: 'workos',
|
||||
buttonLabel: 'Continue with WorkOS',
|
||||
description: 'Enterprise SSO via WorkOS',
|
||||
enabled: true,
|
||||
href: '/auth/provider/workos',
|
||||
},
|
||||
{
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
enabled: true,
|
||||
href: '/auth/provider/keycloak',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks disabled providers without exposing them in the enabled list', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false');
|
||||
|
||||
expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']);
|
||||
expect(getSsoProvider('keycloak')).toEqual({
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
enabled: false,
|
||||
href: '/auth/provider/keycloak',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unknown providers', () => {
|
||||
expect(getSsoProvider('authentik')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
export type SsoProviderId = 'workos' | 'keycloak';
|
||||
|
||||
export interface SsoProvider {
|
||||
id: SsoProviderId;
|
||||
buttonLabel: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const PROVIDER_METADATA: Record<SsoProviderId, Omit<SsoProvider, 'enabled' | 'href'>> = {
|
||||
workos: {
|
||||
id: 'workos',
|
||||
buttonLabel: 'Continue with WorkOS',
|
||||
description: 'Enterprise SSO via WorkOS',
|
||||
},
|
||||
keycloak: {
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
},
|
||||
};
|
||||
|
||||
export function getEnabledSsoProviders(): SsoProvider[] {
|
||||
return (Object.keys(PROVIDER_METADATA) as SsoProviderId[])
|
||||
.map((providerId) => getSsoProvider(providerId))
|
||||
.filter((provider): provider is SsoProvider => provider?.enabled === true);
|
||||
}
|
||||
|
||||
export function getSsoProvider(providerId: string): SsoProvider | null {
|
||||
if (!isSsoProviderId(providerId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...PROVIDER_METADATA[providerId],
|
||||
enabled: isSsoProviderEnabled(providerId),
|
||||
href: `/auth/provider/${providerId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function isSsoProviderId(value: string): value is SsoProviderId {
|
||||
return value === 'workos' || value === 'keycloak';
|
||||
}
|
||||
|
||||
function isSsoProviderEnabled(providerId: SsoProviderId): boolean {
|
||||
switch (providerId) {
|
||||
case 'workos':
|
||||
return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
|
||||
case 'keycloak':
|
||||
return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import { createAppRouter } from '@/routes';
|
||||
import '@/app/globals.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
throw new Error('missing #root element');
|
||||
}
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={createAppRouter()} />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { useSessionMock } = vi.hoisted(() => ({
|
||||
useSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
}));
|
||||
|
||||
import { AuthGuard, GuestGuard } from './guards';
|
||||
|
||||
interface RenderedRouter {
|
||||
container: HTMLDivElement;
|
||||
router: ReturnType<typeof createMemoryRouter>;
|
||||
}
|
||||
|
||||
const mountedRoots: Root[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
async function renderRouter(
|
||||
routeObjects: RouteObject[],
|
||||
initialEntry: string,
|
||||
): Promise<RenderedRouter> {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const router = createMemoryRouter(routeObjects, { initialEntries: [initialEntry] });
|
||||
const root = createRoot(container);
|
||||
mountedRoots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
return { container, router };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of mountedRoots.splice(0)) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
document.body.replaceChildren();
|
||||
useSessionMock.mockReset();
|
||||
});
|
||||
|
||||
const guestRoutes: RouteObject[] = [
|
||||
{
|
||||
path: '/login',
|
||||
element: <GuestGuard />,
|
||||
children: [{ index: true, element: <p>Guest page</p> }],
|
||||
},
|
||||
{ path: '/chat', element: <p>Chat page</p> },
|
||||
];
|
||||
|
||||
const authenticatedRoutes: RouteObject[] = [
|
||||
{
|
||||
path: '/chat',
|
||||
element: <AuthGuard />,
|
||||
children: [{ index: true, element: <p>Private page</p> }],
|
||||
},
|
||||
{ path: '/login', element: <p>Login page</p> },
|
||||
];
|
||||
|
||||
describe('GuestGuard', () => {
|
||||
it('renders the guest outlet while session lookup is pending', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
||||
|
||||
const view = await renderRouter(guestRoutes, '/login');
|
||||
|
||||
expect(view.container.textContent).toContain('Guest page');
|
||||
expect(view.router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('renders the guest outlet when no session exists', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||
|
||||
const view = await renderRouter(guestRoutes, '/login');
|
||||
|
||||
expect(view.container.textContent).toContain('Guest page');
|
||||
expect(view.router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('redirects an authenticated session to chat', async () => {
|
||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||
|
||||
const view = await renderRouter(guestRoutes, '/login');
|
||||
|
||||
expect(view.container.textContent).toContain('Chat page');
|
||||
expect(view.router.state.location.pathname).toBe('/chat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthGuard', () => {
|
||||
it('renders the existing loading treatment while session lookup is pending', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
||||
|
||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||
|
||||
expect(view.container.textContent).toContain('Loading...');
|
||||
expect(view.router.state.location.pathname).toBe('/chat');
|
||||
});
|
||||
|
||||
it('redirects an unauthenticated visitor to login', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||
|
||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||
|
||||
expect(view.container.textContent).toContain('Login page');
|
||||
expect(view.router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('renders the authenticated outlet when a session exists', async () => {
|
||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||
|
||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||
|
||||
expect(view.container.textContent).toContain('Private page');
|
||||
expect(view.router.state.location.pathname).toBe('/chat');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
export function GuestGuard(): ReactElement {
|
||||
const { data: session } = useSession();
|
||||
|
||||
return session ? <Navigate to="/chat" replace /> : <Outlet />;
|
||||
}
|
||||
|
||||
export function AuthGuard(): ReactElement {
|
||||
const { data: session, isPending } = useSession();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState, type FormEvent, type ReactElement } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons';
|
||||
import { api } from '@/lib/api';
|
||||
import { authClient, signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
|
||||
export function LoginPage(): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
||||
const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState<
|
||||
SsoProviderDiscovery['id'] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
void api<SsoProviderDiscovery[]>('/api/sso/providers').then(
|
||||
(providers) => {
|
||||
if (active) setSsoProviders(providers.filter((provider) => provider.configured));
|
||||
},
|
||||
() => {
|
||||
if (active) setSsoProviders([]);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(event.currentTarget);
|
||||
const email = String(form.get('email') ?? '');
|
||||
const password = String(form.get('password') ?? '');
|
||||
|
||||
try {
|
||||
const result = await signIn.email({ email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Sign in failed');
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/chat', { replace: true });
|
||||
} catch (caught: unknown) {
|
||||
setError(caught instanceof Error ? caught.message : 'Sign in failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise<void> {
|
||||
setError(null);
|
||||
setSsoLoadingProviderId(providerId);
|
||||
|
||||
try {
|
||||
const result = await authClient.signIn.oauth2({
|
||||
providerId,
|
||||
callbackURL: '/chat',
|
||||
newUserCallbackURL: '/chat',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? `Sign in with ${providerId} failed`);
|
||||
setSsoLoadingProviderId(null);
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
setError(caught instanceof Error ? caught.message : `Sign in with ${providerId} failed`);
|
||||
setSsoLoadingProviderId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Sign in</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Sign in to your Mosaic account</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<SsoProviderButtons
|
||||
providers={ssoProviders}
|
||||
loadingProviderId={ssoLoadingProviderId}
|
||||
onOidcSignIn={(providerId) => {
|
||||
void handleSsoSignIn(providerId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-text-muted">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-blue-400 hover:text-blue-300">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState, type FormEvent, type ReactElement } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { signUp } from '@/lib/auth-client';
|
||||
|
||||
export function RegisterPage(): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(event.currentTarget);
|
||||
const name = String(form.get('name') ?? '');
|
||||
const email = String(form.get('email') ?? '');
|
||||
const password = String(form.get('password') ?? '');
|
||||
|
||||
try {
|
||||
const result = await signUp.email({ name, email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Registration failed');
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/chat', { replace: true });
|
||||
} catch (caught: unknown) {
|
||||
setError(caught instanceof Error ? caught.message : 'Registration failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Create account</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Get started with Mosaic</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-text-secondary">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-text-muted">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-blue-400 hover:text-blue-300">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { apiMock, oauth2Mock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
oauth2Mock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
signIn: { oauth2: oauth2Mock },
|
||||
}));
|
||||
|
||||
import { SsoCallbackPage } from './sso-callback';
|
||||
|
||||
const mountedRoots: Root[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
apiMock.mockResolvedValue([
|
||||
{
|
||||
id: 'authentik',
|
||||
name: 'Authentik',
|
||||
protocols: ['oidc'],
|
||||
configured: true,
|
||||
loginMode: 'oidc',
|
||||
callbackPath: '/api/auth/oauth2/callback/authentik',
|
||||
teamSync: { enabled: false, claim: null },
|
||||
samlFallback: { configured: false, loginUrl: null },
|
||||
warnings: [],
|
||||
},
|
||||
]);
|
||||
oauth2Mock.mockResolvedValue({ data: null, error: null });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of mountedRoots.splice(0)) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
document.body.replaceChildren();
|
||||
apiMock.mockReset();
|
||||
oauth2Mock.mockReset();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
describe('SsoCallbackPage', () => {
|
||||
it('rejects a control-character callback that normalizes to an external origin', async () => {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: '/auth/provider/:provider',
|
||||
element: <SsoCallbackPage />,
|
||||
},
|
||||
],
|
||||
{
|
||||
initialEntries: ['/auth/provider/authentik?callbackURL=%2F%0A%2F%2Fevil.example'],
|
||||
},
|
||||
);
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
mountedRoots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<RouterProvider router={router} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(oauth2Mock).toHaveBeenCalledWith({
|
||||
providerId: 'authentik',
|
||||
callbackURL: '/chat',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState, type ReactElement } from 'react';
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
|
||||
export function SsoCallbackPage(): ReactElement {
|
||||
const { provider: providerId = '' } = useParams<'provider'>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
||||
const [providerName, setProviderName] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function redirectToProvider(): Promise<void> {
|
||||
try {
|
||||
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
||||
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
||||
if (cancelled) return;
|
||||
|
||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||
if (!provider) {
|
||||
setError('Unknown SSO provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProviderName(provider.name);
|
||||
if (!provider.configured) {
|
||||
setError(`${provider.name} is not enabled in this deployment.`);
|
||||
return;
|
||||
}
|
||||
if (provider.loginMode !== 'oidc') {
|
||||
setError(`${provider.name} is not available for OIDC sign in.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signIn.oauth2({
|
||||
providerId: provider.id,
|
||||
callbackURL,
|
||||
});
|
||||
|
||||
if (!cancelled && result?.error) {
|
||||
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void redirectToProvider();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [providerId, requestedCallbackURL]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
{providerName
|
||||
? `Redirecting you to ${providerName}...`
|
||||
: 'Preparing your sign-in request...'}
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
<p>{error}</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Return to login
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
|
||||
If the redirect does not start automatically, return to the login page and try again.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export function Placeholder({ title }: { title: string }): ReactElement {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<h1 className="text-xl font-medium">{title}</h1>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Vitest abort-controller realm', () => {
|
||||
it('provides a global signal accepted by Node native Request', () => {
|
||||
const controller = new AbortController();
|
||||
const request = new Request('https://mosaic.invalid/navigation', {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(request.signal).toBeInstanceOf(AbortSignal);
|
||||
controller.abort();
|
||||
expect(request.signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { transferableAbortController } from 'node:util';
|
||||
|
||||
// jsdom installs realm-local abort constructors while Node's undici Request
|
||||
// remains native. React Router passes a global AbortSignal to Request, so both
|
||||
// constructors must come from Node's native realm during tests.
|
||||
const nativeController = transferableAbortController();
|
||||
const nativeAbortController = nativeController.constructor;
|
||||
const nativeAbortSignal = nativeController.signal.constructor;
|
||||
|
||||
for (const target of [globalThis, window]) {
|
||||
Object.defineProperties(target, {
|
||||
AbortController: {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: nativeAbortController,
|
||||
},
|
||||
AbortSignal: {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: nativeAbortSignal,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// The proxy exists only in dev; in production the SPA is same-origin with the gateway
|
||||
// (served by it under Candidate A, or behind one FQDN under Candidate B) and every
|
||||
// request uses a relative path, so no origin may ever be configured here or in src/.
|
||||
const gatewayTarget = 'http://localhost:14242';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3100,
|
||||
proxy: {
|
||||
'/api': gatewayTarget,
|
||||
'/socket.io': { target: gatewayTarget, ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,21 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
// tsconfig uses "jsx": "preserve" for Next; tests need esbuild to compile it
|
||||
esbuild: {
|
||||
jsx: 'automatic',
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
isolate: true,
|
||||
exclude: ['e2e/**', 'node_modules/**'],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/gateway/package.json ./apps/gateway/
|
||||
COPY packages/ ./packages/
|
||||
COPY plugins/ ./plugins/
|
||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||
COPY scripts/ ./scripts/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
||||
@@ -21,11 +23,22 @@ RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
# WorkspaceService shells out to git at runtime and roots workspaces at
|
||||
# $MOSAIC_ROOT/.workspaces (apps/gateway/src/workspace/workspace.service.ts);
|
||||
# mount a volume over /opt/mosaic to persist workspaces across container restarts.
|
||||
# Intentionally unpinned: Alpine's signed repository is the trust anchor; pinning
|
||||
# git was declined so routine base-image security updates remain maintainable.
|
||||
RUN apk add --no-cache git \
|
||||
&& mkdir -p /opt/mosaic/.workspaces \
|
||||
&& chown -R node:node /opt/mosaic /app
|
||||
ENV MOSAIC_ROOT=/opt/mosaic
|
||||
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
||||
COPY --from=builder /deploy/node_modules ./node_modules
|
||||
COPY --from=builder /deploy/package.json ./package.json
|
||||
COPY --chown=node:node --from=builder /deploy/node_modules ./node_modules
|
||||
COPY --chown=node:node --from=builder /deploy/package.json ./package.json
|
||||
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
||||
# copy from builder explicitly as belt-and-suspenders
|
||||
COPY --from=builder /app/apps/gateway/dist ./dist
|
||||
EXPOSE 4000
|
||||
COPY --chown=node:node --from=builder /app/apps/gateway/dist ./dist
|
||||
# gateway defaults to port 14242 (apps/gateway/src/main.ts)
|
||||
EXPOSE 14242
|
||||
USER node
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
@@ -8,9 +8,11 @@ WORKDIR /app
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/ ./packages/
|
||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||
COPY scripts/ ./scripts/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm --filter @mosaic/web build
|
||||
RUN pnpm --filter @mosaicstack/web build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
-184
@@ -102,128 +102,6 @@ Context compaction, session replacement, and same-PID runtime reloads can leave
|
||||
|
||||
---
|
||||
|
||||
## Pi Persistent Goal Loop (#1150)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
A Pi agent can stop after a plausible-looking answer even when the operator's broader objective is
|
||||
not complete, and ordinary compaction can weaken or omit the original objective. Mosaic needs an
|
||||
optional, operator-controlled goal loop that keeps a Pi session oriented, checks progress at native
|
||||
lifecycle boundaries, and resumes work until completion is verified or a bounded safety state is
|
||||
reached.
|
||||
|
||||
The objective is a Mosaic-owned Pi extension deployed from the framework into
|
||||
`~/.config/mosaic/runtime/pi/`. It must not install into or depend on `~/.pi/agent/extensions/`.
|
||||
|
||||
### Scope
|
||||
|
||||
#### In scope
|
||||
|
||||
1. `PGL-REQ-01`: The framework SHALL ship a dedicated Pi goal extension under
|
||||
`packages/mosaic/framework/runtime/pi/`, seed it under `$MOSAIC_HOME/runtime/pi/`, and make
|
||||
`mosaic pi` load it alongside the core Mosaic extension when present.
|
||||
2. `PGL-REQ-02`: `/goal` SHALL support setting a goal plus status, pause, resume, cancel, and help
|
||||
operations without silently replacing an active goal.
|
||||
3. `PGL-REQ-03`: Active branch-specific goal state SHALL be persisted in Pi custom session entries,
|
||||
restored on session start and tree navigation, and never rely on a compaction summary as its
|
||||
source of truth.
|
||||
4. `PGL-REQ-04`: A hidden goal contract SHALL be injected through Pi's `context` event before every
|
||||
model request so it remains effective across tool turns, retries, and post-compaction requests.
|
||||
5. `PGL-REQ-05`: The harness SHALL inspect every `turn_end` and successful `session_compact` event.
|
||||
A structured terminating goal-report tool SHALL capture `continue`, evidence-bearing `achieved`,
|
||||
or `blocked` status without requiring a redundant model turn.
|
||||
6. `PGL-REQ-06`: An achievement claim SHALL remain provisional until a second consecutive
|
||||
evidence-bearing verification report. Any continuation report or successful compaction during
|
||||
verification SHALL reset the verification sequence.
|
||||
7. `PGL-REQ-07`: Continuation SHALL be initiated at safe lifecycle boundaries, primarily
|
||||
`agent_settled`; manual compaction and restored active sessions may schedule a deferred idle
|
||||
continuation without re-entering compaction handlers.
|
||||
8. `PGL-REQ-08`: The loop SHALL have operator cancellation plus bounded turn and repeated-no-progress
|
||||
limits. Exhausted or blocked goals pause rather than continuing indefinitely.
|
||||
9. `PGL-REQ-09`: Framework installation and update SHALL preserve normal manifest ownership: the
|
||||
goal extension is framework-owned under `runtime/**`, while no goal extension or configuration
|
||||
asset is created or modified under the operator's main Pi configuration. Pi remains the owner of
|
||||
its native session files used by `appendEntry()`.
|
||||
|
||||
#### Out of scope
|
||||
|
||||
1. A mathematical guarantee that an arbitrary natural-language goal is semantically complete.
|
||||
2. Automatically executing user-supplied shell predicates or accepting executable validation code in
|
||||
`/goal` arguments.
|
||||
3. Restarting Pi after process, host, or supervisor failure; the existing Mosaic fleet/runtime
|
||||
supervisor owns process durability.
|
||||
4. Gateway, database, web UI, Discord, or cross-harness goal orchestration in this slice.
|
||||
|
||||
### User and stakeholder requirements
|
||||
|
||||
- An operator can start a goal from Pi and see its current phase, evidence, limits, and latest report.
|
||||
- The agent remains oriented after each turn and compaction until verified, paused, blocked,
|
||||
exhausted, or cancelled.
|
||||
- Local testing uses a file under `~/.config/mosaic/runtime/pi/`; the feature never writes an
|
||||
extension asset to `~/.pi/agent/extensions/`.
|
||||
- Framework updates deploy the same reviewed extension source through Mosaic's existing manifest
|
||||
sync path.
|
||||
|
||||
### Non-functional requirements
|
||||
|
||||
1. **Safety:** bounded continuation, explicit cancellation, no arbitrary command execution, and no
|
||||
completion without non-empty reported evidence.
|
||||
2. **Reliability:** serialized continuation scheduling, branch-aware restoration, compaction-safe
|
||||
context injection, and stale-timer cancellation on session shutdown.
|
||||
3. **Performance:** no extra nested judge-model request on every turn; structured reporting uses the
|
||||
active agent's final terminating tool call.
|
||||
4. **Observability:** Pi status/notifications expose phase and bounded counters without recording
|
||||
credentials or hidden model reasoning.
|
||||
5. **Maintainability:** the state machine is deterministic and behavior-tested independently from Pi
|
||||
provider/network access.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. `AC-PGL-01`: A framework-sync fixture installs the extension at
|
||||
`$MOSAIC_HOME/runtime/pi/goal-extension.ts`, and launcher tests prove both Mosaic Pi extensions are
|
||||
emitted in deterministic order while absent optional files remain backward-compatible.
|
||||
2. `AC-PGL-02`: Command tests prove set/status/pause/resume/cancel behavior, active-goal replacement
|
||||
refusal, and bounded input handling.
|
||||
3. `AC-PGL-03`: Lifecycle tests prove every turn is recorded, active context is injected on every
|
||||
request, two evidence-bearing achievement reports are required, and `agent_settled` continues an
|
||||
unmet goal without duplicate scheduling.
|
||||
4. `AC-PGL-04`: Compaction and restoration tests prove goal state survives, verification is reset and
|
||||
rechecked after compaction, manual compaction continuation is deferred until idle, and tree/session
|
||||
branch state is reconstructed correctly.
|
||||
5. `AC-PGL-05`: Limit tests prove max-turn and repeated-no-progress exhaustion stop autonomous
|
||||
continuation, while pause/cancel/blocked states do not restart.
|
||||
6. `AC-PGL-06`: Focused tests, package typecheck/lint/test, repository quality gates, a local Pi load
|
||||
smoke test from `~/.config/mosaic/runtime/pi/`, independent review, and terminal-green CI pass before
|
||||
issue #1150 closes.
|
||||
|
||||
### Constraints, risks, and assumptions
|
||||
|
||||
- Dependency: Pi's extension API must continue to provide `registerCommand`, `registerTool`,
|
||||
`context`, `turn_end`, `agent_settled`, `session_compact`, session custom entries, and terminating
|
||||
tool results.
|
||||
- Risk: the working agent can overstate completion. Mitigation: structured evidence, a mandatory
|
||||
second verification pass, explicit semantic limitations, and operator-visible reports.
|
||||
- Risk: an impossible goal can consume unbounded resources. Mitigation: hard turn/no-progress bounds
|
||||
and paused terminal states.
|
||||
- Risk: automatic continuation can race compaction or session replacement. Mitigation: drive from
|
||||
`agent_settled`, defer idle restarts, generation-check timers, and clear timers on shutdown.
|
||||
- `ASSUMPTION:` Two consecutive evidence-bearing reports are the initial local verification policy;
|
||||
rationale: it provides a real recheck without doubling every turn's model cost. Future policy may
|
||||
add independent or deterministic validators.
|
||||
- `ASSUMPTION:` Default limits are 40 turns and 6 repeated no-progress reports, configurable only by
|
||||
bounded Mosaic environment settings; rationale: useful persistence with a finite autonomous budget.
|
||||
- `ASSUMPTION:` Documentation remains canonical in-repo for this slice; no external docs publication
|
||||
is requested.
|
||||
|
||||
### Testing and delivery intent
|
||||
|
||||
Use TDD for the deterministic controller and lifecycle invariants. Test with fake Pi lifecycle
|
||||
objects first, then run a local load/smoke test from the deployed Mosaic path. Deliver source, tests,
|
||||
launcher wiring, framework/runtime documentation, user/developer guides, and sitemap updates in one
|
||||
reviewed squash PR to `main` with terminal-green CI.
|
||||
|
||||
---
|
||||
|
||||
## Fleet Declarative Configuration Management Workstream (FCM, #758)
|
||||
|
||||
### Problem and objective
|
||||
@@ -268,68 +146,6 @@ lands. M0 consists only of these normative requirements, the complete task DAG,
|
||||
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
|
||||
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
|
||||
|
||||
### Fleet git identity launch propagation (#1043)
|
||||
|
||||
#### Problem and objective
|
||||
|
||||
A fleet seat can have a registered per-agent Git credential while its launched runtime process lacks
|
||||
`MOSAIC_GIT_IDENTITY`. The credential resolver then cannot select the seat identity reliably, which
|
||||
blocks repository operations on fail-closed estates and can fall through to an unrelated identity on
|
||||
estates where that refusal is not active. The objective is to make Git identity a deterministic,
|
||||
roster-derived part of the generated launch projection and prove it reaches the launched process.
|
||||
|
||||
#### Normative requirements
|
||||
|
||||
1. `FGI-REQ-01`: Every generated fleet agent projection SHALL declare
|
||||
`MOSAIC_GIT_IDENTITY=<MOSAIC_AGENT_NAME>`; a differing or unsafe identity SHALL fail closed before
|
||||
tmux launch.
|
||||
2. `FGI-REQ-02`: The clean `/usr/bin/env -i` pane boundary SHALL pass every variable declared by the
|
||||
generated projection, including `MOSAIC_GIT_IDENTITY`, to the launched runtime process.
|
||||
3. `FGI-REQ-03`: A behavioral integration test SHALL set-compare the complete generated projection
|
||||
against the launched process environment. Source-text/string-presence assertions are insufficient.
|
||||
4. `FGI-REQ-04`: Verification SHALL include RED-first evidence and a delete-the-subject mutation that
|
||||
removes Git-identity pane propagation and makes the behavioral test fail.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
1. `AC-FGI-01`: A launched seat process contains every key/value pair declared by its generated
|
||||
environment projection, including the roster-derived Git identity.
|
||||
2. `AC-FGI-02`: Missing, unsafe, or split Git identity is rejected before a tmux session is created.
|
||||
3. `AC-FGI-03`: Focused launcher and generated-environment tests, repository quality gates,
|
||||
independent review, and the required RED/green/R7 evidence are recorded before push.
|
||||
|
||||
### Framework shell assertion portability (#1098)
|
||||
|
||||
#### Problem and objective
|
||||
|
||||
The blocking framework-shell chain can report that a pane command omitted `/usr/bin/env -i` even when
|
||||
`-i` matched successfully. A short-circuiting `grep -q` under `set -o pipefail` may close its pipe after
|
||||
the match and cause an upstream producer to exit with SIGPIPE, turning a valid semantic result into a
|
||||
nonzero aggregate pipeline. The objective is to inspect the captured NUL-delimited argv directly and
|
||||
make failures carry the observed records needed for diagnosis.
|
||||
|
||||
#### Normative requirements
|
||||
|
||||
1. `FSP-REQ-01`: The pane-boundary test SHALL validate an adjacent `/usr/bin/env`, `-i` argv pair from
|
||||
the authoritative NUL-delimited tmux capture without a short-circuit pipeline whose upstream status
|
||||
can override a successful match.
|
||||
2. `FSP-REQ-02`: Missing, reversed, or non-adjacent boundary tokens SHALL fail, while valid boundaries
|
||||
SHALL remain valid regardless of trailing argv size, pipe capacity, process scheduling, or host/CI
|
||||
utility implementation.
|
||||
3. `FSP-REQ-03`: A failed boundary check SHALL print stable indexed, shell-escaped observed argv records
|
||||
before exiting nonzero; the fixture SHALL continue to contain generated non-secret launch data only.
|
||||
4. `FSP-REQ-04`: Verification SHALL include RED-first large-payload evidence, negative token-order
|
||||
controls, the complete focused launcher suite, canonical Woodpecker CI, and independent review.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
1. `AC-FSP-01`: A large captured argv with adjacent `/usr/bin/env`, `-i` passes even when the former
|
||||
`grep -q` pipeline returns nonzero from an upstream SIGPIPE.
|
||||
2. `AC-FSP-02`: Missing executable, missing flag, and detached/reversed flag fixtures return nonzero and
|
||||
emit the indexed observed argv.
|
||||
3. `AC-FSP-03`: The focused suite passes on the development host and CI image, and the merged-main
|
||||
Woodpecker pipeline is terminal green before #1098 closes.
|
||||
|
||||
---
|
||||
|
||||
## Exact Cross-Harness Fleet Communications Contract (#766)
|
||||
|
||||
@@ -14,13 +14,6 @@
|
||||
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
||||
- [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes.
|
||||
|
||||
## Pi persistent goals
|
||||
|
||||
- [Persistent goal user guide](guides/user-guide.md#pi-persistent-goals) — `/goal` commands, verification behavior, limits, compaction/resume semantics, and limitations.
|
||||
- [Goal extension developer guide](guides/dev-guide.md#pi-persistent-goal-extension) — framework ownership, launcher ordering, lifecycle design, tests, and local Mosaic-path smoke workflow.
|
||||
- [Goal loop operations](guides/admin-guide.md#pi-goal-loop-operations) — deployment ownership, bounded settings, pause/resume procedures, and supervisor boundary.
|
||||
- [Pi runtime reference](../packages/mosaic/framework/runtime/pi/RUNTIME.md#extensions) — deployed paths, command summary, and bounded environment settings.
|
||||
|
||||
## Fleet configuration management
|
||||
|
||||
- [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
|
||||
|
||||
@@ -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.
|
||||
+10
-13
@@ -5,14 +5,14 @@ Generated environment files are rebuildable projections, not an operator-editabl
|
||||
|
||||
## Launch chain
|
||||
|
||||
| Layer | Responsibility |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket; Git identity is derived from the exact agent name. |
|
||||
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
|
||||
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
|
||||
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
|
||||
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
|
||||
| runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
|
||||
| Layer | Responsibility |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. |
|
||||
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
|
||||
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
|
||||
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
|
||||
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
|
||||
| runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
|
||||
|
||||
The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
|
||||
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
|
||||
@@ -24,7 +24,6 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf
|
||||
|
||||
```dotenv
|
||||
MOSAIC_AGENT_NAME=<roster name>
|
||||
MOSAIC_GIT_IDENTITY=<roster name>
|
||||
MOSAIC_AGENT_CLASS=<roster class>
|
||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||
@@ -34,10 +33,8 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||
```
|
||||
|
||||
`MOSAIC_GIT_IDENTITY` is not independently configurable: it must equal `MOSAIC_AGENT_NAME`, preventing
|
||||
split runtime and repository identity authority. The generated launch contract supports `claude`,
|
||||
`codex`, `opencode`, and `pi`. mosaic fleet add rejects another runtime before it writes the roster or
|
||||
modifies generated, local, or quarantine state.
|
||||
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add
|
||||
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
|
||||
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
|
||||
it has no generated-launch adapter and cannot be added through this path.
|
||||
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
The launcher consumes validated data, not shell configuration.
|
||||
|
||||
1. Read and validate the canonical roster.
|
||||
2. Render deterministic <name>.env.generated data from that roster, including `MOSAIC_GIT_IDENTITY` derived exactly from the roster agent name.
|
||||
2. Render deterministic <name>.env.generated data from that roster.
|
||||
3. Parse optional <name>.env.local through a strict allowlist.
|
||||
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
|
||||
5. Reject a Git identity that is unsafe or differs from the generated agent name.
|
||||
6. Derive the runtime command from validated runtime/model/reasoning data and pass every generated projection entry through the clean process environment boundary.
|
||||
7. Target only the exact configured tmux socket and roster session after ownership checks.
|
||||
5. Derive the runtime command from validated runtime/model/reasoning data.
|
||||
6. Target only the exact configured tmux socket and roster session after ownership checks.
|
||||
|
||||
## File precedence and ownership
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ values, credential material, or command text.
|
||||
|
||||
```dotenv
|
||||
MOSAIC_AGENT_NAME=<roster name>
|
||||
MOSAIC_GIT_IDENTITY=<roster name>
|
||||
MOSAIC_AGENT_CLASS=<roster class>
|
||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||
@@ -45,9 +44,8 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||
```
|
||||
|
||||
`MOSAIC_GIT_IDENTITY` is derived from and must equal `MOSAIC_AGENT_NAME`; it is not a separate
|
||||
operator-controlled identity authority. The generated launch contract supports only `claude`, `codex`,
|
||||
`opencode`, and `pi`. fleet add uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
||||
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add
|
||||
uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
||||
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
|
||||
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
|
||||
this projection path.
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
3. [Provider Configuration](#provider-configuration)
|
||||
4. [MCP Server Configuration](#mcp-server-configuration)
|
||||
5. [Environment Variables Reference](#environment-variables-reference)
|
||||
6. [Pi Goal Loop Operations](#pi-goal-loop-operations)
|
||||
7. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
6. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -265,16 +264,6 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
|
||||
| `AGENT_SYSTEM_PROMPT` | — | Platform-level system prompt injected into all sessions |
|
||||
| `AGENT_USER_TOOLS` | all tools | Comma-separated allowlist of tools for non-admin users |
|
||||
|
||||
### Mosaic Pi goal loop
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ----------------------------- | ------- | -------------------------------------------------------------------- |
|
||||
| `MOSAIC_GOAL_MAX_TURNS` | `40` | Per-goal autonomous turn limit; accepted range `1..500` |
|
||||
| `MOSAIC_GOAL_MAX_NO_PROGRESS` | `6` | Consecutive identical progress-report limit; accepted range `1..100` |
|
||||
|
||||
These variables are consumed by the framework-owned Pi goal extension at goal creation. Invalid or
|
||||
out-of-range values fall back to the defaults; they do not disable the bounds.
|
||||
|
||||
### Providers
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -385,29 +374,3 @@ Session cleanup is scoped to one session identifier and only removes that sessio
|
||||
| Variable | Default | Description |
|
||||
| ----------------------- | ----------------------------- | ------------------------------------------ |
|
||||
| `MOSAIC_WORKSPACE_ROOT` | monorepo root (auto-detected) | Root path for mission workspace operations |
|
||||
|
||||
---
|
||||
|
||||
## Pi Goal Loop Operations
|
||||
|
||||
The reviewed runtime asset is deployed at
|
||||
`~/.config/mosaic/runtime/pi/goal-extension.ts` by framework install/update. Do not install another
|
||||
copy under `~/.pi/agent/extensions/`; duplicate registration can create suffixed commands and two
|
||||
competing lifecycle controllers.
|
||||
|
||||
Operational checks:
|
||||
|
||||
1. Run `mosaic pi` and verify `/goal help` is available.
|
||||
2. Use `/goal status` to inspect phase, turn/no-progress limits, compaction checks, and evidence.
|
||||
Reports persist in Pi session data; controller-owned state redacts common credential shapes, but
|
||||
Pi's model/tool-call history is separate. Operators must not place secrets or raw sensitive output
|
||||
in goals, pause reasons, or evidence.
|
||||
3. Use `/goal pause <reason>` before planned maintenance or manual investigation. Pause and cancel
|
||||
abort the current goal-driven run when Pi is busy.
|
||||
4. Use `/goal resume` only after addressing a blocker; counters restart with the configured bounds.
|
||||
5. Use `/goal cancel` before replacing an unfinished goal.
|
||||
|
||||
A blocked or exhausted goal remains stopped and visible; Mosaic does not automatically raise its
|
||||
limits or restart the process. Framework sync owns file deployment, while Pi's native session file
|
||||
owns branch replay. Process/host restart remains the responsibility of the existing runtime or fleet
|
||||
supervisor.
|
||||
|
||||
+13
-82
@@ -9,9 +9,8 @@
|
||||
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
||||
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
||||
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
|
||||
8. [Pi Persistent Goal Extension](#pi-persistent-goal-extension)
|
||||
9. [API Endpoint Reference](#api-endpoint-reference)
|
||||
10. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
8. [API Endpoint Reference](#api-endpoint-reference)
|
||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -196,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
|
||||
@@ -386,85 +396,6 @@ M1 intentionally manages Claude Code only. Pi's Mosaic launcher can discover the
|
||||
canonical root directly. Codex still relies on the existing full skill-sync
|
||||
linker and needs separate parity analysis before this lifecycle API is extended.
|
||||
|
||||
## Pi Persistent Goal Extension
|
||||
|
||||
The source of the Mosaic-owned Pi goal controller is:
|
||||
|
||||
```text
|
||||
packages/mosaic/framework/runtime/pi/goal-extension.ts
|
||||
```
|
||||
|
||||
The framework manifest classifies `runtime/**` as framework-owned. Both the bash installer and the
|
||||
TypeScript file adapter therefore deploy the same reviewed source to:
|
||||
|
||||
```text
|
||||
$MOSAIC_HOME/runtime/pi/goal-extension.ts
|
||||
# default: ~/.config/mosaic/runtime/pi/goal-extension.ts
|
||||
```
|
||||
|
||||
Do not copy or link this extension into `~/.pi/agent/extensions/`. The launcher function
|
||||
`discoverPiExtensionArgs()` emits the core `mosaic-extension.ts` first and the optional
|
||||
`goal-extension.ts` second, preserving compatibility with an older installed framework that does
|
||||
not have the goal file yet.
|
||||
|
||||
### Lifecycle design
|
||||
|
||||
| Pi API | Goal-controller responsibility |
|
||||
| ------------------------------ | --------------------------------------------------------------------------------- |
|
||||
| `registerCommand('goal')` | Set, inspect, pause, resume, or cancel one branch-specific goal |
|
||||
| `registerTool(...)` | Record a terminating structured progress report with evidence |
|
||||
| `context` | Inject the active goal contract before every provider request |
|
||||
| `turn_end` | Record every turn, reject mixed final reports, and enforce the turn bound |
|
||||
| `agent_settled` | Start one deduplicated continuation only after Pi has no retry/compact/queue work |
|
||||
| `session_compact` | Record the compact check, reset provisional verification, and defer idle work |
|
||||
| `session_start`/`session_tree` | Rebuild state from custom entries on the active branch |
|
||||
| `session_shutdown` | Invalidate deferred callbacks and clear UI state |
|
||||
|
||||
State is appended as `mosaic-goal-state` custom entries, which do not enter model context. The
|
||||
`context` hook creates a fresh hidden `mosaic-goal-context` message for each request instead of
|
||||
trusting compaction summaries. The `mosaic_goal_report` result uses `terminate: true`; when it is the
|
||||
sole final tool call, Pi avoids an unnecessary model response before the controller decides whether
|
||||
to verify, continue, or stop.
|
||||
|
||||
Before state is appended or displayed, the controller applies bounded credential-pattern redaction
|
||||
to the goal statement, report summary/evidence/next step, and stop reason. Fingerprints are computed
|
||||
over redacted report content. Pi session entries are append-only, so a credential-bearing legacy
|
||||
entry cannot honestly be erased by the extension: restoration fails closed, emits a warning, and
|
||||
requires removal of the affected session before setting a new goal. This is defense-in-depth rather
|
||||
than a secret-storage contract, and it does not rewrite Pi's separate model-message/tool-call
|
||||
history. Goal prompts tell the agent not to submit credentials or raw sensitive output, and tests use
|
||||
canaries to prove known forms do not reach new custom entries, status text, context, or tool details
|
||||
while ordinary typed fields such as `token: string` remain intact.
|
||||
|
||||
Completion remains evidence-gated but semantic: two consecutive `achieved` reports are required,
|
||||
and the second run is explicitly a verification pass. This avoids an extra judge-model request after
|
||||
every turn. Deterministic validator commands are intentionally not accepted as `/goal` input in this
|
||||
slice, so never describe this mechanism as proof of arbitrary natural-language completion.
|
||||
|
||||
### Tests and local smoke workflow
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/mosaic exec vitest run \
|
||||
src/runtime/pi-goal-extension.spec.ts \
|
||||
src/commands/launch.spec.ts \
|
||||
src/config/file-adapter.test.ts
|
||||
|
||||
bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
||||
```
|
||||
|
||||
For an additive local smoke test without reseeding unrelated live framework files:
|
||||
|
||||
```bash
|
||||
install -D -m 0644 \
|
||||
packages/mosaic/framework/runtime/pi/goal-extension.ts \
|
||||
~/.config/mosaic/runtime/pi/goal-extension.ts
|
||||
|
||||
pi --extension ~/.config/mosaic/runtime/pi/goal-extension.ts
|
||||
```
|
||||
|
||||
Use `/goal help`, `/goal set ...`, and `/goal status` in that test session. A released framework
|
||||
sync installs the file, and a released Mosaic CLI loads it automatically through `mosaic pi`.
|
||||
|
||||
## API Endpoint Reference
|
||||
|
||||
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
||||
|
||||
+15
-57
@@ -8,10 +8,9 @@
|
||||
4. [Tasks](#tasks)
|
||||
5. [Settings](#settings)
|
||||
6. [CLI Usage](#cli-usage)
|
||||
7. [Pi Persistent Goals](#pi-persistent-goals)
|
||||
8. [Sub-package Commands](#sub-package-commands)
|
||||
9. [Telemetry](#telemetry)
|
||||
10. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
7. [Sub-package Commands](#sub-package-commands)
|
||||
8. [Telemetry](#telemetry)
|
||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -176,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
|
||||
@@ -308,57 +317,6 @@ mosaic prdy
|
||||
mosaic quality-rails
|
||||
```
|
||||
|
||||
## Pi Persistent Goals
|
||||
|
||||
`mosaic pi` loads a Mosaic-owned goal extension from
|
||||
`~/.config/mosaic/runtime/pi/goal-extension.ts`. It is deliberately not installed in
|
||||
`~/.pi/agent/extensions/`; framework installation and updates manage it with the rest of the Mosaic
|
||||
runtime assets.
|
||||
|
||||
Start Pi, then set a goal:
|
||||
|
||||
```text
|
||||
/goal set Deliver the feature, tests, documentation, and verification evidence
|
||||
# Shorthand:
|
||||
/goal Deliver the feature, tests, documentation, and verification evidence
|
||||
```
|
||||
|
||||
Control and inspect the loop with:
|
||||
|
||||
| Command | Behavior |
|
||||
| ---------------------- | ------------------------------------------------------------------ |
|
||||
| `/goal status` | Show phase, limits, compaction checks, latest report, and evidence |
|
||||
| `/goal pause [reason]` | Stop autonomous continuation while preserving the goal |
|
||||
| `/goal resume` | Resume with fresh turn and no-progress counters |
|
||||
| `/goal cancel` | Cancel the goal and remove its active status |
|
||||
| `/goal help` | Show command help |
|
||||
|
||||
While a goal is active, Mosaic injects its contract before every Pi model request and checks every
|
||||
completed model/tool turn. The agent ends each work cycle with the structured
|
||||
`mosaic_goal_report` tool. `achieved` is provisional until a second consecutive report rechecks the
|
||||
whole goal with evidence. A continuation report or a successful compaction resets provisional
|
||||
verification.
|
||||
|
||||
Goal statements and reports are stored in Pi session data. Mosaic redacts common credential shapes
|
||||
before appending its goal-state entries and before goal tool output or `/goal status`, but
|
||||
pattern-based redaction is not a secret store. Pi's own model-message and tool-call records are
|
||||
outside that redactor. Never put tokens, passwords, private keys, connection strings, or raw
|
||||
sensitive output in a goal or report; cite the command, artifact, and pass/fail result instead.
|
||||
|
||||
The loop stops instead of running forever when it is paused, blocked, cancelled, verified, reaches
|
||||
its turn limit, or repeats the same no-progress report too many times. Defaults are 40 turns and 6
|
||||
repeated no-progress reports. Operators may lower or raise them within enforced bounds before
|
||||
launching Pi:
|
||||
|
||||
```bash
|
||||
MOSAIC_GOAL_MAX_TURNS=60 MOSAIC_GOAL_MAX_NO_PROGRESS=8 mosaic pi
|
||||
```
|
||||
|
||||
Goal state is branch-specific Pi session data. It survives compaction and session resume, but Pi's
|
||||
process still must be relaunched or supervised after a process/host failure. This initial verifier
|
||||
checks structured evidence twice; it cannot mathematically prove every arbitrary natural-language
|
||||
goal. Use explicit acceptance criteria and inspect `/goal status` for consequential work.
|
||||
|
||||
---
|
||||
|
||||
### Claude Code Skill Registration
|
||||
|
||||
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.
|
||||
@@ -1,72 +0,0 @@
|
||||
# #1099 pipefail + early-exit sweep
|
||||
|
||||
Baseline: `df4c591ab42aa1ae62c12935fdc0e772684864a0`
|
||||
|
||||
This is a site inventory, not a risk count. `FIXED` means the early-exiting consumer no longer has a piped upstream process whose SIGPIPE can become the result under `pipefail`. `NOT-LOAD-BEARING` means the pipeline status is explicitly discarded. `UNREACHABLE-AND-WHY` describes designed input, not a payload-size safety claim.
|
||||
|
||||
## Tranche 1 — runtime and general scripts
|
||||
|
||||
| Baseline site | Verdict | Construction / reason |
|
||||
| --- | --- | --- |
|
||||
| `tools/matrix-presence-harness/run.sh:38` | FIXED | nullglob array selects the first path; no pipeline |
|
||||
| `tools/e2e-install-test.sh:139` | FIXED | capture help completely, then grep via redirection |
|
||||
| `tools/install.sh:312` | FIXED | NUL `mapfile` reads all roots; count != 1 reaches the named malformed-archive diagnostic |
|
||||
| `scripts/analysis/reflect-board-history.sh:76` | FIXED | capture Git history completely, then grep via redirection |
|
||||
| `scripts/analysis/reflect-git-history.sh:67` | FIXED | grep reads from a here-string |
|
||||
| `scripts/analysis/reflect-git-history.sh:69` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/authentik/user-create.sh:72` | FIXED | jq `first(...)` reads the response directly |
|
||||
| `packages/mosaic/framework/tools/git/mutate-push-guard.sh:87` | FIXED | grep `-m1` reads the file directly; downstream `cut` consumes its complete scalar output |
|
||||
| `packages/mosaic/framework/tools/orchestrator/session-resume.sh:94` | FIXED | `mapfile` plus bounded indexed loop replaces `head` pipeline |
|
||||
| `packages/mosaic/framework/tools/prdy/prdy-status.sh:69` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:172` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:173` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:174` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:175` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:176` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:177` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:178` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/typecheck-hook.sh:16` | FIXED | Bash regex extracts the first field without a pipeline |
|
||||
| `packages/mosaic/framework/tools/qa/typecheck-hook.sh:56` | FIXED | grep and bounded sed each read from a here-string |
|
||||
| `packages/mosaic/framework/tools/tmux/send-message.sh:113` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/tmux/send-message.sh:124` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/wake/detector.sh:126` | FIXED | one awk reads the manifest directly and exits after the first exact key |
|
||||
| `packages/mosaic/framework/tools/wake/detector.sh:270` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/wake/detector.sh:278` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/wake/digest.sh:647` | FIXED | capture complete locator output, then select first line by parameter expansion |
|
||||
| `packages/mosaic/framework/tools/wake/reconcile.sh:149` | FIXED | one awk reads the manifest directly and exits after the first exact key |
|
||||
|
||||
## Explicit withdrawn / non-load-bearing sites
|
||||
|
||||
| Baseline site | Verdict | Reason |
|
||||
| --- | --- | --- |
|
||||
| `tools/install.sh:182` | NOT-LOAD-BEARING | `|| true` explicitly discards lookup status |
|
||||
| `tools/install.sh:356` | UNREACHABLE-AND-WHY | `pnpm pack` writes one matching CLI tarball into a fresh directory immediately before lookup; citation withdrawn in #1099 |
|
||||
| `tools/install.sh:357` | UNREACHABLE-AND-WHY | same fresh-directory invariant for gateway tarball; citation withdrawn in #1099 |
|
||||
| `tools/install.sh:627` | NOT-LOAD-BEARING | `|| true` explicitly discards lookup status |
|
||||
| `scripts/agent/session-start.sh:70` | NOT-LOAD-BEARING | optional scratchpad lookup has `|| true` |
|
||||
| `packages/mosaic/framework/templates/repo/scripts/agent/session-start.sh:58` | NOT-LOAD-BEARING | optional scratchpad lookup has `|| true` |
|
||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:25` | UNREACHABLE-AND-WHY | withdrawn in #1099 after designed-input reachability measurement; preserved without re-litigation |
|
||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:27` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:30` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:32` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:34` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||
|
||||
## Tranche 2 — non-wake test harnesses
|
||||
|
||||
All 22 baseline sites below are `FIXED`; the checked-in tranche fixture is passed through the same scanner and asserts all 22 occurrences and 21 normalized identities (the same response-split line occurs twice).
|
||||
|
||||
| Baseline site(s) | Verdict | Construction |
|
||||
| --- | --- | --- |
|
||||
| `systemd/user/test-fleet-units.sh:148` | FIXED | capture tmux output, then grep via redirection |
|
||||
| `git/test-issue-comment-readback.sh:283,302` | FIXED | parameter expansion splits status/body without `head` |
|
||||
| `git/test-pr-review-gitea-comment.sh:228` | FIXED | parameter expansion splits status/body |
|
||||
| `git/test-lane-brief-pr-linkage.sh:72` | FIXED | grep reads from a here-string |
|
||||
| `git/test-pr-review-repo-host-override.sh:225-226` | FIXED | grep reads from a here-string |
|
||||
| `orchestrator/smoke-test.sh:67,72` | FIXED | parameter expansion selects first line |
|
||||
| `orchestrator/test-board-roll.sh:99-100` | FIXED | grep reads from a here-string |
|
||||
| `quality/scripts/test-upgrade-durable-snapshot.sh:180` | FIXED | complete sorted output is read with `mapfile`, then indexed |
|
||||
| `quality/scripts/test-upgrade-rollback.sh:339,356` | FIXED | direct `grep -m1` file reads; cleanup captures before testing |
|
||||
| `tmux/test-send-message-socket.sh:37,38,44-46,68,72` | FIXED | capture commands complete before redirected grep assertions |
|
||||
| `tmux/test-send-message-verdict.sh:34` | FIXED | grep reads from a here-string |
|
||||
|
||||
Remaining wake-validation sites are intentionally deferred to the final review-sized tranche and are not yet assigned a safety verdict here.
|
||||
@@ -1,71 +0,0 @@
|
||||
# #1019 — Zero-timeout queue-guard harness race
|
||||
|
||||
- **Issue:** #1019 (parent status remains `believed-fixed, pending jarvis validation`; do not close)
|
||||
- **Branch:** `fix/1019-ci-queue-timeout-harness`
|
||||
- **Owner:** `be-coder-08`
|
||||
- **Base:** `origin/main` at `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`
|
||||
- **Charter:** `/home/hermes/agent-work/tl-mosaic/CHARTER-1019-HARNESS-FIX.md`
|
||||
|
||||
## Objective
|
||||
|
||||
Make `test-ci-queue-wait-tristate.sh` deterministic without changing any asserted outcome. Remove the indiscriminate zero-timeout race, require every status-classification case to prove the provider was observed, and prove the harness-controlled virtual clock is active.
|
||||
|
||||
## Scope
|
||||
|
||||
- In scope: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` only, plus this evidence scratchpad.
|
||||
- Out of scope: guard parsers, D2/D3 behavior, installer/reseed staleness, PR #1060, and issue closure.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. RED deterministically reproduces deadline pre-emption before the provider call.
|
||||
2. Every case that intends status classification positively proves provider observation.
|
||||
3. Pending observes `pending` before deterministic virtual-time expiration.
|
||||
4. The virtual clock has a positive interception control; a broken-clock mutant makes the suite red.
|
||||
5. The exact CI-base image passes the final harness repeatedly with zero failures.
|
||||
6. Baseline gates, independent code/security review, exact-head CI, and coordinator-authorized squash merge pass.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add deterministic RED instrumentation for the known merge/provider-unreachable pre-emption.
|
||||
2. Replace global `-t 0` with a nonzero timeout interpreted under an event-driven virtual clock; stub sleep without wall waiting.
|
||||
3. Add provider-observation and virtual-clock positive controls without changing outcome assertions.
|
||||
4. Run focused shell checks, repeat in exact CI-base image, baseline gates, and independent reviews.
|
||||
5. Commit with both identity layers, queue-guard plus direct Woodpecker terminal-state verification, push, self-post PR, verify poster/head/CI, obtain coordinator merge authorization, then squash merge without closing #1019.
|
||||
|
||||
## Budget
|
||||
|
||||
- No explicit token cap supplied. Keep scope to one harness file and one scratchpad; stop/report at the charter's 60% context gate.
|
||||
|
||||
## Evidence
|
||||
|
||||
- RED, deterministic pre-provider expiry: `evidence/1019-harness-fix/red-pre-provider-expiry.log` — rc 1; merge/provider-unreachable got rc 124 instead of 75, omitted CANNOT_ASSERT, did not observe the status provider, and wrote no additional audit record (four named failures).
|
||||
- GREEN host focused harness: `evidence/1019-harness-fix/green-host.log` — rc 0, all outcome classes passed.
|
||||
- Load-bearing clock negative control: a temporary same-directory mutant replaced the virtual `date` body with `/bin/date`; `evidence/1019-harness-fix/red-clock-not-intercepted.log` — rc 1 with named `virtual clock interception did not run` failures. The mutant file was removed after the run.
|
||||
- Exact CI-base repeat: `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`, repository mounted read-only, harness work under container `/tmp`; `evidence/1019-harness-fix/ci-image-repeat/summary.log` — **100 pass / 0 fail / 100 total**.
|
||||
- Synchronization design: provider-status observation creates the event marker; virtual time is 1000 before the event and 1002 afterward. Pending alone reaches the stubbed no-op sleep and a post-observation deadline check. `-t 1` is uniquely load-bearing because removing it restores the 900-second default deadline at virtual time 1900, which 1002 does not cross. The numeric timeout is subject semantics under virtual time, not a wall-clock synchronization duration.
|
||||
|
||||
## Review remediation — semantic timeout vs. liveness bound
|
||||
|
||||
Security review found that virtual time remained at 1000 forever before provider observation and stubbed sleep never waited. A regression looping before the status endpoint—or blocking in the first provider call—therefore could prevent `run_guard` from returning, so the post-return provider assertion could never fire.
|
||||
|
||||
**General rule:** A timeout usually serves two purposes: semantics and liveness. Removing wall time from semantic synchronization can silently remove the only independent hang bound. Preserve deterministic virtual time for subject semantics, but provide a separately implemented real-clock liveness watchdog and prove that watchdog fires.
|
||||
|
||||
Remediation:
|
||||
|
||||
- Every guard subject invocation is launched by absolute `/usr/bin/python3` in a new session. Python's internal monotonic `wait(timeout=...)` provides real-clock liveness independently of PATH; expiry kills the entire isolated process group, so neither PATH-front shims nor a blocked provider descendant can retain the capture pipe.
|
||||
- Watchdog expiry returns distinct harness rc 90 plus `FAIL HANG watchdog`, separate from subject timeout rc 124.
|
||||
- A first attempt using absolute `/usr/bin/timeout -s KILL` passed on GNU coreutils but failed in the exact Alpine CI-base image: BusyBox killed the immediate wrapper while the guard/provider descendants survived and retained the command-substitution pipe. The process-group kill is therefore required behavior, not portability polish.
|
||||
- A committed positive control hangs the branch-provider stub before the status endpoint. It must terminate through the watchdog, emit the hang-specific diagnostic, return rc 90, and prove the status provider was never reached.
|
||||
- RED before remediation: a temporary ordinary-success mutant hung before provider observation; only an external control could kill the suite (rc 137), and there was no internal hang-specific diagnostic (`red-watchdog-absent.log`).
|
||||
- The watchdog mutant/control is load-bearing: removing the internal watchdog leaves the control unable to produce its required rc 90 and diagnostic.
|
||||
|
||||
Post-review evidence:
|
||||
|
||||
- Host focused harness with process-group watchdog: rc 0 (`green-watchdog-process-group-host.log`).
|
||||
- Exact Alpine CI-base focused harness with process-group watchdog: rc 0 (`green-watchdog-ci-image.log`).
|
||||
- Hanging ordinary-success mutant: suite rc 1; success returned rc 90, emitted `FAIL HANG watchdog`, and loudly reported that provider/clock observation did not occur (`red-watchdog-fires.log`).
|
||||
- Removed-`-t 1` mutant: suite rc 1; pending was terminated by the watchdog instead of producing `ASSERTED_NOT_READY`, proving the explicit timeout is load-bearing (`red-timeout-argument-removed.log`).
|
||||
|
||||
## 60% context hold
|
||||
|
||||
Stopped before baseline/review/commit as required by the charter. Remaining: inspect final diff, shell/static/baseline gates, independent code/security review, remediation if any, identity-bound commit/trailer verification, mandatory queue guard plus direct terminal Woodpecker `mosaic` enumeration, push, self-posted PR/provider poster read-back, exact-head terminal-green CI, coordinator merge authorization, squash merge, main CI verification, and leave #1019 unclosed as `believed-fixed, pending jarvis validation`.
|
||||
@@ -1,229 +0,0 @@
|
||||
# #1043 — Fleet pane git-identity propagation
|
||||
|
||||
## Objective
|
||||
|
||||
Ensure a fleet seat's launched runtime process receives its roster-derived `MOSAIC_GIT_IDENTITY`, and lock the complete generated-environment propagation boundary with an enumerated set comparison.
|
||||
|
||||
## Tracking
|
||||
|
||||
- External issue: `mosaicstack/stack#1043`
|
||||
- Branch: `fix/1043-pane-git-identity`
|
||||
- Coordinator: `tl-mosaic`
|
||||
- `docs/TASKS.md`: read-only by project worker contract; not modified.
|
||||
|
||||
## Constraints
|
||||
|
||||
- RED-first bug reproducer is mandatory.
|
||||
- R7 delete-the-subject mutation must turn the behavioral test red.
|
||||
- Assert launched-process environment, not source text.
|
||||
- One push only; do not poll CI after push.
|
||||
- Run the CI queue guard immediately before push and report its `state=` line as state, not evidence.
|
||||
- Do not modify a live host launcher or obtain/copy another credential.
|
||||
- Self-post the PR, verify provider attribution, then stop.
|
||||
- Final status wording: `believed-fixed, pending jarvis validation`.
|
||||
|
||||
## Scope inventory
|
||||
|
||||
Re-derived against `origin/main` at `85d2108e`:
|
||||
|
||||
- Launch consumer: `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
|
||||
- Behavioral launch test: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||
- Generated-environment contract/parser: `packages/mosaic/src/fleet/generated-env-boundary.ts`
|
||||
- Roster projection producers:
|
||||
- `packages/mosaic/src/commands/fleet.ts`
|
||||
- `packages/mosaic/src/fleet/fleet-reconciler.ts`
|
||||
- `packages/mosaic/src/fleet/fleet-agent-crud.ts`
|
||||
- `packages/mosaic/src/fleet/v1-v2-migration.ts`
|
||||
- Contract and producer tests discovered by repository search.
|
||||
- Generated-environment operator/developer docs and their executable documentation contract test.
|
||||
|
||||
Discrepancy sent to `tl-mosaic`: current main no longer contains the charter's `PANE_SHELL_SNIPPET`; #772 replaced it with an `/usr/bin/env -i` argv launch boundary, and current generated projections do not declare git identity. Code-read inventory is **NOT MEASURED** behavior.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add the process-environment set-comparison regression first and record RED.
|
||||
2. Add roster-derived `MOSAIC_GIT_IDENTITY=<agent name>` to the complete generated projection contract.
|
||||
3. Validate identity syntax and equality with `MOSAIC_AGENT_NAME`; pass it through the clean pane environment.
|
||||
4. Update affected projection tests and generated-environment docs.
|
||||
5. Run focused and baseline gates.
|
||||
6. Perform R7 by deleting the pane propagation entry, prove RED, restore, and prove GREEN.
|
||||
7. Run independent review, remediate, commit, queue guard, one push, self-post PR, verify provider attribution, and stop without CI polling.
|
||||
|
||||
## Budget
|
||||
|
||||
No explicit token cap was provided. Working cap: one narrow logical unit, no dependency installation unless existing tooling requires it, no unrelated refactor.
|
||||
|
||||
## Evidence log
|
||||
|
||||
### TDD and mutation evidence
|
||||
|
||||
- RED-first, repository launcher: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` exited 64 on pre-fix source with `code=unknown-key key=MOSAIC_GIT_IDENTITY`. The generated seat could not launch with the required declared identity.
|
||||
- GREEN: the same repository launcher test emitted `ok - start-agent-session generated environment boundary`.
|
||||
- R7 delete-the-subject: removed only `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"` from the repository launch array; the same test exited 1 with `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
|
||||
- R7 restoration: restored that launch entry; the same test returned green.
|
||||
- Launcher under test is explicitly `packages/mosaic/framework/tools/fleet/start-agent-session.sh` through the test's `$START`, **not** the stale installed host copy.
|
||||
|
||||
### Situational and focused tests
|
||||
|
||||
- Repository launcher boundary: green, including set comparison of all nine generated projection entries and fail-before-tmux cases for missing, unsafe, mismatched, and local-shadow Git identity.
|
||||
- Fleet systemd launcher integration: `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — green.
|
||||
- Focused Mosaic Vitest set: 6 files, 311 tests — green.
|
||||
- `bash -n` on changed shell files — green.
|
||||
- `git diff --check` — green.
|
||||
|
||||
### Baseline gates
|
||||
|
||||
- `pnpm typecheck` — 45/45 tasks green.
|
||||
- `pnpm lint` — 25/25 tasks green.
|
||||
- `pnpm format:check` — green.
|
||||
- `pnpm test:checkout` — green.
|
||||
- Repository-wide Vitest under a hermetic current-version npm prefix: Mosaic 81/81 files and 1510/1510 tests green; other workspace test tasks shown green before the framework-shell phase.
|
||||
- Canonical `pnpm test` is not fully green on this host for unrelated environment-sensitive gates:
|
||||
1. the first two runs exposed the globally installed Mosaic 0.0.48 update banner in three CLI smoke tests expecting empty stderr;
|
||||
2. after isolating that global-version input, the framework wake assertion aborted at the known `#973` Bash `BASH_LINENO` convention check (exit 97; observed `[3 5]`, expected `[3 4]`).
|
||||
No tests were weakened or bypassed; focused changed-surface tests are green. CI remains the canonical clean-environment result and is intentionally not polled after push per charter.
|
||||
|
||||
### Independent review
|
||||
|
||||
- Codex code review first pass: request changes for missing shell rejection-path coverage.
|
||||
- Remediation: added table-driven missing/unsafe/mismatch/local-shadow launcher cases, each asserting no tmux call.
|
||||
- Codex code re-review: **approve**, no findings, confidence 0.88.
|
||||
- Codex security review: risk `none`, no findings, confidence 0.97.
|
||||
|
||||
### Acceptance criteria mapping
|
||||
|
||||
| Acceptance criterion | Evidence |
|
||||
| --- | --- |
|
||||
| AC-FGI-01: launched process receives every generated key/value | Repository launcher process-environment `comm -23` set comparison; GREEN and R7 RED evidence above |
|
||||
| AC-FGI-02: missing, unsafe, or split identity fails before tmux | Table-driven shell cases plus TypeScript generated-boundary tests |
|
||||
| AC-FGI-03: focused/baseline/review evidence recorded | Commands and review outcomes above; host-sensitive full-suite limitations stated explicitly |
|
||||
|
||||
### Documentation checklist
|
||||
|
||||
- PRD updated with #1043 requirements and acceptance criteria.
|
||||
- Fleet launch runbook, generated-env concept, and generated-env reference updated.
|
||||
- No API/OpenAPI, sitemap, user publishing target, deployment, or external docs publication change applies.
|
||||
- `docs/TASKS.md` remains unmodified per its single-writer project contract.
|
||||
|
||||
## Round 2 — PR #1073 review 97 remediation
|
||||
|
||||
### Review blocker
|
||||
|
||||
The launched-process suite was signed-excluded from CI enumeration. Manual GREEN/R7 evidence therefore did not prove a PR workflow could detect regression.
|
||||
|
||||
### RED-first and canonical wiring
|
||||
|
||||
1. Removed the suite's signed exclusion before adding a CI execution path.
|
||||
2. `check-test-enumeration.sh` went RED with exact `UNENUMERATED` output for `test-start-agent-session.sh`: population 49, enumerated 30, excluded 18.
|
||||
3. Added both `framework/tools/fleet/test-start-agent-session.sh` and `framework/systemd/user/test-fleet-units.sh` to `@mosaicstack/mosaic`'s canonical `test:framework-shell` chain.
|
||||
4. The guard returned GREEN: population 49, enumerated 32, excluded 18, surfaces 45. The systemd suite is outside the guard's tools-only population but now has the same explicit canonical execution disposition.
|
||||
|
||||
### Workflow-level R7
|
||||
|
||||
- Deleted only the pane launch entry `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"`.
|
||||
- Ran the exact `.woodpecker/ci.yml` test-step command, `pnpm test`, with only a temporary PATH-scoped npm shim reporting the checkout's current 0.0.49 version so the unrelated global 0.0.48 banner could not preempt the shell chain.
|
||||
- Result: exit 1 at `@mosaicstack/mosaic#test`, with the enumeration guard GREEN followed by `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
|
||||
- Restored the launch entry. The canonical `test:framework-shell` chain then reached both newly wired suites and printed both GREEN markers before the known unrelated #973 host-only `BASH_LINENO` abort.
|
||||
- An actual provider PR workflow on the intentionally broken mutant is **NOT MEASURED**: the one-push constraint forbids pushing a red mutant and then a repaired head. Local execution proves the exact PR workflow command and dependency chain go RED on the subject deletion; CI on the repaired pushed head remains canonical.
|
||||
|
||||
### Workflow population
|
||||
|
||||
- **DEFINED:** 3 workflows (`ci.yml`, `ci-image.yml`, `publish.yml`).
|
||||
- **ELIGIBLE for `pull_request`:** 1/3 (`ci.yml`), based on top-level `when:` clauses.
|
||||
- **REPORTED:** Round-1 exact-head provider read reported 1/1 eligible context (`ci/woodpecker/pr/ci`). Post-remediation-head reported count is **NOT MEASURED** by this seat because CI polling is prohibited; workflow definitions and eligibility did not change.
|
||||
|
||||
### Independent remediation review
|
||||
|
||||
- First Round-2 review identified a CI-image blocker: the newly wired launcher suite used Perl, which the Alpine CI base does not install.
|
||||
- Replaced the suite's three Perl-only fixture mutations with POSIX/BusyBox-compatible `sed -i` substitutions; production behavior and assertions are unchanged.
|
||||
- Codex re-review: **APPROVE**, confidence 0.93, no findings.
|
||||
|
||||
### Vitest denominator reconciliation
|
||||
|
||||
The PR's `311/311` is correct for its explicitly named six-file command at both the original and remediation worktrees:
|
||||
|
||||
- generated environment boundary: 24
|
||||
- fleet documentation: 23
|
||||
- Tess service profile: 6
|
||||
- fleet regen command: 27
|
||||
- fleet agent CRUD command: 22
|
||||
- fleet command: 209
|
||||
- total: **311**
|
||||
|
||||
Review 97 reported 312/312 without naming its six files. That is a different or miscounted population and cannot replace the command-scoped 311 denominator; the PR follow-up will name the exact files and arithmetic.
|
||||
|
||||
## Round 3 — Alpine stale-marker portability
|
||||
|
||||
### Objective and plan
|
||||
|
||||
- Replace the GNU-only relative-date fixture with a deterministic POSIX/BusyBox timestamp while preserving the required stale-marker assertion.
|
||||
- Re-run the launcher suite in the canonical `ci-base:latest` Alpine image, then run applicable repository gates and independent review.
|
||||
- Update the PR body to name the repeated GNU-host/Alpine-CI portability pattern, run the mandatory queue guard, push once, verify provider attribution, and stop without CI polling.
|
||||
- Working budget: 8K tokens; scope is one fixture line plus delivery evidence. No production behavior changes.
|
||||
|
||||
### RED-first evidence
|
||||
|
||||
Before the fix, the canonical CI image command
|
||||
`docker run --rm -v "$PWD:/work" -w /work git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||
exited 1 at the stale-marker setup with exact BusyBox output
|
||||
`touch: invalid date '10 seconds ago'`. The prior fresh-marker assertions had already executed, matching pipeline 2233's failure location.
|
||||
|
||||
### Root cause and fix
|
||||
|
||||
The test used GNU `touch -d` relative-date parsing although the PR workflow runs on Alpine/BusyBox. The fixture now uses POSIX `touch -t 200001010000.00`, a fixed timestamp that is unconditionally stale; the stale assertion remains mandatory and was not made tolerant of missing timestamp metadata.
|
||||
|
||||
### Structural pattern
|
||||
|
||||
This is the third GNU-host/Alpine-CI portability defect in the lane: GNU `grep` multi-match counting, Perl-only fixture mutation, and GNU `touch -d` date parsing. The repeated cause is shell suites authored on a GNU host but executed in an Alpine CI image; durable prevention belongs in CI-image execution or portability lint, not assertion weakening.
|
||||
|
||||
### GREEN and quality evidence
|
||||
|
||||
- Focused launcher suite in `ci-base:latest`: exit 0, `ok - start-agent-session generated environment boundary`.
|
||||
- Canonical test step in `ci-base:latest` with the pipeline's `pgvector/pgvector:pg17` service, readiness check, migration, and `pnpm test`: exit 0; 46/46 Turbo tasks; Mosaic 81/81 files and 1510/1510 tests; Gateway 57 passed/5 skipped files and 629 passed/11 skipped tests; enumeration 49 population / 32 enumerated / 18 signed exclusions / 45 named surfaces.
|
||||
- The first image-only `pnpm test` attempt lacked the pipeline PostgreSQL service and failed only on connection refusal after the launcher suite was GREEN. The rerun supplied the canonical service precondition and passed.
|
||||
- Canonical-image baseline: typecheck 45/45 tasks, lint 25/25 tasks, format check GREEN; `git diff --check` GREEN.
|
||||
- Independent Codex code review: APPROVE, confidence 0.96, 2/2 Round-3 files, no findings.
|
||||
- Independent Codex security review: risk none, confidence 0.99, 2/2 Round-3 files, no findings.
|
||||
|
||||
### Re-derived inventory and denominators
|
||||
|
||||
- Round-3 git delta: **2/2 files** — launcher suite and task scratchpad; 25 insertions / 1 deletion before evidence finalization.
|
||||
- Full PR path inventory against `origin/main` at `85d2108e`: **19/19 changed paths**; Round 3 adds no new PR path.
|
||||
- Workflow definition population: **1/3 pull-request-eligible** (`ci.yml` of `ci.yml`, `ci-image.yml`, `publish.yml`).
|
||||
- Do not re-litigate the settled 311/312 populations; both are valid for their separately named Tess6 and CRUD-core7 sets.
|
||||
|
||||
## Round 4 — bound stale-marker observation
|
||||
|
||||
### Objective and plan
|
||||
|
||||
- Make the heartbeat assertion discriminate an initially stale native marker from a fresh marker without changing the production staleness threshold or shortening the polling window.
|
||||
- Freeze only the sidecar's numeric observation clock during the stale-fixture arm so elapsed assertion time cannot turn a fresh mutant stale.
|
||||
- Prove two independent mutants RED: disable production stale-marker detection while retaining the stale fixture; replace the stale fixture with a fresh marker. Restore the tree and prove GREEN in the canonical Alpine image.
|
||||
- Re-derive the changed-path inventory, run applicable quality and independent review gates, commit with environment-only author/committer identity, queue-guard, push once, verify provider attribution using curl stdin config, and stop without CI polling.
|
||||
- Working budget: 8K tokens. Scope is the launcher test and its scratchpad evidence; production launcher behavior remains unchanged.
|
||||
|
||||
### Root cause and bounded observation
|
||||
|
||||
The 30 × 0.1-second assertion window overlaps the production `now - marker > interval * 2 + 1` threshold at interval 1. Depending on second boundaries and load, a fresh marker can age past the threshold before the assertion ends. A focused pre-fix fresh-mutant attempt returned RED while Review 101's full-suite run returned GREEN; the differing result is itself timing dependence, not a discriminating assertion.
|
||||
|
||||
The test now supplies a fixed numeric epoch only to the stale-fixture sidecar. Its real marker mtime is still read from the filesystem, but assertion runtime cannot advance `now`. Date formatting still delegates to the image's real `/bin/date`. Neither the production threshold nor the 30 × 0.1-second polling window changed.
|
||||
|
||||
### Two-mutant RED / restored GREEN
|
||||
|
||||
All three runs used `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`:
|
||||
|
||||
1. **Stale-detection mutant RED:** replaced only the production stale-age predicate with `false` while retaining the fixed stale marker; suite exit 1 with `FAIL: heartbeat sidecar did not resume after native marker became stale or absent`.
|
||||
2. **Fresh-marker mutant RED:** replaced only `touch -t 200001010000.00` with fresh `touch`; suite exit 1 with the same failed stale-resumption assertion. The fixed observation epoch kept the mutant fresh throughout all 30 polls.
|
||||
3. **Restored tree GREEN:** suite exit 0 with `ok - start-agent-session generated environment boundary`.
|
||||
|
||||
### Re-derived inventory
|
||||
|
||||
- Round-4 delta: **2/2 files** — launcher test plus task scratchpad; production launcher delta is empty.
|
||||
- Full PR inventory against `origin/main`: **19/19 paths**; Round 4 adds no path.
|
||||
- Production stale threshold remains `now - marker > iv * 2 + 1`; assertion polling remains 30 × 0.1 seconds.
|
||||
- Review 101's confirmed enumeration/workflow/CI and attribution evidence is accepted without re-polling or re-derivation.
|
||||
|
||||
## Residual risk
|
||||
|
||||
- Landing on `main` does not update the currently installed host launcher. Host framework installation/reseed and Jarvis live-seat validation are separate downstream events.
|
||||
- Canonical CI result is pending and will not be polled by this seat.
|
||||
@@ -1,97 +0,0 @@
|
||||
# #1098 — Framework shell portability / red main
|
||||
|
||||
## Objective
|
||||
|
||||
Restore terminal-green `main` by making the `test-start-agent-session.sh` clean-environment assertion semantic and portable without removing either newly enumerated framework-shell suite.
|
||||
|
||||
## Scope
|
||||
|
||||
- Tracking issue: `mosaicstack/stack#1098`
|
||||
- Branch: `fix/framework-shell-portability`
|
||||
- Base: `origin/main` at `4fa2768962702d53e16e8b67ee6ad52ebcb0910e`
|
||||
- Primary file: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||
- Requirements source: `docs/PRD.md` § Framework shell assertion portability (#1098)
|
||||
- Out of scope: deployed files under `~/.config/mosaic`, pnpm-store cleanup, checkout deletion, and changes to the launcher’s `/usr/bin/env -i` behavior.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. The test inspects the captured NUL-delimited tmux argv semantically and accepts an adjacent `/usr/bin/env`, `-i` pair regardless of trailing payload size or pipe scheduling.
|
||||
2. Missing `/usr/bin/env`, missing `-i`, and non-adjacent `-i` remain failures.
|
||||
3. Failure output includes the observed argv records with stable indexes and shell escaping; it exposes no credentials because this fixture supplies only generated non-secret launch data.
|
||||
4. The focused suite passes on the dev host and in the repository CI image; the blocking PR/main pipeline returns terminal green.
|
||||
5. Independent review passes; PR is squash-merged and #1098 is closed only after merged-main CI is terminal green.
|
||||
|
||||
## Budget
|
||||
|
||||
- ASSUMPTION: 30K-token working budget; rationale: one shell-test defect plus full PR/CI lifecycle.
|
||||
- Auto-reduction: focused shell and package gates first; rely on canonical Woodpecker for the full monorepo suite rather than duplicating a dependency install under constrained `/home`.
|
||||
- Disk baseline before clone/build: `/home` 7.1G free (99% used), `/tmp` 2.4G free (92% used).
|
||||
|
||||
## Investigation
|
||||
|
||||
### First-hand CI evidence
|
||||
|
||||
- Public log: `GET https://ci.mosaicstack.dev/api/repos/47/logs/2269/53041`
|
||||
- Decoded 1,436 entries (11 null `data` entries treated as empty log rows), 190,756 bytes.
|
||||
- Failure: `FAIL: pane command did not clear its environment` immediately after the expected pane-PID warning.
|
||||
- BusyBox primitives, complete assertion pipeline, real CI image, stale/current image digests, Turbo cache masking, gateway failure, and heartbeat-sidecar concurrent writing were independently excluded.
|
||||
|
||||
### Root cause
|
||||
|
||||
The assertion ends in:
|
||||
|
||||
```bash
|
||||
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i'
|
||||
```
|
||||
|
||||
The script has `set -o pipefail`. `grep -q` exits as soon as it finds the valid `-i` record. Upstream `tail`/`printf` can then receive SIGPIPE, making the aggregate pipeline nonzero even though grep returned 0 and the semantic property is true. This depends on payload size, pipe capacity, and scheduling, explaining a local/image pass with a CI failure.
|
||||
|
||||
Discriminating stress control with `/usr/bin/env` followed immediately by `-i`:
|
||||
|
||||
- 8,192-byte trailing payload: `printf=0 tail=0 grep=0`, aggregate 0.
|
||||
- 16,384-byte trailing payload: `printf=0 tail=141 grep=0`, aggregate 141.
|
||||
- 32,768+ bytes: `printf=141 tail=141 grep=0`, aggregate 141.
|
||||
- A full-reading `grep -xF` control remained 0 for every payload.
|
||||
|
||||
This is a third branch omitted by the earlier present-vs-corrupted split: the pair can be present and intact while `pipefail` reports an upstream SIGPIPE.
|
||||
|
||||
## TDD plan
|
||||
|
||||
1. RED: preserve the one-off stress reproducer above and add an automated large-argv semantic regression that fails under the current pipeline implementation.
|
||||
2. GREEN: parse the authoritative NUL-delimited capture into a Bash array and search for an adjacent `/usr/bin/env`, `-i` pair without a short-circuit pipeline.
|
||||
3. Add negative controls for missing, detached, and reversed tokens.
|
||||
4. On failure, print indexed `%q` argv records before returning nonzero.
|
||||
5. Run focused suite, mutation controls, shell syntax/format checks, then repository baseline gates feasible without dependency installation.
|
||||
6. Independent review, queue guard, push, PR, CI, coordinator merge authorization, squash merge, merged-main CI, issue close.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Checkout created and based on `origin/main` `4fa27689`.
|
||||
- [x] CI log decoded directly.
|
||||
- [x] Root-cause stress control reproduced semantic match + aggregate pipeline failure.
|
||||
- [x] RED evidence: intact `/usr/bin/env`, `-i` fixture produced component statuses `0/141/0` and aggregate 141 under the former `grep -q` pipeline; full-reading semantic control stayed 0.
|
||||
- [x] GREEN implementation: direct NUL-argv adjacency parser, indexed diagnostics, and full-reading scalar predicates replace all load-bearing early-exit pipelines in this test.
|
||||
- [x] Baseline/situational tests:
|
||||
- focused launcher suite: PASS on GNU host and cached Alpine CI image;
|
||||
- paired `test-fleet-units.sh`: PASS;
|
||||
- enumeration guard: PASS (`population=53`, `enumerated=36`, `excluded=18`), 14/14 mutation needles;
|
||||
- `bash -n`, ShellCheck, `git diff --check`: PASS;
|
||||
- static denominator after change: zero load-bearing `grep -q`/`head`/`-m1` pipeline candidates in `test-start-agent-session.sh`;
|
||||
- delete-the-subject mutation removing production `-i`: RED with 78 indexed argv records, byte count, and explicit boundary failure.
|
||||
- [x] Independent review:
|
||||
- first Codex review: request changes — negative fixtures did not each assert diagnostics;
|
||||
- remediation: centralized predicate + diagnostic wrapper and exercised all four negative fixtures;
|
||||
- second Codex review: APPROVE, 0 blockers/should-fix/suggestions;
|
||||
- Codex security review: risk none, 0 findings.
|
||||
- [ ] PR CI, formal fleet review, merge, merged-main CI, issue closure.
|
||||
|
||||
## Documentation disposition
|
||||
|
||||
- Updated canonical `docs/PRD.md` with FSP requirements and acceptance criteria.
|
||||
- This is an internal test/reliability change with no API, user workflow, deployment, navigation, or publishing-surface change; no user/admin/API/sitemap update is required.
|
||||
- `docs/TASKS.md` remains unchanged because the project contract makes it orchestrator-only.
|
||||
|
||||
## Risks
|
||||
|
||||
- The CI failure did not print its captured argv, so the exact CI payload is unavailable. The stress control proves the assertion is non-portable and can emit the exact false verdict; branch CI is the canonical confirmation that replacing it resolves pipeline 2269’s failure class.
|
||||
- Printing fixture argv is safe only while this test’s projection remains non-secret. The diagnostic must stay scoped to the test capture and shell-escaped.
|
||||
@@ -1,37 +0,0 @@
|
||||
# #1099 — pipefail + early-exit sweep
|
||||
|
||||
## Scope and decisions
|
||||
|
||||
- Baseline `df4c591ab42aa1ae62c12935fdc0e772684864a0`, after #1100 removed its 35 sites.
|
||||
- Split into review-sized non-closing tranches: runtime/general; tmux/git/quality tests; wake validation/tests.
|
||||
- Do not equate class membership with demonstrated risk. Do not use payload size or pipeline stage count as a safety proxy.
|
||||
- Preserve the issue's withdrawn findings for `qa-hook-stdin.sh` and the two fresh-directory `pnpm pack` lookups. Fix `install.sh:312` because malformed multi-root input must reach its named handler.
|
||||
|
||||
## Tranche 1 TDD
|
||||
|
||||
RED-first control: `node --test scripts/pipefail-early-exit.test.mjs` reported exactly 26 non-accepted runtime/general sites, including `install.sh:312`, and exited 1. A checked-in fixture generated from immutable baseline `df4c591a` records all 26 normalized sites; the control passes every fixture entry through the same scanner, asserts exact identity/count/uniqueness, and separately requires zero findings in the current tree. It also inventories accepted sites rather than silently excluding whole files.
|
||||
|
||||
Construction choices:
|
||||
|
||||
- here-string/file redirection for scalar grep assertions;
|
||||
- full capture then parameter expansion for first-line selection;
|
||||
- arrays/`mapfile` for complete populations;
|
||||
- direct jq/awk/grep selection where one tool can express the property;
|
||||
- no `|| true` added to a load-bearing assertion.
|
||||
|
||||
Site-by-site verdicts: `docs/reports/quality/1099-pipefail-sweep.md`.
|
||||
|
||||
## Tranche 2 TDD
|
||||
|
||||
Expanded the unconditional scanner over 11 non-wake test harnesses. RED named exactly 22 source lines; a second immutable-baseline fixture now asserts those 22 entries through the same scanner. Rewrites preserve command status by capturing producers before redirected assertions, use parameter expansion for line selection, and use complete `mapfile` populations where ordering matters. Current-tree finding count is zero for tranches 1 and 2.
|
||||
|
||||
## Verification so far
|
||||
|
||||
- `bash -n` on every changed shell script: pass.
|
||||
- structural Node control: pass.
|
||||
- `test-mutate-push-guard.sh`: 8/8 pass.
|
||||
- `test-send-message-verdict.sh`: 3/3 pass.
|
||||
- `test-send-message-socket.sh`: pass.
|
||||
- Independent review 143 found two semantic regressions: a help-probe `|| true` changed the failure truth table, and an unguarded Git capture changed non-Git data-dir behavior from rc 0 + JSON to silent rc 128. Both received RED-first regressions before correction; help status is now separate and required, and Git status remains condition-guarded.
|
||||
- Wake detector/reconcile/digest/preimage suites terminate at their existing fail-closed #973 `BASH_LINENO` environment probe (exit 97, observed `[3 5]`, expected `[3 4]`) before subject tests. No bypass or skip was used; canonical CI remains required.
|
||||
- ShellCheck reports only pre-existing source-following, unused-variable, and untouched `ls | head` findings; no new diagnostic was introduced.
|
||||
@@ -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.
|
||||
@@ -1,156 +0,0 @@
|
||||
# #1150 — Pi persistent goal extension
|
||||
|
||||
- **Task ID:** ISSUE-1150 (no `docs/TASKS.md` row; that file is orchestrator-only)
|
||||
- **Issue:** #1150 — `pi: add persistent /goal controller extension to Mosaic framework`
|
||||
- **Branch:** `feat/1150-pi-goal-extension`
|
||||
- **Mode:** Delivery
|
||||
- **Status:** in progress
|
||||
|
||||
## Objective
|
||||
|
||||
Build and locally validate a Mosaic-owned Pi `/goal` extension. Source must ship from
|
||||
`packages/mosaic/framework/runtime/pi/`, framework sync must deploy it under
|
||||
`~/.config/mosaic/runtime/pi/`, and no extension/configuration asset may be written into `~/.pi`.
|
||||
Pi's native session manager remains the owner of session entries.
|
||||
|
||||
## Scope and acceptance source
|
||||
|
||||
- Canonical requirements: `docs/PRD.md`, section **Pi Persistent Goal Loop (#1150)**.
|
||||
- User intent: continuous goal orientation and status checking after each Pi turn and compaction,
|
||||
tested locally before framework delivery.
|
||||
- Documentation target: canonical in-repo user/developer/runtime docs; no external publication.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `ASSUMPTION:` Initial semantic verification uses two consecutive structured, evidence-bearing
|
||||
reports from the working agent rather than a second model request after every turn. This keeps the
|
||||
loop testable and avoids doubling model cost while making the limitation explicit.
|
||||
- `ASSUMPTION:` Default autonomous bounds are 40 turns and 6 repeated no-progress reports, with only
|
||||
bounded numeric environment overrides.
|
||||
- `ASSUMPTION:` A local smoke copy to `~/.config/mosaic/runtime/pi/goal-extension.ts` is authorized by
|
||||
the user's explicit request. Full framework reseed into the live home is not required for the smoke
|
||||
test and would touch unrelated framework-owned files.
|
||||
|
||||
## Budget
|
||||
|
||||
- Working estimate: 30K implementation/review tokens.
|
||||
- Hard user cap: none stated.
|
||||
- Cost control: deterministic fake-Pi tests; no nested evaluator calls; only bounded arithmetic/load
|
||||
smoke workflows against the installed runtime.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Update PRD and create tracking/scratchpad artifacts.
|
||||
2. Read launcher, installer ownership, Pi extension, and documentation surfaces.
|
||||
3. TDD: add fake-Pi behavior tests for commands, state restoration, turn checks, compaction, limits,
|
||||
verification, and continuation deduplication.
|
||||
4. Implement `runtime/pi/goal-extension.ts` and deterministic launcher discovery.
|
||||
5. Add framework-sync/deployment acceptance coverage.
|
||||
6. Update user, developer, runtime, framework README, and sitemap documentation.
|
||||
7. Run focused tests, local Mosaic-path smoke test, then baseline repository gates.
|
||||
8. Run independent review, remediate, commit, push/PR/CI/merge/issue closure per delivery gates.
|
||||
|
||||
## TDD decision
|
||||
|
||||
Applied. The continuation state machine and lifecycle scheduling are control-path logic where a race
|
||||
or false terminal state can cause unbounded work or premature completion.
|
||||
|
||||
## Progress checkpoints
|
||||
|
||||
- [x] Issue #1150 created through Mosaic wrapper.
|
||||
- [x] Isolated worktree created from `origin/main`.
|
||||
- [x] PRD requirements and acceptance criteria added.
|
||||
- [x] Task scratchpad created.
|
||||
- [x] RED controller and security-regression tests written and observed failing before implementation.
|
||||
- [x] Goal controller, launcher discovery, framework deployment coverage, and bounded state machine
|
||||
implemented.
|
||||
- [x] User, admin, developer, runtime, adapter, README, and sitemap documentation updated.
|
||||
- [x] Final source copied additively to `~/.config/mosaic/runtime/pi/goal-extension.ts`; source and
|
||||
deployed SHA-256 are identical.
|
||||
- [x] Live Pi RPC smoke from the exact Mosaic path reached `achieved` with two verification passes and
|
||||
no extension errors.
|
||||
- [x] Baseline and situational checks completed, except the explicitly documented unavailable
|
||||
PostgreSQL-only root integration case.
|
||||
- [x] Independent code and OWASP/security reviews completed; all findings remediated and re-reviewed.
|
||||
- [ ] Commit, push, PR, terminal-green CI, squash merge, and issue closure complete.
|
||||
|
||||
## Tests and evidence
|
||||
|
||||
### Situational
|
||||
|
||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/runtime/pi-goal-extension.spec.ts`
|
||||
- final: 25 passed.
|
||||
- Covers commands, per-turn checks, context injection, two-pass verification, mixed-report
|
||||
rejection, bounded limits, compaction, branch restore, stale timers, credential redaction,
|
||||
typed-field false-positive protection, and append-only legacy-state fail-closed behavior.
|
||||
- Final focused launcher/controller/file-adapter run: 3 files / 67 tests passed.
|
||||
- Final V8 coverage for `framework/runtime/pi/goal-extension.ts`:
|
||||
- 99.17% statements/lines, 93.78% branches, 100% functions.
|
||||
- Installer migration fixture: 24 passed and byte-compared the deployed framework asset.
|
||||
- Standalone extension TypeScript check against installed Pi 0.84.1 types passed:
|
||||
`pnpm --filter @mosaicstack/mosaic exec tsc --noEmit --pretty false --module NodeNext
|
||||
--moduleResolution NodeNext --target ES2022 --skipLibCheck framework/runtime/pi/goal-extension.ts`.
|
||||
- Live deployment/load evidence:
|
||||
- source/deployed SHA-256:
|
||||
`1f0a3806e0948ad5f49684273a7e535e9880c148f7fd16d13ee487fcd601f637`.
|
||||
- `get_commands` identified `/goal` as an extension command sourced from
|
||||
`~/.config/mosaic/runtime/pi/goal-extension.ts`; `/goal help` succeeded; zero extension errors.
|
||||
- live arithmetic goal ended `achieved`, verification `2/2`, with 3 goal reports / 3 agent starts
|
||||
and zero extension errors.
|
||||
- no goal extension exists under `~/.pi` extension paths.
|
||||
|
||||
### Baseline
|
||||
|
||||
- `pnpm build`: passed before the final framework-only redaction remediation; the extension is not a
|
||||
package build input and its final source passed the standalone Pi type check.
|
||||
- `pnpm typecheck`: 45/45 tasks passed.
|
||||
- `pnpm lint`: 25/25 tasks passed.
|
||||
- `pnpm format:check`: passed.
|
||||
- Final Mosaic package components:
|
||||
- Vitest: 82 files / 1,539 tests passed.
|
||||
- full `test:framework-shell` harness passed.
|
||||
- the discovered pre-existing tmux loader-marker race was reproduced with constructor PID
|
||||
evidence, fixed with a pane readiness/FIFO barrier, passed 3 consecutive focused runs, and passed
|
||||
in the full shell harness.
|
||||
- one combined rerun encountered the separate existing real-lease probe TOCTOU in
|
||||
`install-ordering-guard.spec.ts`; an earlier final Vitest run was fully green and the changed
|
||||
focused suites remained green.
|
||||
- Gateway safe baseline excluding the prohibited PostgreSQL-only fixture: 55 files / 600 tests passed
|
||||
(6 files / 12 tests skipped by their existing environment gates).
|
||||
- Root `pnpm test` reached 43 successful workspace tasks and all changed-package Vitest tests, but
|
||||
the unchanged `apps/gateway/src/__tests__/cross-user-isolation.test.ts` afterAll hook retried a
|
||||
PostgreSQL connection and failed authentication (`28P01`). This checkout explicitly forbids local
|
||||
PostgreSQL startup/access; the failure is unrelated to #1150 and cannot be remediated by starting
|
||||
the database. The gateway suite excluding that PostgreSQL-only file and required CI are used as
|
||||
the safe verification paths.
|
||||
|
||||
### Independent review
|
||||
|
||||
- Codex code review: approved, 0 findings across 15 files.
|
||||
- Initial Codex security review: one medium CWE-532/A09 finding for raw report persistence.
|
||||
- Remediation added central credential-pattern redaction, prompt/docs guidance, canary tests, typed
|
||||
field false-positive guards, and sticky fail-closed restore for credential-bearing append-only
|
||||
history.
|
||||
- Codex security re-review: risk `none`, 0 findings, confidence 0.87.
|
||||
- Focused remediation review findings were fixed; final focused re-review verdict: `APPROVE`.
|
||||
- Focused independent review of the tmux readiness barrier: `APPROVE`, no actionable findings.
|
||||
|
||||
## Risks and blockers
|
||||
|
||||
- Live `~/.config/mosaic` is shared by active Pi/fleet processes. Local deployment remained a single
|
||||
additive framework file and did not reload or restart unrelated sessions.
|
||||
- Completion verification is semantic, not mathematical: the active agent supplies structured
|
||||
evidence twice. Operators must still inspect consequential outcomes.
|
||||
- Credential redaction is pattern-based defense-in-depth, not a secret store. It covers
|
||||
controller-owned state/status/tool details, not Pi's separate model-message/tool-call history.
|
||||
Goals and reports must never contain real secrets or raw sensitive output. Because Pi session
|
||||
entries are append-only, a detected credential-bearing legacy branch fails closed and the affected
|
||||
session must be removed.
|
||||
- Current installed Pi is newer than the repository's historical gateway Pi dependency. The
|
||||
extension was checked and smoke-tested against installed Pi 0.84.1 using stable documented APIs.
|
||||
- Local root testing cannot safely execute the unchanged PostgreSQL-only integration fixture under
|
||||
the checkout's explicit database safety constraints. Terminal-green PR CI remains mandatory before
|
||||
merge.
|
||||
- The unchanged real-lease default-probe test can observe different broker availability across its two
|
||||
sequential probes; one combined package rerun hit that existing TOCTOU. The same final Vitest suite
|
||||
passed in a separate run, and CI remains the merge authority.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,99 +0,0 @@
|
||||
# PR merge squash message field
|
||||
|
||||
- **Charter:** `/home/hermes/agent-work/CHARTER-PRMERGE-MESSAGE-FIELD.md`
|
||||
- **Owner:** `be-coder-08`
|
||||
- **Branch:** `fix/pr-merge-message-field`
|
||||
- **Base:** remote `main` / local `origin/main` at `85d2108e4ed15c744ad3b87a5b629e7b2d39405a`
|
||||
- **Estate:** HOMELAB tooling shared by HOMELAB and USC
|
||||
|
||||
## Objective
|
||||
|
||||
Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genuine multi-author PRs retain non-poster branch authors without weakening hardcoded squash behavior.
|
||||
|
||||
## Binding requirements
|
||||
|
||||
1. `Do` remains hardcoded to `squash`; no provider/repository default may select merge style.
|
||||
2. A verified trailer uses a PR commit's linked `author.login` and that same commit's author email. No `/users/{login}` primary-email lookup occurs. Recorded rationale: this asks only what the provider can answer.
|
||||
3. A commit with `author.login` null blocks before merge, prints both the null provider fact and commit email fact, and names the escalation principal.
|
||||
4. The BLOCK arm must be observed firing; a normal canonical single-author API payload remains explicit squash plus its reviewed `head_commit_id`.
|
||||
5. Every provider mutation is read back from the provider; no real PR is merged during tests.
|
||||
|
||||
## Derived interface decisions
|
||||
|
||||
- Add `--co-author-trailers` rather than accepting arbitrary message text. The wrapper enumerates PR commits and constructs trailers, making an unchecked `Co-authored-by` line unexpressible.
|
||||
- Require `--escalate-to PRINCIPAL` with `--co-author-trailers`, so the BLOCK diagnostic always names a principal rather than a generic role.
|
||||
- Do not expose `MergeTitleField` separately. When trailers exist, set it from the provider PR title and set `MergeMessageField` only to construction-generated trailers. This preserves one provider source for the title and avoids an unrelated caller-controlled degree of freedom.
|
||||
- Preserve first-commit order and emit one trailer per distinct non-poster `author.login`, using that first linked commit's own email.
|
||||
|
||||
## Canonical delivery plan
|
||||
|
||||
1. Port the capability into the installed source of truth, `packages/mosaic/framework/tools/git/pr-merge.sh`; do not retain `infra/fleet/tools/git` as a second copy.
|
||||
2. Preserve canonical `--expect-head`, exact head branch/repository/SHA queue inspection, Gitea atomic head pinning, GitHub `--match-head-commit`, and delete-after-merge semantics.
|
||||
3. Do not port the deployed-only `--skip-queue-guard` bypass. Add the focused harness to the canonical framework-shell suite and re-establish RED/GREEN on the packaged baseline.
|
||||
4. Deliver through a reviewed package release followed by `mosaic update` with its default framework reseed. The installer snapshots, manifest-syncs framework-owned `tools/**`, and rolls back on failure.
|
||||
5. Before either estate relies on the change, require installed/package hash equality, `MergeMessageField` presence, and a green focused harness. Release/reseed ownership is currently unassigned and blocks activation after source merge.
|
||||
|
||||
## Evidence
|
||||
|
||||
- RED against the byte-identical deployed baseline (`sha256 08a65e8584c5…`): rc 1 with eight named failures. The wrapper rejected `--co-author-trailers`; the null-login path emitted none of the required BLOCK facts/principal; and both verified/ordinary API paths failed the stdin-config credential assertion (ordinary path exposed the fixture token through curl argv). Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-red.log`.
|
||||
- GREEN on the deployed-baseline candidate: verified linked multi-author payload, null-login BLOCK, required named principal, explicit squash, stdin-config token transport, and absence of `/users` lookup all passed. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-green.log`.
|
||||
- RED against canonical packaged baseline `c581ef48…`: rc 1 with 32 assertions. It rejects the new option, and the first harness version did not satisfy canonical head branch/repository/SHA metadata. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-packaged-baseline-red.log`. The port adapts the fixture rather than weakening canonical head controls.
|
||||
- Provider capability probe against `git.mosaicstack.dev`: authenticated `be-coder-08` POST to deliberately nonexistent PR `2147483647` with both message fields returned JSON HTTP 404; the unauthenticated same request returned JSON HTTP 401 (not the charter's predicted 403). The authenticated-vs-unauthenticated differential proves write authorization resolved while no mergeable subject existed. `tl-mosaic` ruled the literal non-load-bearing: preserve the observed 404/401 pair and do not manufacture a 403 case. No cause was inferred and no real PR was targeted.
|
||||
- Provider-generated trailer behavior is not treated as exclusive or absent. The wrapper's VERIFIED/BLOCK decision binds each requested non-poster trailer to commit `author.login` plus that commit's email; it does not assume `MergeMessageField` is the squash's only trailer source. The poster is omitted from the constructed list because the resulting squash author already records the poster; any additional provider-generated trailer is outside this change's unmeasured mechanism.
|
||||
- An early candidate SHA-256 `5de32876990e4f26920448cb3220cc7f1146d558b4dd2bc1ee1a2abee2f2cbe6` passed the initial harness, then author-side review found credential-fallback and argv-exposure defects. The live deployed wrapper was atomically restored to baseline SHA-256 `08a65e8584c52c6d41ea1c686f8b95585c21e4b37320a2447eba09359a0e02c1`; the remediated candidate remains only in the worktree.
|
||||
|
||||
## Remediation and current review state
|
||||
|
||||
1. Token and Basic Auth now use stdin curl configuration, not argv. PR title, contributor email, and the JSON payload also remain out of child argv.
|
||||
2. Each credential attempt binds commit inspection and merge. A token failure during either inspection or mutation causes Basic fallback to repeat inspection before mutation; the payload pins the inspected `head_commit_id`.
|
||||
3. Focused tests cover token-resolution fail-closed behavior, both HTTP-401 fallback seams, metadata/credential argv absence, null-login BLOCK, explicit squash, canonical reviewed-head binding, unchanged ordinary payload, and retained log-safe provider diagnostics. Token-resolution RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-token-resolution-red.log`.
|
||||
4. Codex review rounds 3–5 requested retained provider error text, log-safe provider diagnostics, fail-closed credential fallback, stable value-option parsing, and PR-title trailer-injection prevention. These are remediated with regression assertions. A post-remediation independent review is still required.
|
||||
5. **Accepted linkage limitation:** `author.login` resolution proves that the commit address maps to a registered provider account. It does not prove that the named principal authored the commit because Git author metadata is self-asserted. This gate checks attribution linkage, not authorship; commit signing is out of scope and currently unadopted. Coordinators explicitly ruled that this does not add a third state.
|
||||
6. Codex's sandbox could not execute the harness because its checkout was read-only; that environmental limitation is recorded separately from host-side test results.
|
||||
|
||||
## Disposable provider fixture acceptance
|
||||
|
||||
- Use a retained scratch repository only, with two branch authors and `author != committer` on at least one commit.
|
||||
- Arm A supplies a message-field trailer for one non-poster; record whether that value lands without forcing the partial-pair result into under-specified `APPENDS`/`REPLACES` labels. Demonstrate an absence control.
|
||||
- Arm B includes a registered trailer for a different non-poster on a branch commit; record whether it survives or drops. Verify identity through an existing commit whose `author.login` resolves and demonstrate an absence control.
|
||||
- Parse landed trailers key-agnostically with `^[A-Za-z-]+-[Bb]y:` and record generated poster pair presence/absence plus resulting poster attribution.
|
||||
- Record `/users/<login>` status and raw email only as non-gating estate telemetry. Never read `active`, `visibility`, or any profile field as an identity gate.
|
||||
- Use distinct principals: poster `be-coder-08`, merger `Mos`, Arm A `be-coder-07`, and Arm B `be-coder-06`. Capture every trailer-shaped line verbatim and in order. Zero trailer lines means the generator did not fire and the run is `VOID`, not evidence that either arm dropped.
|
||||
- Report the same read-back evidence to `mos-claude` on socket `default` and `tl-mosaic` on socket `mosaic-fleet`. Report values rather than mechanism inferences and stop on any poster-attribution regression.
|
||||
|
||||
## Fixture preflight
|
||||
|
||||
- Retained public repository: `mosaicstack/prmerge-trailer-fixture`; PR `#1`, posted by `be-coder-08` and reserved for merge by `Mos`.
|
||||
- Existing `mosaicstack/stack` commits resolve `be-coder-07` and `be-coder-06` through `author.login`; exact addresses are `[email protected]` and `[email protected]`.
|
||||
- Non-gating HOMELAB telemetry for authenticated reader `be-coder-08`: `/api/v1/users/be-coder-06` returned HTTP 200 with raw `email` value `[email protected]`.
|
||||
- Provider preflight showed PR commit enumeration is newest-first. A new RED test proved that deriving `head_commit_id` from the final array element selected the wrong commit. The candidate now reads `.head.sha` from the authenticated PR endpoint before enumeration, verifies it appears in the commit set, and atomically pins that SHA in the explicit squash payload. RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-head-order-red.log`.
|
||||
- Fixture PR head `f6ba6e5105031fa21f5ff7bd8e4379d99c16e1de` has `author.login=be-coder-07`, `committer.login=be-coder-08`, and branch-message trailer `Co-authored-by: be-coder-06 <[email protected]>`.
|
||||
|
||||
## Fixture result
|
||||
|
||||
- `Mos` merged retained fixture PR `#1` through staged candidate SHA-256 `60e779a85fd13b729d859ea7c986d1e9b1641b97991611329226c1b3113ffb6e`; resulting squash commit: `3f550715d9bc716426fd355a65fe997b3a90fa7d` with one parent.
|
||||
- Provider read-back: poster/commit author `be-coder-08`, committer/merger `Mos`. The run is non-void.
|
||||
- Trailer-shaped lines, verbatim and in order:
|
||||
1. `Co-authored-by: be-coder-07 <[email protected]>`
|
||||
2. `Co-authored-by: be-coder-08 <[email protected]>`
|
||||
- Arm A supplied field value (`be-coder-07`) landed. Arm B branch trailer (`be-coder-06`) dropped. Both fabricated absence controls remained absent. No `Co-committed-by:` line landed.
|
||||
- The candidate payload construction explicitly excludes the poster and supplied only the Arm A `be-coder-07` line. Therefore the landed poster line was provider-generated, not candidate-composed. The raw result supports `FIELD LANDS`, `BRANCH DROPS`, and `POSTER GENERATED`; it does not support a claim that candidate code supplied the poster. Evidence: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-fixture-readback.log` and the retained provider object.
|
||||
- Retained fixture PR `#2` measured the N=2 shape needed by `#1030`: supplied `be-coder-07` then `be-coder-06`; both landed in that order, followed by the provider-generated poster line. No truncation or dedup occurred at N=2. Resulting squash: `39db9d13aed0…`.
|
||||
|
||||
## Current hold point
|
||||
|
||||
PR `mosaicstack/stack#1066` is open. Its first frozen head `f4b162fa…` was terminal-green in Woodpecker `mosaic` pipeline `#2225`, but that evidence becomes stale when the canonical port moves the head. The deployed wrapper remains baseline `08a65e85…`; no manual copy will occur. Canonical port tests, commit amendment, rebase, one guarded force-with-lease, exact-head CI, and new independent review remain. Even after source merge, activation remains blocked on an assigned package-release/reseed owner and installed-byte read-back.
|
||||
|
||||
## Security review 96 remediation
|
||||
|
||||
Exact reviewed predecessor head: `1ceb11058f64dd7f4a817ceb2124f980a1c4dd23`.
|
||||
|
||||
RED-first focused harness produced 10 named failures: all curl calls lacked size/time/connect bounds; raw ESC email reached mutation; oversized and stalled curl failures were discarded and reached mutation; nonempty Basic output with resolver rc 91 authorized mutation.
|
||||
|
||||
Security remediation:
|
||||
|
||||
- Removed the cross-principal HTTP-401 Basic fallback. Both inspection-401 and merge-401 paths now refuse without Basic resolution or mutation; `get_gitea_basic_auth` references in the merge subject are 0.
|
||||
- Applied `--max-filesize`, `--max-time`, and `--connect-timeout` to all 3/3 provider curl sites and fail closed on curl transport rc at all 3/3 sites.
|
||||
- Required linked email bytes to be ASCII and printable before constructing `MergeMessageField`; guarded construction sites 1/1.
|
||||
|
||||
GREEN: message-field, exact-head, empty-UID/API, queue branch/repository/SHA, bash syntax, ShellCheck, and diff check pass. R7 total-removal mutants went RED: email guard 3 rows; bound switches 1 row; transport-rc guards 4 rows; HTTP-401 refusal 3 rows. R7 bound: mutants prove total removal only; explicit denominators above prove site coverage.
|
||||
@@ -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 D1–D15 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 1–8, 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 D1–D15 binding contract.
|
||||
- No source code, database, deployment, or live runtime changes made.
|
||||
@@ -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.
|
||||
+2
-1
@@ -10,7 +10,8 @@
|
||||
"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",
|
||||
"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": "node scripts/install-hooks.mjs"
|
||||
|
||||
@@ -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.'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,8 +13,7 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher:
|
||||
|
||||
1. Injects the full runtime contract via `--append-system-prompt`
|
||||
2. Loads Mosaic skills via `--skill` flags
|
||||
3. Loads framework-owned `mosaic-extension.ts` and `goal-extension.ts` from
|
||||
`~/.config/mosaic/runtime/pi/` via ordered `--extension` flags
|
||||
3. Loads the Mosaic extension via `--extension` for lifecycle hooks
|
||||
4. Detects active missions and injects initial prompts
|
||||
|
||||
## Capabilities vs Other Runtimes
|
||||
@@ -23,7 +22,6 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher:
|
||||
- Native thinking levels replace sequential-thinking MCP
|
||||
- Native skill discovery compatible with Mosaic SKILL.md format
|
||||
- Native extension system for lifecycle hooks (TypeScript, not bash shims)
|
||||
- Bounded persistent `/goal` loop with per-turn, post-compaction, and two-pass evidence checks
|
||||
- Native session persistence and resume
|
||||
- Model-agnostic (Anthropic, OpenAI, Google, Ollama, custom providers)
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -94,14 +104,7 @@ The launcher:
|
||||
1. Verifies `~/.config/mosaic` exists
|
||||
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
|
||||
3. Injects `AGENTS.md` into the runtime
|
||||
4. For Pi, loads the framework-owned core and persistent-goal extensions from
|
||||
`~/.config/mosaic/runtime/pi/`
|
||||
5. Forwards all arguments to the runtime CLI
|
||||
|
||||
Inside `mosaic pi`, `/goal set <statement>` starts a bounded persistent goal loop. Use `/goal status`,
|
||||
`/goal pause`, `/goal resume`, or `/goal cancel` to control it. The extension remains part of Mosaic
|
||||
under `~/.config/mosaic/runtime/pi/goal-extension.ts`; it is not installed in Pi's main extension
|
||||
directory.
|
||||
4. Forwards all arguments to the runtime CLI
|
||||
|
||||
You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtime adapters will tell the agent to read `~/.config/mosaic/AGENTS.md`.
|
||||
|
||||
@@ -115,13 +118,13 @@ 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
|
||||
│ ├── opencode/ ← AGENTS.md, RUNTIME.md
|
||||
│ ├── pi/ ← RUNTIME.md, mosaic-extension.ts, goal-extension.ts
|
||||
│ ├── pi/ ← RUNTIME.md, mosaic-extension.ts
|
||||
│ └── mcp/ ← MCP server configs
|
||||
├── skills/ ← Universal skills (synced from mosaic/agent-skills)
|
||||
├── skills-local/ ← Local cross-runtime skills
|
||||
@@ -133,7 +136,7 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
|
||||
|
||||
| Launch method | Injection mechanism |
|
||||
| ------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + Mosaic extensions |
|
||||
| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + extension |
|
||||
| `mosaic claude` | `--append-system-prompt` with composed runtime contract (`AGENTS.md` + runtime reference) |
|
||||
| `mosaic codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch |
|
||||
| `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch |
|
||||
@@ -181,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.
|
||||
@@ -191,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.
|
||||
@@ -204,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
|
||||
@@ -216,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
|
||||
|
||||
@@ -51,26 +51,12 @@ Skills are discovered from:
|
||||
|
||||
### Extensions
|
||||
|
||||
`mosaic pi` loads framework-owned extensions directly from `~/.config/mosaic/runtime/pi/` in this
|
||||
order:
|
||||
The Mosaic Pi extension (`~/.config/mosaic/runtime/pi/mosaic-extension.ts`) handles:
|
||||
|
||||
1. `mosaic-extension.ts` — session lifecycle, mission context, memory routing, lease/mutator gates,
|
||||
and fleet heartbeat reporting.
|
||||
2. `goal-extension.ts` — optional persistent `/goal` controller with per-turn and post-compaction
|
||||
checks.
|
||||
|
||||
The goal extension is deployed by Mosaic and MUST NOT be copied into `~/.pi/agent/extensions/`.
|
||||
Use `/goal set <statement>` (or `/goal <statement>`) to start, then `/goal status`, `/goal pause`,
|
||||
`/goal resume`, or `/goal cancel` to control it. An active goal is injected before every model
|
||||
request, restored from branch-specific session entries, and considered achieved only after two
|
||||
consecutive evidence-bearing reports. Common credential shapes are redacted before controller-owned
|
||||
goal-state entries are persisted or
|
||||
displayed; Pi's own model/tool-call history is separate. Goals and reports must contain references
|
||||
and pass/fail summaries rather than secrets or raw sensitive output.
|
||||
|
||||
- `MOSAIC_GOAL_MAX_TURNS` — autonomous turn limit, default `40`, accepted range `1..500`.
|
||||
- `MOSAIC_GOAL_MAX_NO_PROGRESS` — identical no-progress report limit, default `6`, accepted range
|
||||
`1..100`.
|
||||
- Session start/end lifecycle hooks
|
||||
- Active mission detection and context injection
|
||||
- Memory routing to `~/.config/mosaic/memory/`
|
||||
- MACP queue status reporting
|
||||
|
||||
### Sessions
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user