Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90eb48fa53 |
@@ -8,7 +8,6 @@ coverage
|
|||||||
.env.local
|
.env.local
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
__pycache__/
|
|
||||||
docs/reports/
|
docs/reports/
|
||||||
|
|
||||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
pnpm typecheck && pnpm lint && pnpm format:check
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||||
# HOME resolves to /root in the ci-base image, preserving its warmed-store path.
|
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
|
||||||
# Non-root checkouts use their own HOME. Override without editing this file via
|
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
||||||
# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting).
|
# instead of repopulating a fresh one.
|
||||||
store-dir=${HOME}/.local/share/pnpm/store
|
store-dir=/root/.local/share/pnpm/store
|
||||||
|
|||||||
@@ -4,14 +4,6 @@ pnpm-lock.yaml
|
|||||||
**/node_modules
|
**/node_modules
|
||||||
**/drizzle
|
**/drizzle
|
||||||
**/.next
|
**/.next
|
||||||
# Python build/test artifacts — same category as node_modules/dist/.next above.
|
|
||||||
# Prettier must never scan generated trees; without these a local venv poisons
|
|
||||||
# `pnpm format:check` with thousands of third-party files.
|
|
||||||
**/venv
|
|
||||||
**/__pycache__
|
|
||||||
**/.mypy_cache
|
|
||||||
**/.pytest_cache
|
|
||||||
**/htmlcov
|
|
||||||
.claude/
|
.claude/
|
||||||
docs/tess/TASKS.md
|
docs/tess/TASKS.md
|
||||||
docs/scratchpads/
|
docs/scratchpads/
|
||||||
|
|||||||
@@ -41,11 +41,6 @@ steps:
|
|||||||
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
|
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
||||||
# Test-membership guard (#1017): also first link of test:framework-shell.
|
|
||||||
# Invoked from BOTH surfaces it audits (F2, PR #1018) — the guard is link
|
|
||||||
# [0] of the pnpm chain, so severing that chain would silence it together
|
|
||||||
# with everything it guards; this direct line keeps one instrument running.
|
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
|
||||||
|
|
||||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||||
@@ -103,12 +98,6 @@ steps:
|
|||||||
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
# openssl (#912) is the wake HMAC signer: the digest H1/H2, beacon B12,
|
|
||||||
# and install I8 legs hard-require it in CI. It is baked into ci-base via
|
|
||||||
# Dockerfile.ci, but ci-base only rebuilds on push-to-main/tag — this
|
|
||||||
# `apk add` guarantees openssl is present on PR pipelines too (and is a
|
|
||||||
# fast no-op once the rebuilt image already ships it).
|
|
||||||
- apk add --no-cache openssl
|
|
||||||
# postgresql-client (pg_isready) is baked into ci-base.
|
# postgresql-client (pg_isready) is baked into ci-base.
|
||||||
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
||||||
- |
|
- |
|
||||||
|
|||||||
+5
-104
@@ -1,5 +1,5 @@
|
|||||||
# Build, publish npm packages, and push Docker images
|
# Build, publish npm packages, and push Docker images
|
||||||
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
# Runs only on main branch push/tag
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||||
@@ -23,21 +23,9 @@ variables:
|
|||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- '.woodpecker/**'
|
- '.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:
|
when:
|
||||||
- branch: [main, next]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -115,84 +103,6 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- 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
|
# TODO: Uncomment when ready to publish to npmjs.org
|
||||||
# publish-npmjs:
|
# publish-npmjs:
|
||||||
# image: *node_image
|
# image: *node_image
|
||||||
@@ -224,17 +134,8 @@ steps:
|
|||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- 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}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; 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"
|
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
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||||
@@ -245,7 +146,7 @@ steps:
|
|||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *main_image_build_when
|
when: *image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -271,7 +172,7 @@ steps:
|
|||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *main_image_build_when
|
when: *image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
|
|||||||
@@ -11,87 +11,48 @@
|
|||||||
|
|
||||||
## Project Context
|
## Project Context
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### Stack
|
## Package Map
|
||||||
|
|
||||||
- **API:** NestJS with Fastify (`apps/gateway`)
|
| Package | Purpose | Key Dependencies |
|
||||||
- **Web:** Next.js 16 with React 19 (`apps/web`)
|
| ------------------ | ------------------------------- | -------------------------------- |
|
||||||
- **ORM and database:** Drizzle ORM, PostgreSQL 17, and pgvector (`packages/db`)
|
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
||||||
- **Authentication:** BetterAuth (`packages/auth`)
|
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
||||||
- **Agent runtime:** Pi SDK (`apps/gateway`, `packages/mosaic`)
|
| `packages/types` | Shared TypeScript contracts | class-validator |
|
||||||
- **Queue:** Valkey 8 (`packages/queue`)
|
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
|
||||||
- **Build:** pnpm workspaces and Turborepo
|
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
||||||
- **CI:** Woodpecker CI
|
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
|
||||||
- **Observability:** OpenTelemetry and Jaeger
|
| `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 |
|
||||||
|
|
||||||
### Package Map
|
## Architecture Rules
|
||||||
|
|
||||||
| Package | Purpose | Key Dependencies |
|
1. Gateway is the single API surface — all clients connect through it
|
||||||
| ------------------ | ----------------------------- | -------------------------------- |
|
2. Pi SDK is ESM-only — gateway and CLI must use ESM
|
||||||
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
3. Socket.IO typed events defined in `@mosaicstack/types` enforce compile-time contracts
|
||||||
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
4. OTEL auto-instrumentation loads before NestJS bootstrap
|
||||||
| `packages/types` | Shared TypeScript contracts | class-validator |
|
5. BetterAuth manages auth tables; schema defined in `@mosaicstack/db`
|
||||||
| `packages/db` | Drizzle schema and migrations | drizzle-orm, postgres |
|
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
|
||||||
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
|
||||||
| `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
|
## Development Workflow
|
||||||
|
|
||||||
Requirements: Node.js 20+, pnpm 10.6.2, and Docker Compose when optional local services are needed.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install --frozen-lockfile
|
docker compose up -d # Infrastructure
|
||||||
pnpm preflight
|
pnpm install # Dependencies
|
||||||
|
pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
||||||
# Optional local queue service only; do not start the full Compose stack.
|
|
||||||
docker compose up -d valkey
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The pre-push hook requires:
|
## Repo-Specific Notes
|
||||||
|
|
||||||
```bash
|
- DTOs in `*.dto.ts` files at module boundaries
|
||||||
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
||||||
```
|
- NodeNext module resolution in all tsconfigs
|
||||||
|
- Scratchpads are mandatory for non-trivial tasks
|
||||||
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)
|
## docs/TASKS.md — Schema (CANONICAL)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,46 @@
|
|||||||
# Claude Compatibility Pointer
|
# CLAUDE.md — Mosaic Stack
|
||||||
|
|
||||||
@AGENTS.md
|
## Project
|
||||||
|
|
||||||
Do not add project guidance here. Keep `AGENTS.md` authoritative so every agent runtime receives the same instructions.
|
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
|
||||||
|
|||||||
+1
-4
@@ -25,10 +25,7 @@ FROM node:24-alpine
|
|||||||
# postgresql-client used by the test step's pg_isready readiness probe. `bash`,
|
# postgresql-client used by the test step's pg_isready readiness probe. `bash`,
|
||||||
# `git`, and `jq` are baked here too — framework shell tests and the shipped
|
# `git`, and `jq` are baked here too — framework shell tests and the shipped
|
||||||
# Codex review wrappers require them without per-run installation in ci.yml.
|
# Codex review wrappers require them without per-run installation in ci.yml.
|
||||||
# `openssl` (#912) is the non-circular HMAC signer for the wake trust layer:
|
RUN apk add --no-cache python3 make g++ postgresql-client bash git jq
|
||||||
# the digest H1/H2, beacon B12, and install I8 legs hard-require it in CI so the
|
|
||||||
# §4 G6 evidence comes from an actually-run HMAC leg, not a skipped one.
|
|
||||||
RUN apk add --no-cache python3 make g++ postgresql-client bash git jq openssl
|
|
||||||
|
|
||||||
# Pin pnpm to the repo's packageManager version via corepack.
|
# Pin pnpm to the repo's packageManager version via corepack.
|
||||||
RUN corepack enable && corepack prepare [email protected] --activate
|
RUN corepack enable && corepack prepare [email protected] --activate
|
||||||
|
|||||||
@@ -30,16 +30,6 @@ This installs both components:
|
|||||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
| **@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:
|
After install, the wizard runs automatically or you can invoke it manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -48,13 +38,9 @@ mosaic wizard # Full guided setup (gateway install → verify)
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Node.js ≥ 22
|
- Node.js ≥ 20
|
||||||
- npm (for global @mosaicstack/mosaic install)
|
- npm (for global @mosaicstack/mosaic install)
|
||||||
- One or more runtimes:
|
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
||||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
|
||||||
- [Codex](https://github.com/openai/codex)
|
|
||||||
- [OpenCode](https://opencode.ai)
|
|
||||||
- [Pi](https://pi.dev)
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -204,7 +190,7 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js ≥ 22
|
- Node.js ≥ 20
|
||||||
- pnpm 10.6+
|
- pnpm 10.6+
|
||||||
- Docker & Docker Compose
|
- Docker & Docker Compose
|
||||||
|
|
||||||
@@ -215,21 +201,8 @@ git clone [email protected]:mosaicstack/stack.git
|
|||||||
cd stack
|
cd stack
|
||||||
|
|
||||||
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
||||||
# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without
|
|
||||||
# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed.
|
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# Verify dependencies and generated state before running source-quality gates.
|
|
||||||
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
|
|
||||||
# The web build certifies its exact standalone symlink manifest; added, removed,
|
|
||||||
# retargeted, or manifest-only-tampered generated links also exit 43. This detects
|
|
||||||
# accidental, independent, stale, and foreign-residue mutation—the class exposed by
|
|
||||||
# a five-month-stale .next that produced 19 phantom TS2307 errors.
|
|
||||||
# It does NOT defend against a same-UID actor that can rewrite both manifest and
|
|
||||||
# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side
|
|
||||||
# trust anchor outside worktree authority.
|
|
||||||
pnpm preflight
|
|
||||||
|
|
||||||
# Optional local queue service only. This does not start PostgreSQL.
|
# Optional local queue service only. This does not start PostgreSQL.
|
||||||
docker compose up -d valkey
|
docker compose up -d valkey
|
||||||
|
|
||||||
@@ -257,7 +230,6 @@ Gateway start command until KBN-101-02 makes that state fail closed.
|
|||||||
### Quality Gates
|
### Quality Gates
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm preflight # Checkout/dependency/generated-state validation
|
|
||||||
pnpm typecheck # TypeScript type checking (all packages)
|
pnpm typecheck # TypeScript type checking (all packages)
|
||||||
pnpm lint # ESLint (all packages)
|
pnpm lint # ESLint (all packages)
|
||||||
pnpm test # Vitest (all packages)
|
pnpm test # Vitest (all packages)
|
||||||
@@ -375,9 +347,7 @@ 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 --check # Version check only
|
||||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||||
bash tools/install.sh --next # Prerelease lane: source build from next
|
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||||
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 --yes # Non-interactive, accept all defaults
|
||||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,519 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,11 +1,9 @@
|
|||||||
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||||
import { sql, type Db } from '@mosaicstack/db';
|
import { sql, type Db } from '@mosaicstack/db';
|
||||||
import { createQueue } from '@mosaicstack/queue';
|
import { createQueue } from '@mosaicstack/queue';
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
import { ProviderService } from '../agent/provider.service.js';
|
import { ProviderService } from '../agent/provider.service.js';
|
||||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
|
||||||
import { AdminGuard } from './admin.guard.js';
|
import { AdminGuard } from './admin.guard.js';
|
||||||
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
||||||
|
|
||||||
@@ -16,9 +14,6 @@ export class AdminHealthController {
|
|||||||
@Inject(DB) private readonly db: Db,
|
@Inject(DB) private readonly db: Db,
|
||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||||
@Optional()
|
|
||||||
@Inject(MOSAIC_CONFIG)
|
|
||||||
private readonly mosaicConfig: MosaicConfig | null,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -60,14 +55,6 @@ export class AdminHealthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async checkCache(): Promise<ServiceStatusDto> {
|
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 start = Date.now();
|
||||||
const handle = createQueue();
|
const handle = createQueue();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
* to avoid real I/O — they verify the complete classify → match → decide path.
|
* to avoid real I/O — they verify the complete classify → match → decide path.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
|
||||||
import { RoutingEngineService } from './routing-engine.service.js';
|
import { RoutingEngineService } from './routing-engine.service.js';
|
||||||
import { DEFAULT_ROUTING_RULES } from '../routing/default-rules.js';
|
import { DEFAULT_ROUTING_RULES } from '../routing/default-rules.js';
|
||||||
import type { RoutingRule } from './routing.types.js';
|
import type { RoutingRule } from './routing.types.js';
|
||||||
@@ -18,7 +17,7 @@ import type { RoutingRule } from './routing.types.js';
|
|||||||
/** Build a RoutingEngineService backed by the given rule set and health map. */
|
/** Build a RoutingEngineService backed by the given rule set and health map. */
|
||||||
function makeService(
|
function makeService(
|
||||||
rules: RoutingRule[],
|
rules: RoutingRule[],
|
||||||
healthMap: Record<string, { status: ProviderHealthStatus }>,
|
healthMap: Record<string, { status: string }>,
|
||||||
): RoutingEngineService {
|
): RoutingEngineService {
|
||||||
const mockDb = {
|
const mockDb = {
|
||||||
select: vi.fn().mockReturnValue({
|
select: vi.fn().mockReturnValue({
|
||||||
@@ -68,11 +67,11 @@ function defaultRules(): RoutingRule[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A health map where anthropic, openai, and zai are all healthy. */
|
/** A health map where anthropic, openai, and zai are all healthy. */
|
||||||
const allHealthy: Record<string, { status: ProviderHealthStatus }> = {
|
const allHealthy: Record<string, { status: string }> = {
|
||||||
anthropic: { status: 'healthy' },
|
anthropic: { status: 'up' },
|
||||||
openai: { status: 'healthy' },
|
openai: { status: 'up' },
|
||||||
zai: { status: 'healthy' },
|
zai: { status: 'up' },
|
||||||
ollama: { status: 'healthy' },
|
ollama: { status: 'up' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── M4-013 E2E tests ─────────────────────────────────────────────────────────
|
// ─── M4-013 E2E tests ─────────────────────────────────────────────────────────
|
||||||
@@ -213,10 +212,10 @@ describe('M4-013: routing end-to-end pipeline', () => {
|
|||||||
// Let's use a simple coding message to target Simple coding → Codex (openai)
|
// Let's use a simple coding message to target Simple coding → Codex (openai)
|
||||||
const message = 'implement a sort function';
|
const message = 'implement a sort function';
|
||||||
|
|
||||||
const unhealthyHealth: Record<string, { status: ProviderHealthStatus }> = {
|
const unhealthyHealth = {
|
||||||
anthropic: { status: 'down' },
|
anthropic: { status: 'down' },
|
||||||
openai: { status: 'healthy' },
|
openai: { status: 'up' },
|
||||||
zai: { status: 'healthy' },
|
zai: { status: 'up' },
|
||||||
ollama: { status: 'down' },
|
ollama: { status: 'down' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { routingRules, type Db, and, asc, eq, or } from '@mosaicstack/db';
|
import { routingRules, type Db, and, asc, eq, or } from '@mosaicstack/db';
|
||||||
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
|
||||||
import { DB } from '../../database/database.module.js';
|
import { DB } from '../../database/database.module.js';
|
||||||
import { ProviderService } from '../provider.service.js';
|
import { ProviderService } from '../provider.service.js';
|
||||||
import { classifyTask } from './task-classifier.js';
|
import { classifyTask } from './task-classifier.js';
|
||||||
@@ -50,7 +49,7 @@ export class RoutingEngineService {
|
|||||||
async resolve(
|
async resolve(
|
||||||
message: string,
|
message: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
availableProviders?: Record<string, { status: ProviderHealthStatus }>,
|
availableProviders?: Record<string, { status: string }>,
|
||||||
): Promise<RoutingDecision> {
|
): Promise<RoutingDecision> {
|
||||||
const classification = classifyTask(message);
|
const classification = classifyTask(message);
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
@@ -70,8 +69,9 @@ export class RoutingEngineService {
|
|||||||
if (!this.matchConditions(rule, classification)) continue;
|
if (!this.matchConditions(rule, classification)) continue;
|
||||||
|
|
||||||
const providerStatus = health[rule.action.provider]?.status;
|
const providerStatus = health[rule.action.provider]?.status;
|
||||||
|
const isHealthy = providerStatus === 'up' || providerStatus === 'ok';
|
||||||
|
|
||||||
if (!this.isRoutable(providerStatus)) {
|
if (!isHealthy) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Rule "${rule.name}" matched but provider "${rule.action.provider}" is unhealthy (status: ${providerStatus ?? 'unknown'})`,
|
`Rule "${rule.name}" matched but provider "${rule.action.provider}" is unhealthy (status: ${providerStatus ?? 'unknown'})`,
|
||||||
);
|
);
|
||||||
@@ -111,10 +111,6 @@ export class RoutingEngineService {
|
|||||||
|
|
||||||
// ─── Private helpers ───────────────────────────────────────────────────────
|
// ─── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
private isRoutable(status: ProviderHealthStatus | undefined): boolean {
|
|
||||||
return status === 'healthy' || status === 'degraded';
|
|
||||||
}
|
|
||||||
|
|
||||||
private evaluateCondition(
|
private evaluateCondition(
|
||||||
condition: RoutingCondition,
|
condition: RoutingCondition,
|
||||||
classification: TaskClassification,
|
classification: TaskClassification,
|
||||||
@@ -190,12 +186,11 @@ export class RoutingEngineService {
|
|||||||
* Walk the fallback chain and return the first healthy provider/model pair.
|
* Walk the fallback chain and return the first healthy provider/model pair.
|
||||||
* If none are healthy, return the first entry unconditionally (last resort).
|
* If none are healthy, return the first entry unconditionally (last resort).
|
||||||
*/
|
*/
|
||||||
private applyFallbackChain(
|
private applyFallbackChain(health: Record<string, { status: string }>): RoutingDecision {
|
||||||
health: Record<string, { status: ProviderHealthStatus }>,
|
|
||||||
): RoutingDecision {
|
|
||||||
for (const candidate of FALLBACK_CHAIN) {
|
for (const candidate of FALLBACK_CHAIN) {
|
||||||
const providerStatus = health[candidate.provider]?.status;
|
const providerStatus = health[candidate.provider]?.status;
|
||||||
if (this.isRoutable(providerStatus)) {
|
const isHealthy = providerStatus === 'up' || providerStatus === 'ok';
|
||||||
|
if (isHealthy) {
|
||||||
this.logger.debug(`Fallback resolved: ${candidate.provider}/${candidate.model}`);
|
this.logger.debug(`Fallback resolved: ${candidate.provider}/${candidate.model}`);
|
||||||
return {
|
return {
|
||||||
provider: candidate.provider,
|
provider: candidate.provider,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
|
||||||
import { RoutingEngineService } from './routing-engine.service.js';
|
import { RoutingEngineService } from './routing-engine.service.js';
|
||||||
import type { RoutingRule, TaskClassification } from './routing.types.js';
|
import type { RoutingRule, TaskClassification } from './routing.types.js';
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ function makeClassification(overrides: Partial<TaskClassification> = {}): TaskCl
|
|||||||
/** Build a minimal RoutingEngineService with mocked DB and ProviderService. */
|
/** Build a minimal RoutingEngineService with mocked DB and ProviderService. */
|
||||||
function makeService(
|
function makeService(
|
||||||
rules: RoutingRule[] = [],
|
rules: RoutingRule[] = [],
|
||||||
healthMap: Record<string, { status: ProviderHealthStatus }> = {},
|
healthMap: Record<string, { status: string }> = {},
|
||||||
): RoutingEngineService {
|
): RoutingEngineService {
|
||||||
const mockDb = {
|
const mockDb = {
|
||||||
select: vi.fn().mockReturnValue({
|
select: vi.fn().mockReturnValue({
|
||||||
@@ -218,10 +217,7 @@ describe('RoutingEngineService.resolve — priority ordering', () => {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const service = makeService(rules, {
|
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });
|
||||||
anthropic: { status: 'healthy' },
|
|
||||||
openai: { status: 'healthy' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const decision = await service.resolve('implement a function');
|
const decision = await service.resolve('implement a function');
|
||||||
expect(decision.ruleName).toBe('high priority');
|
expect(decision.ruleName).toBe('high priority');
|
||||||
@@ -245,10 +241,7 @@ describe('RoutingEngineService.resolve — priority ordering', () => {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const service = makeService(rules, {
|
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });
|
||||||
anthropic: { status: 'healthy' },
|
|
||||||
openai: { status: 'healthy' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const decision = await service.resolve('implement a function');
|
const decision = await service.resolve('implement a function');
|
||||||
expect(decision.ruleName).toBe('coding rule');
|
expect(decision.ruleName).toBe('coding rule');
|
||||||
@@ -277,7 +270,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
|||||||
|
|
||||||
const service = makeService(rules, {
|
const service = makeService(rules, {
|
||||||
anthropic: { status: 'down' }, // primary is unhealthy
|
anthropic: { status: 'down' }, // primary is unhealthy
|
||||||
openai: { status: 'healthy' },
|
openai: { status: 'up' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const decision = await service.resolve('implement a function');
|
const decision = await service.resolve('implement a function');
|
||||||
@@ -297,7 +290,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const service2 = makeService(unhealthyRules, {
|
const service2 = makeService(unhealthyRules, {
|
||||||
anthropic: { status: 'healthy' },
|
anthropic: { status: 'up' },
|
||||||
openai: { status: 'down' },
|
openai: { status: 'down' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,7 +306,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
|||||||
|
|
||||||
const service = makeService(rules, {
|
const service = makeService(rules, {
|
||||||
anthropic: { status: 'down' }, // Sonnet is on anthropic — down
|
anthropic: { status: 'down' }, // Sonnet is on anthropic — down
|
||||||
ollama: { status: 'healthy' }, // Haiku is also on anthropic — use Ollama as next
|
ollama: { status: 'up' }, // Haiku is also on anthropic — use Ollama as next
|
||||||
});
|
});
|
||||||
|
|
||||||
const decision = await service.resolve('hello there');
|
const decision = await service.resolve('hello there');
|
||||||
@@ -352,7 +345,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', ()
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
const service = makeService(rules, { anthropic: { status: 'up' } });
|
||||||
|
|
||||||
const decision = await service.resolve('completely unrelated message xyz');
|
const decision = await service.resolve('completely unrelated message xyz');
|
||||||
expect(decision.ruleName).toBe('catch-all');
|
expect(decision.ruleName).toBe('catch-all');
|
||||||
@@ -376,7 +369,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', ()
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
const service = makeService(rules, { anthropic: { status: 'up' } });
|
||||||
|
|
||||||
const codingDecision = await service.resolve('implement a function');
|
const codingDecision = await service.resolve('implement a function');
|
||||||
expect(codingDecision.ruleName).toBe('specific coding rule');
|
expect(codingDecision.ruleName).toBe('specific coding rule');
|
||||||
@@ -408,7 +401,7 @@ describe('RoutingEngineService.resolve — disabled rules', () => {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
const service = makeService(rules, { anthropic: { status: 'up' } });
|
||||||
|
|
||||||
const decision = await service.resolve('implement a function');
|
const decision = await service.resolve('implement a function');
|
||||||
expect(decision.ruleName).toBe('enabled fallback');
|
expect(decision.ruleName).toBe('enabled fallback');
|
||||||
@@ -459,45 +452,9 @@ describe('RoutingEngineService.resolve — availableProviders override', () => {
|
|||||||
ps: unknown,
|
ps: unknown,
|
||||||
) => RoutingEngineService)(mockDb, mockProviderService);
|
) => RoutingEngineService)(mockDb, mockProviderService);
|
||||||
|
|
||||||
const preSupplied: Record<string, { status: ProviderHealthStatus }> = {
|
const preSupplied = { anthropic: { status: 'up' } };
|
||||||
anthropic: { status: 'healthy' },
|
|
||||||
};
|
|
||||||
await service.resolve('implement a function', undefined, preSupplied);
|
await service.resolve('implement a function', undefined, preSupplied);
|
||||||
|
|
||||||
expect(mockHealthCheckAll).not.toHaveBeenCalled();
|
expect(mockHealthCheckAll).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── resolve — canonical ProviderHealthStatus values ──────────────────────────
|
|
||||||
|
|
||||||
describe('RoutingEngineService.resolve — canonical health status routing', () => {
|
|
||||||
it('routes healthy and degraded providers by rule, and falls through to fallback when down', async () => {
|
|
||||||
const codingRule = makeRule({
|
|
||||||
name: 'coding rule',
|
|
||||||
priority: 1,
|
|
||||||
conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
|
|
||||||
action: { provider: 'openai', model: 'gpt-4o' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// healthy → selected by its own rule, not the fallback chain
|
|
||||||
const healthyService = makeService([codingRule], { openai: { status: 'healthy' } });
|
|
||||||
const healthyDecision = await healthyService.resolve('implement a function');
|
|
||||||
expect(healthyDecision.ruleName).toBe('coding rule');
|
|
||||||
expect(healthyDecision.provider).toBe('openai');
|
|
||||||
|
|
||||||
// down → rule is skipped as unroutable, falls through to the fallback chain
|
|
||||||
const downService = makeService([codingRule], {
|
|
||||||
openai: { status: 'down' },
|
|
||||||
anthropic: { status: 'healthy' },
|
|
||||||
});
|
|
||||||
const downDecision = await downService.resolve('implement a function');
|
|
||||||
expect(downDecision.ruleName).toBe('fallback');
|
|
||||||
expect(downDecision.provider).toBe('anthropic');
|
|
||||||
|
|
||||||
// degraded → still routable, selected by its own rule, not the fallback chain
|
|
||||||
const degradedService = makeService([codingRule], { openai: { status: 'degraded' } });
|
|
||||||
const degradedDecision = await degradedService.resolve('implement a function');
|
|
||||||
expect(degradedDecision.ruleName).toBe('coding rule');
|
|
||||||
expect(degradedDecision.provider).toBe('openai');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,624 +0,0 @@
|
|||||||
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -21,22 +21,11 @@ import { AdminModule } from './admin/admin.module.js';
|
|||||||
import { CommandsModule } from './commands/commands.module.js';
|
import { CommandsModule } from './commands/commands.module.js';
|
||||||
import { PreferencesModule } from './preferences/preferences.module.js';
|
import { PreferencesModule } from './preferences/preferences.module.js';
|
||||||
import { GCModule } from './gc/gc.module.js';
|
import { GCModule } from './gc/gc.module.js';
|
||||||
import { HarnessModule } from './harness/harness.module.js';
|
|
||||||
import { ReloadModule } from './reload/reload.module.js';
|
import { ReloadModule } from './reload/reload.module.js';
|
||||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||||
import { QueueModule } from './queue/queue.module.js';
|
import { QueueModule } from './queue/queue.module.js';
|
||||||
import { FederationModule } from './federation/federation.module.js';
|
import { FederationModule } from './federation/federation.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -61,11 +50,10 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
|||||||
PreferencesModule,
|
PreferencesModule,
|
||||||
CommandsModule,
|
CommandsModule,
|
||||||
GCModule,
|
GCModule,
|
||||||
HarnessModule,
|
|
||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
...(federationEnabled ? [FederationModule] : []),
|
FederationModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||||
@@ -13,7 +12,6 @@ const mockRegistry = {
|
|||||||
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
||||||
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
{ name: 'mcp', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
|
||||||
],
|
],
|
||||||
skills: [],
|
skills: [],
|
||||||
})),
|
})),
|
||||||
@@ -74,30 +72,17 @@ const mockChatGateway = {
|
|||||||
broadcastSessionInfo: vi.fn(),
|
broadcastSessionInfo: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockMcpClient = {
|
function buildService(): CommandExecutorService {
|
||||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
|
||||||
getServerStatuses: vi.fn(() => []),
|
|
||||||
getToolDefinitions: vi.fn(() => []),
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildService(
|
|
||||||
redis: typeof mockRedis | null = mockRedis,
|
|
||||||
mcpClient: {
|
|
||||||
reconnectServer: ReturnType<typeof vi.fn>;
|
|
||||||
getServerStatuses: ReturnType<typeof vi.fn>;
|
|
||||||
getToolDefinitions: ReturnType<typeof vi.fn>;
|
|
||||||
} = mockMcpClient,
|
|
||||||
): CommandExecutorService {
|
|
||||||
return new CommandExecutorService(
|
return new CommandExecutorService(
|
||||||
mockRegistry as never,
|
mockRegistry as never,
|
||||||
mockAgentService as never,
|
mockAgentService as never,
|
||||||
mockSystemOverride as never,
|
mockSystemOverride as never,
|
||||||
mockSessionGC as never,
|
mockSessionGC as never,
|
||||||
redis as never,
|
mockRedis as never,
|
||||||
mockBrain as never,
|
mockBrain as never,
|
||||||
null,
|
null,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
mcpClient as never,
|
null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,22 +131,6 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
expect(ttl).toBe(300);
|
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
|
// /provider with no args — returns usage
|
||||||
it('/provider with no args returns usage message', async () => {
|
it('/provider with no args returns usage message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||||
@@ -273,124 +242,4 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
expect(result.command).toBe('tools');
|
expect(result.command).toBe('tools');
|
||||||
expect(result.message).toContain('tools');
|
expect(result.message).toContain('tools');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Top-level catch sanitization (P3-4 re-review finding #1): a rejected
|
|
||||||
// Redis `set` inside /provider login is the only reachable path into the
|
|
||||||
// top-level catch in `execute()`. The raw exception must be logged
|
|
||||||
// server-side but never handed back to the socket client.
|
|
||||||
it('sanitizes the top-level command catch, logging the raw exception but never returning it to the client', async () => {
|
|
||||||
const distinctiveRawFailure = 'ECONNREFUSED distinctive-raw-redis-failure-token-9f31';
|
|
||||||
const rawError = new Error(distinctiveRawFailure);
|
|
||||||
const failingRedis = {
|
|
||||||
set: vi.fn().mockRejectedValue(rawError),
|
|
||||||
get: vi.fn(),
|
|
||||||
del: vi.fn(),
|
|
||||||
};
|
|
||||||
const failingService = buildService(failingRedis as unknown as typeof mockRedis);
|
|
||||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
const payload: SlashCommandPayload = {
|
|
||||||
command: 'provider',
|
|
||||||
args: 'login anthropic',
|
|
||||||
conversationId,
|
|
||||||
};
|
|
||||||
const result = await failingService.execute(payload, userScope);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.command).toBe('provider');
|
|
||||||
expect(result.message).toBe('Command failed due to an internal error.');
|
|
||||||
expect(result.message).not.toContain(distinctiveRawFailure);
|
|
||||||
expect(result.message).not.toContain('ECONNREFUSED');
|
|
||||||
|
|
||||||
// The real exception is still logged server-side, as the raw Error
|
|
||||||
// object itself (not stringified/interpolated into the log message).
|
|
||||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
|
||||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(rawError));
|
|
||||||
expect(loggedRawError).toBe(true);
|
|
||||||
|
|
||||||
loggerErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inner catch sanitization (P3-5 operator ruling): every catch in
|
|
||||||
// command-executor.service.ts that returns a SlashCommandResultPayload
|
|
||||||
// must sanitize the client-facing message the same way the top-level
|
|
||||||
// catch does, while still logging the raw exception server-side.
|
|
||||||
it('/agent new sanitizes agent-creation failures, logging the raw exception but never returning it to the client', async () => {
|
|
||||||
const marker = new Error('distinctive-agent-create-failure-token-A17f');
|
|
||||||
mockBrain.agents.create.mockRejectedValueOnce(marker);
|
|
||||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
const payload: SlashCommandPayload = {
|
|
||||||
command: 'agent',
|
|
||||||
args: 'new my-new-agent',
|
|
||||||
conversationId,
|
|
||||||
};
|
|
||||||
const result = await service.execute(payload, userScope);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.command).toBe('agent');
|
|
||||||
expect(result.message).toBe('Failed to create agent due to an internal error.');
|
|
||||||
expect(result.message).not.toContain('distinctive-agent-create-failure-token-A17f');
|
|
||||||
|
|
||||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
|
||||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
|
||||||
expect(loggedRawError).toBe(true);
|
|
||||||
|
|
||||||
loggerErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('/agent <name> switch sanitizes agent-lookup failures, logging the raw exception but never returning it to the client', async () => {
|
|
||||||
const marker = new Error('distinctive-agent-switch-failure-token-B29c');
|
|
||||||
mockBrain.agents.findByName.mockRejectedValueOnce(marker);
|
|
||||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
const payload: SlashCommandPayload = {
|
|
||||||
command: 'agent',
|
|
||||||
args: 'some-other-agent',
|
|
||||||
conversationId,
|
|
||||||
};
|
|
||||||
const result = await service.execute(payload, userScope);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.command).toBe('agent');
|
|
||||||
expect(result.message).toBe('Failed to switch agent due to an internal error.');
|
|
||||||
expect(result.message).not.toContain('distinctive-agent-switch-failure-token-B29c');
|
|
||||||
|
|
||||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
|
||||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
|
||||||
expect(loggedRawError).toBe(true);
|
|
||||||
|
|
||||||
loggerErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('/mcp reconnect sanitizes MCP client failures, logging the raw exception but never returning it to the client', async () => {
|
|
||||||
const marker = new Error('distinctive-mcp-reconnect-failure-token-C33e');
|
|
||||||
const mockMcpClient = {
|
|
||||||
reconnectServer: vi.fn().mockRejectedValue(marker),
|
|
||||||
getServerStatuses: vi.fn(() => []),
|
|
||||||
getToolDefinitions: vi.fn(() => []),
|
|
||||||
};
|
|
||||||
const mcpService = buildService(mockRedis, mockMcpClient);
|
|
||||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
const payload: SlashCommandPayload = {
|
|
||||||
command: 'mcp',
|
|
||||||
args: 'reconnect my-server',
|
|
||||||
conversationId,
|
|
||||||
};
|
|
||||||
const result = await mcpService.execute(payload, userScope);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.command).toBe('mcp');
|
|
||||||
expect(result.message).toBe(
|
|
||||||
'Failed to reconnect MCP server "my-server" due to an internal error.',
|
|
||||||
);
|
|
||||||
expect(result.message).not.toContain('distinctive-mcp-reconnect-failure-token-C33e');
|
|
||||||
|
|
||||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
|
||||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
|
||||||
expect(loggedRawError).toBe(true);
|
|
||||||
|
|
||||||
loggerErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,12 +36,6 @@ const authorization = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockMcpClient = {
|
|
||||||
getServerStatuses: vi.fn(() => []),
|
|
||||||
getToolDefinitions: vi.fn(() => []),
|
|
||||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
|
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
|
||||||
return new CommandExecutorService(
|
return new CommandExecutorService(
|
||||||
registry as never,
|
registry as never,
|
||||||
@@ -52,7 +46,7 @@ function buildExecutor(authorizationService: unknown = authorization): CommandEx
|
|||||||
{ agents: {} } as never,
|
{ agents: {} } as never,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
mockMcpClient as never,
|
null,
|
||||||
authorizationService as never,
|
authorizationService as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ export class CommandExecutorService {
|
|||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||||
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
|
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
||||||
@Optional()
|
|
||||||
@Inject(COMMANDS_REDIS)
|
|
||||||
private readonly redis: QueueHandle['redis'] | null,
|
|
||||||
@Inject(BRAIN) private readonly brain: Brain,
|
@Inject(BRAIN) private readonly brain: Brain,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(forwardRef(() => ReloadService))
|
@Inject(forwardRef(() => ReloadService))
|
||||||
@@ -34,7 +31,9 @@ export class CommandExecutorService {
|
|||||||
@Optional()
|
@Optional()
|
||||||
@Inject(forwardRef(() => ChatGateway))
|
@Inject(forwardRef(() => ChatGateway))
|
||||||
private readonly chatGateway: ChatGateway | null,
|
private readonly chatGateway: ChatGateway | null,
|
||||||
@Inject(McpClientService) private readonly mcpClient: McpClientService,
|
@Optional()
|
||||||
|
@Inject(McpClientService)
|
||||||
|
private readonly mcpClient: McpClientService | null,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(CommandAuthorizationService)
|
@Inject(CommandAuthorizationService)
|
||||||
private readonly authorization: CommandAuthorizationService | null = null,
|
private readonly authorization: CommandAuthorizationService | null = null,
|
||||||
@@ -157,13 +156,8 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Command /${command} failed`, err);
|
this.logger.error(`Command /${command} failed: ${err}`);
|
||||||
return {
|
return { command, conversationId, success: false, message: String(err) };
|
||||||
command,
|
|
||||||
conversationId,
|
|
||||||
success: false,
|
|
||||||
message: 'Command failed due to an internal error.',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,11 +333,11 @@ export class CommandExecutorService {
|
|||||||
data: { agentId: newAgent.id, agentName: newAgent.name },
|
data: { agentId: newAgent.id, agentName: newAgent.name },
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to create agent "${namePart}" for user ${userId}`, err);
|
this.logger.error(`Failed to create agent: ${err}`);
|
||||||
return {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to create agent due to an internal error.',
|
message: `Failed to create agent: ${String(err)}`,
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -394,11 +388,11 @@ export class CommandExecutorService {
|
|||||||
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
|
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to switch agent "${agentName}"`, err);
|
this.logger.error(`Failed to switch agent "${agentName}": ${err}`);
|
||||||
return {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to switch agent due to an internal error.',
|
message: `Failed to switch agent: ${String(err)}`,
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -449,16 +443,14 @@ export class CommandExecutorService {
|
|||||||
byte.toString(16).padStart(2, '0'),
|
byte.toString(16).padStart(2, '0'),
|
||||||
).join('');
|
).join('');
|
||||||
const key = `mosaic:auth:poll:${tokenHash}`;
|
const key = `mosaic:auth:poll:${tokenHash}`;
|
||||||
if (this.redis) {
|
// Persist only a short-lived token digest. The raw token is delivered only by
|
||||||
// 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.
|
||||||
// the authenticated dashboard flow, never in chat output or command metadata.
|
await this.redis.set(
|
||||||
await this.redis.set(
|
key,
|
||||||
key,
|
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
'EX',
|
||||||
'EX',
|
300,
|
||||||
300,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
success: true,
|
success: true,
|
||||||
@@ -546,6 +538,15 @@ export class CommandExecutorService {
|
|||||||
args: string | null,
|
args: string | null,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
): Promise<SlashCommandResultPayload> {
|
): Promise<SlashCommandResultPayload> {
|
||||||
|
if (!this.mcpClient) {
|
||||||
|
return {
|
||||||
|
command: 'mcp',
|
||||||
|
conversationId,
|
||||||
|
success: false,
|
||||||
|
message: 'MCP client service is not available.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const action = args?.trim().split(/\s+/)[0] ?? 'status';
|
const action = args?.trim().split(/\s+/)[0] ?? 'status';
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -602,12 +603,11 @@ export class CommandExecutorService {
|
|||||||
message: `MCP server "${serverName}" reconnected successfully.`,
|
message: `MCP server "${serverName}" reconnected successfully.`,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to reconnect MCP server "${serverName}"`, err);
|
|
||||||
return {
|
return {
|
||||||
command: 'mcp',
|
command: 'mcp',
|
||||||
conversationId,
|
conversationId,
|
||||||
success: false,
|
success: false,
|
||||||
message: `Failed to reconnect MCP server "${serverName}" due to an internal error.`,
|
message: `Failed to reconnect MCP server "${serverName}": ${err instanceof Error ? err.message : String(err)}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,6 @@
|
|||||||
* - Unknown command returns descriptive error
|
* - Unknown command returns descriptive error
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { CommandsModule } from './commands.module.js';
|
|
||||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||||
@@ -49,12 +47,6 @@ const mockBrain = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockMcpClient = {
|
|
||||||
getServerStatuses: vi.fn(() => []),
|
|
||||||
getToolDefinitions: vi.fn(() => []),
|
|
||||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildRegistry(): CommandRegistryService {
|
function buildRegistry(): CommandRegistryService {
|
||||||
@@ -73,7 +65,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
|
|||||||
mockBrain as never,
|
mockBrain as never,
|
||||||
null, // reloadService (optional)
|
null, // reloadService (optional)
|
||||||
null, // chatGateway (optional)
|
null, // chatGateway (optional)
|
||||||
mockMcpClient as never,
|
null, // mcpClient (optional)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,15 +153,6 @@ describe('CommandRegistryService — integration', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Module Wiring Tests ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('CommandsModule — Nest wiring', () => {
|
|
||||||
it('CommandsModule imports McpClientModule in its Nest metadata', () => {
|
|
||||||
const imports = Reflect.getMetadata('imports', CommandsModule) ?? [];
|
|
||||||
expect(imports).toContain(McpClientModule);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Executor Tests ───────────────────────────────────────────────────────────
|
// ─── Executor Tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('CommandExecutorService — integration', () => {
|
describe('CommandExecutorService — integration', () => {
|
||||||
@@ -276,14 +259,4 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
expect(result.command).toBe(cmd);
|
expect(result.command).toBe(cmd);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// /mcp status reaches the required McpClientService and never reports it unavailable
|
|
||||||
it('/mcp status calls the wired McpClientService and reports the no-servers message', async () => {
|
|
||||||
const payload: SlashCommandPayload = { command: 'mcp', conversationId };
|
|
||||||
const result = await executor.execute(payload, userScope);
|
|
||||||
expect(mockMcpClient.getServerStatuses).toHaveBeenCalledOnce();
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.message).toContain('No MCP servers configured.');
|
|
||||||
expect(result.message).not.toBe('MCP client service is not available.');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
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 { ChatModule } from '../chat/chat.module.js';
|
||||||
import { GCModule } from '../gc/gc.module.js';
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
|
||||||
import { ReloadModule } from '../reload/reload.module.js';
|
import { ReloadModule } from '../reload/reload.module.js';
|
||||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
@@ -15,26 +12,17 @@ import { COMMANDS_REDIS } from './commands.tokens.js';
|
|||||||
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [GCModule, forwardRef(() => ReloadModule), forwardRef(() => ChatModule)],
|
||||||
GCModule,
|
|
||||||
McpClientModule,
|
|
||||||
forwardRef(() => ReloadModule),
|
|
||||||
forwardRef(() => ChatModule),
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: COMMANDS_QUEUE_HANDLE,
|
provide: COMMANDS_QUEUE_HANDLE,
|
||||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
useFactory: (): QueueHandle => {
|
||||||
// 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();
|
return createQueue();
|
||||||
},
|
},
|
||||||
inject: [MOSAIC_CONFIG],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: COMMANDS_REDIS,
|
provide: COMMANDS_REDIS,
|
||||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
useFactory: (handle: QueueHandle) => handle.redis,
|
||||||
inject: [COMMANDS_QUEUE_HANDLE],
|
inject: [COMMANDS_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
CommandRegistryService,
|
CommandRegistryService,
|
||||||
@@ -50,13 +38,9 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CommandsModule implements OnApplicationShutdown {
|
export class CommandsModule implements OnApplicationShutdown {
|
||||||
constructor(
|
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||||
@Optional()
|
|
||||||
@Inject(COMMANDS_QUEUE_HANDLE)
|
|
||||||
private readonly handle: QueueHandle | null,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async onApplicationShutdown(): Promise<void> {
|
async onApplicationShutdown(): Promise<void> {
|
||||||
await this.handle?.close().catch(() => {});
|
await this.handle.close().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
||||||
import { resolveGatewayConfigPath } from '../env.js';
|
|
||||||
|
|
||||||
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
||||||
|
|
||||||
@@ -9,7 +8,7 @@ export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: MOSAIC_CONFIG,
|
provide: MOSAIC_CONFIG,
|
||||||
useFactory: (): MosaicConfig => loadConfig(resolveGatewayConfigPath()),
|
useFactory: (): MosaicConfig => loadConfig(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [MOSAIC_CONFIG],
|
exports: [MOSAIC_CONFIG],
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { CoordModule } from './coord.module.js';
|
|
||||||
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
|
|
||||||
describe('CoordModule DI (compiled-metadata boot)', () => {
|
|
||||||
it('resolves InteractionCoordinationService through Nest DI', async () => {
|
|
||||||
const moduleRef = await Test.createTestingModule({ imports: [CoordModule] })
|
|
||||||
.overrideGuard(AuthGuard)
|
|
||||||
.useValue({ canActivate: (): boolean => true })
|
|
||||||
.compile();
|
|
||||||
expect(moduleRef.get(InteractionCoordinationService)).toBeInstanceOf(
|
|
||||||
InteractionCoordinationService,
|
|
||||||
);
|
|
||||||
await moduleRef.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
InteractionCoordinationClient,
|
InteractionCoordinationClient,
|
||||||
type CoordinationObservation,
|
type CoordinationObservation,
|
||||||
@@ -13,7 +13,6 @@ import type { CreateHandoffDto } from './interaction-coordination.dto.js';
|
|||||||
|
|
||||||
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
||||||
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
||||||
export const HANDOFF_ID_FACTORY = Symbol('HANDOFF_ID_FACTORY');
|
|
||||||
|
|
||||||
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
||||||
const MAX_TRACKED_HANDOFFS = 1_000;
|
const MAX_TRACKED_HANDOFFS = 1_000;
|
||||||
@@ -61,8 +60,6 @@ export class InteractionCoordinationService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
||||||
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
||||||
@Optional()
|
|
||||||
@Inject(HANDOFF_ID_FACTORY)
|
|
||||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
import { config } from 'dotenv';
|
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
import { dirname, join, resolve } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { detectFromEnv, loadConfig } from '@mosaicstack/config';
|
|
||||||
|
|
||||||
type TierSource =
|
|
||||||
| 'process environment'
|
|
||||||
| 'daemon .env'
|
|
||||||
| 'monorepo-root .env'
|
|
||||||
| 'gateway-local .env'
|
|
||||||
| 'default';
|
|
||||||
|
|
||||||
type BootSource = TierSource | 'mosaic.config.json';
|
|
||||||
|
|
||||||
export interface GatewayDotenvPaths {
|
|
||||||
daemonEnv: string;
|
|
||||||
monorepoRootEnv: string;
|
|
||||||
gatewayLocalEnv: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
export function resolveGatewayDotenvPaths(
|
|
||||||
anchor: string = here,
|
|
||||||
homeBase: string = homedir(),
|
|
||||||
): GatewayDotenvPaths {
|
|
||||||
return {
|
|
||||||
daemonEnv: join(homeBase, '.config', 'mosaic', 'gateway', '.env'),
|
|
||||||
monorepoRootEnv: resolve(anchor, '../../..', '.env'),
|
|
||||||
gatewayLocalEnv: resolve(anchor, '..', '.env'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveGatewayConfigPath(anchor: string = here): string {
|
|
||||||
// GATEWAY_HOME is daemon-created 0700; its env override adds no authority because env can set MOSAIC_STORAGE_TIER.
|
|
||||||
const gatewayHome = resolve(
|
|
||||||
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
|
|
||||||
);
|
|
||||||
const daemonConfig = join(gatewayHome, 'mosaic.config.json');
|
|
||||||
const gatewayLocalConfig = resolve(anchor, '..', 'mosaic.config.json');
|
|
||||||
const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json');
|
|
||||||
|
|
||||||
if (existsSync(daemonConfig)) {
|
|
||||||
return daemonConfig;
|
|
||||||
}
|
|
||||||
if (existsSync(gatewayLocalConfig)) {
|
|
||||||
return gatewayLocalConfig;
|
|
||||||
}
|
|
||||||
if (existsSync(monorepoRootConfig)) {
|
|
||||||
return monorepoRootConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
return monorepoRootConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadGatewayEnv(anchor: string = here, homeBase: string = homedir()): void {
|
|
||||||
const { daemonEnv, monorepoRootEnv, gatewayLocalEnv } = resolveGatewayDotenvPaths(
|
|
||||||
anchor,
|
|
||||||
homeBase,
|
|
||||||
);
|
|
||||||
const inheritedTier = process.env['MOSAIC_STORAGE_TIER'];
|
|
||||||
let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment';
|
|
||||||
const inheritedDatabaseUrl = process.env['DATABASE_URL'];
|
|
||||||
let databaseUrlSource: TierSource =
|
|
||||||
inheritedDatabaseUrl === undefined ? 'default' : 'process environment';
|
|
||||||
|
|
||||||
function loadAnchoredDotenv(
|
|
||||||
path: string,
|
|
||||||
sourceLabel: Exclude<TierSource, 'process environment' | 'default'>,
|
|
||||||
): void {
|
|
||||||
if (!existsSync(path)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const beforeTier = process.env['MOSAIC_STORAGE_TIER'];
|
|
||||||
const beforeDatabaseUrl = process.env['DATABASE_URL'];
|
|
||||||
config({ path, quiet: true });
|
|
||||||
|
|
||||||
if (
|
|
||||||
beforeTier === undefined &&
|
|
||||||
process.env['MOSAIC_STORAGE_TIER'] !== undefined &&
|
|
||||||
tierSource === 'default'
|
|
||||||
) {
|
|
||||||
tierSource = sourceLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
beforeDatabaseUrl === undefined &&
|
|
||||||
process.env['DATABASE_URL'] !== undefined &&
|
|
||||||
databaseUrlSource === 'default'
|
|
||||||
) {
|
|
||||||
databaseUrlSource = sourceLabel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load .env from daemon config dir (global install / daemon mode) first.
|
|
||||||
// It takes precedence over file-based local-dev configuration.
|
|
||||||
loadAnchoredDotenv(daemonEnv, 'daemon .env');
|
|
||||||
|
|
||||||
// Load .env from the anchored monorepo root, then fill any remaining values
|
|
||||||
// from apps/gateway/.env when present.
|
|
||||||
loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env');
|
|
||||||
loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env');
|
|
||||||
|
|
||||||
const envOnlyTier = detectFromEnv().tier;
|
|
||||||
const configPath = resolveGatewayConfigPath(anchor);
|
|
||||||
const anchoredConfigExists = existsSync(configPath);
|
|
||||||
const resolvedTier = loadConfig(configPath).tier;
|
|
||||||
const configuredTier = process.env['MOSAIC_STORAGE_TIER'];
|
|
||||||
const databaseUrlDeterminesTier = envOnlyTier === 'standalone' && configuredTier !== 'standalone';
|
|
||||||
const recognizedTierDeterminesTier =
|
|
||||||
(configuredTier === 'federated' ||
|
|
||||||
configuredTier === 'standalone' ||
|
|
||||||
configuredTier === 'local') &&
|
|
||||||
configuredTier === envOnlyTier;
|
|
||||||
|
|
||||||
let source: BootSource;
|
|
||||||
if (anchoredConfigExists) {
|
|
||||||
source = 'mosaic.config.json';
|
|
||||||
} else if (databaseUrlDeterminesTier && databaseUrlSource !== 'default') {
|
|
||||||
source = databaseUrlSource;
|
|
||||||
} else if (recognizedTierDeterminesTier && tierSource !== 'default') {
|
|
||||||
source = tierSource;
|
|
||||||
} else {
|
|
||||||
source = 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadGatewayEnv();
|
|
||||||
@@ -5,8 +5,6 @@ import { EnrollmentController } from './enrollment.controller.js';
|
|||||||
import { EnrollmentService } from './enrollment.service.js';
|
import { EnrollmentService } from './enrollment.service.js';
|
||||||
import { FederationController } from './federation.controller.js';
|
import { FederationController } from './federation.controller.js';
|
||||||
import { CapabilitiesController } from './server/verbs/capabilities.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 { GrantsService } from './grants.service.js';
|
||||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
||||||
@@ -14,13 +12,7 @@ import { ListController } from './server/verbs/list.controller.js';
|
|||||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [
|
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
||||||
EnrollmentController,
|
|
||||||
FederationController,
|
|
||||||
CapabilitiesController,
|
|
||||||
ListController,
|
|
||||||
GetController,
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
AdminGuard,
|
AdminGuard,
|
||||||
CaService,
|
CaService,
|
||||||
@@ -31,7 +23,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
FederationGetQueryService,
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CaService,
|
CaService,
|
||||||
@@ -42,7 +33,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
FederationGetQueryService,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
@@ -1,348 +0,0 @@
|
|||||||
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',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,7 +1,5 @@
|
|||||||
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
|
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
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 { SessionGCService } from './session-gc.service.js';
|
||||||
import { REDIS } from './gc.tokens.js';
|
import { REDIS } from './gc.tokens.js';
|
||||||
|
|
||||||
@@ -11,17 +9,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: GC_QUEUE_HANDLE,
|
provide: GC_QUEUE_HANDLE,
|
||||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
useFactory: (): QueueHandle => {
|
||||||
// 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();
|
return createQueue();
|
||||||
},
|
},
|
||||||
inject: [MOSAIC_CONFIG],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: REDIS,
|
provide: REDIS,
|
||||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
useFactory: (handle: QueueHandle) => handle.redis,
|
||||||
inject: [GC_QUEUE_HANDLE],
|
inject: [GC_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
SessionGCService,
|
SessionGCService,
|
||||||
@@ -29,13 +23,9 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
|||||||
exports: [SessionGCService],
|
exports: [SessionGCService],
|
||||||
})
|
})
|
||||||
export class GCModule implements OnApplicationShutdown {
|
export class GCModule implements OnApplicationShutdown {
|
||||||
constructor(
|
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||||
@Optional()
|
|
||||||
@Inject(GC_QUEUE_HANDLE)
|
|
||||||
private readonly handle: QueueHandle | null,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async onApplicationShutdown(): Promise<void> {
|
async onApplicationShutdown(): Promise<void> {
|
||||||
await this.handle?.close().catch(() => {});
|
await this.handle.close().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,19 +119,6 @@ describe('SessionGCService', () => {
|
|||||||
).resolves.toEqual({ allowed: true });
|
).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 () => {
|
it('collect() returns sessionId in result', async () => {
|
||||||
const result = await service.collect('test-session-id');
|
const result = await service.collect('test-session-id');
|
||||||
expect(result.sessionId).toBe('test-session-id');
|
expect(result.sessionId).toBe('test-session-id');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { QueueHandle } from '@mosaicstack/queue';
|
import type { QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { LogService } from '@mosaicstack/log';
|
import type { LogService } from '@mosaicstack/log';
|
||||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||||
@@ -21,10 +21,7 @@ function escapeRedisGlobLiteral(value: string): string {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionGCService {
|
export class SessionGCService {
|
||||||
constructor(
|
constructor(
|
||||||
// Local tier has no Redis; lifecycle cleanup still demotes this session's logs.
|
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||||
@Optional()
|
|
||||||
@Inject(REDIS)
|
|
||||||
private readonly redis: QueueHandle['redis'] | null,
|
|
||||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -32,10 +29,8 @@ export class SessionGCService {
|
|||||||
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
|
* 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
|
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
||||||
* duration, which can cause latency spikes under production key volumes.
|
* 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[]> {
|
private async scanKeys(pattern: string): Promise<string[]> {
|
||||||
if (!this.redis) return [];
|
|
||||||
const collected: string[] = [];
|
const collected: string[] = [];
|
||||||
let cursor = '0';
|
let cursor = '0';
|
||||||
do {
|
do {
|
||||||
@@ -52,14 +47,12 @@ export class SessionGCService {
|
|||||||
async collect(sessionId: string): Promise<GCResult> {
|
async collect(sessionId: string): Promise<GCResult> {
|
||||||
const result: GCResult = { sessionId, cleaned: {} };
|
const result: GCResult = { sessionId, cleaned: {} };
|
||||||
|
|
||||||
// 1. Valkey: delete all session-scoped keys (skipped on Local tier).
|
// 1. Valkey: delete all session-scoped keys
|
||||||
if (this.redis) {
|
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
const valkeyKeys = await this.scanKeys(pattern);
|
||||||
const valkeyKeys = await this.scanKeys(pattern);
|
if (valkeyKeys.length > 0) {
|
||||||
if (valkeyKeys.length > 0) {
|
await this.redis.del(...valkeyKeys);
|
||||||
await this.redis.del(...valkeyKeys);
|
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. PG: demote hot-tier agent logs for this session only.
|
// 2. PG: demote hot-tier agent logs for this session only.
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
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 } from 'vitest';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { HarnessRegistry } from './harness.registry.js';
|
|
||||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
|
||||||
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
|
||||||
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
|
||||||
// Import the REAL module (not a hand-listed controllers+mocks list) so an
|
|
||||||
// unresolved provider fails at app.init() — the #1145-class DI-boot guard.
|
|
||||||
import { HarnessModule } from './harness.module.js';
|
|
||||||
|
|
||||||
// A known-available tuple from the fake adapter's default catalog.
|
|
||||||
const VALID = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-mini' };
|
|
||||||
// A tuple whose provider/model are not in any catalog.
|
|
||||||
const UNKNOWN = { harnessId: 'fake', providerId: 'ghost-provider', modelId: 'ghost-model' };
|
|
||||||
// A tuple that is known in the catalog but flagged unavailable.
|
|
||||||
const UNAVAILABLE = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-legacy' };
|
|
||||||
|
|
||||||
const authGuard: CanActivate = {
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
|
||||||
requestContext.user = { id: 'user-1' };
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function registryWithFake(): HarnessRegistry {
|
|
||||||
const registry = new HarnessRegistry();
|
|
||||||
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
|
||||||
return registry;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Harness selection HTTP surface', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let repository: HarnessSelectionRepository;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
imports: [HarnessModule],
|
|
||||||
})
|
|
||||||
.overrideGuard(AuthGuard)
|
|
||||||
.useValue(authGuard)
|
|
||||||
.overrideProvider(HARNESS_REGISTRY)
|
|
||||||
.useValue(registryWithFake())
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
// Real in-memory repository from the module graph — proves the module wired it.
|
|
||||||
repository = moduleRef.get(HarnessSelectionRepository);
|
|
||||||
|
|
||||||
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(() => {
|
|
||||||
// Reset owner-scoped state between tests via the public API surface.
|
|
||||||
repository.set({ userId: 'user-1', tenantId: 'user-1' }, VALID);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET selection is server-scoped and ignores caller-supplied scope in the query', async () => {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.get('/api/chat/preferences/selection')
|
|
||||||
.query({ userId: 'attacker', tenantId: 'attacker-tenant', seatId: 'attacker-seat' });
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
// The returned selection is user-1's (guard-derived scope), not the query's.
|
|
||||||
expect(response.body.selection).toEqual(VALID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PUT with a valid structured tuple persists and round-trips via GET', async () => {
|
|
||||||
const next = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-pro' };
|
|
||||||
|
|
||||||
const put = await request(app.getHttpServer())
|
|
||||||
.put('/api/chat/preferences/selection')
|
|
||||||
.send(next)
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
expect(put.status).toBe(200);
|
|
||||||
expect(put.body.selection).toEqual(next);
|
|
||||||
|
|
||||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
|
||||||
expect(get.status).toBe(200);
|
|
||||||
expect(get.body.selection).toEqual(next);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PUT with FREE TEXT is rejected 400 and does not mutate the stored selection', async () => {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.put('/api/chat/preferences/selection')
|
|
||||||
.send({ selection: 'gpt-4o' })
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
|
|
||||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
|
||||||
expect(get.body.selection).toEqual(VALID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['seatId', { ...VALID, seatId: 'attacker-seat' }],
|
|
||||||
['tenantId', { ...VALID, tenantId: 'attacker-tenant' }],
|
|
||||||
['userId', { ...VALID, userId: 'attacker' }],
|
|
||||||
['nativeSessionPath', { ...VALID, nativeSessionPath: '/var/native/x.jsonl' }],
|
|
||||||
['executable', { ...VALID, executable: '/usr/bin/evil' }],
|
|
||||||
['home', { ...VALID, home: '/home/attacker' }],
|
|
||||||
['cwd', { ...VALID, cwd: '/tmp/attacker' }],
|
|
||||||
])(
|
|
||||||
'PUT with an extra authority-bearing field (%s) is rejected 400 and does not mutate stored selection',
|
|
||||||
async (_name, body) => {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.put('/api/chat/preferences/selection')
|
|
||||||
.send(body)
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
|
|
||||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
|
||||||
expect(get.body.selection).toEqual(VALID);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('PUT with an UNKNOWN tuple returns selection_invalid, unchanged and echoed unchanged (no fallback)', async () => {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.put('/api/chat/preferences/selection')
|
|
||||||
.send(UNKNOWN)
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(response.status).toBe(422);
|
|
||||||
expect(response.body.code).toBe('selection_invalid');
|
|
||||||
// Echoed back unchanged: no first-row / first-provider substitution.
|
|
||||||
expect(response.body.selection).toEqual(UNKNOWN);
|
|
||||||
|
|
||||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
|
||||||
expect(get.body.selection).toEqual(VALID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PUT with a KNOWN-but-UNAVAILABLE tuple returns model_unavailable, unchanged (distinct from selection_invalid)', async () => {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.put('/api/chat/preferences/selection')
|
|
||||||
.send(UNAVAILABLE)
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(response.status).toBe(422);
|
|
||||||
expect(response.body.code).toBe('model_unavailable');
|
|
||||||
expect(response.body.selection).toEqual(UNAVAILABLE);
|
|
||||||
|
|
||||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
|
||||||
expect(get.body.selection).toEqual(VALID);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Body, Controller, Get, HttpException, HttpStatus, Put, UseGuards } from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
|
||||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
|
||||||
import { HarnessOperationError } from './harness.registry.js';
|
|
||||||
import { HarnessSelectionService } from './harness-selection.service.js';
|
|
||||||
import { HarnessSelectionInputDto, type SelectionResponseDto } from './harness.dto.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chat-preferences selection surface. The scope is ALWAYS derived on the server
|
|
||||||
* from the authenticated user (`scopeFromUser(CurrentUser)`); the request body and
|
|
||||||
* query string can never name another user, tenant, or seat. A typed selection
|
|
||||||
* failure (unknown tuple → `selection_invalid`, known-but-unavailable →
|
|
||||||
* `model_unavailable`) is returned as 422 with the requested tuple echoed back
|
|
||||||
* unchanged, and never mutates the stored selection.
|
|
||||||
*/
|
|
||||||
@Controller('api/chat/preferences/selection')
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
export class HarnessSelectionController {
|
|
||||||
constructor(private readonly selection: HarnessSelectionService) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
get(@CurrentUser() user: AuthenticatedUserLike): SelectionResponseDto {
|
|
||||||
return { selection: this.selection.getSelection(scopeFromUser(user)) };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put()
|
|
||||||
async put(
|
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
|
||||||
@Body() dto: HarnessSelectionInputDto,
|
|
||||||
): Promise<SelectionResponseDto> {
|
|
||||||
try {
|
|
||||||
const stored = await this.selection.setSelection(scopeFromUser(user), {
|
|
||||||
harnessId: dto.harnessId,
|
|
||||||
providerId: dto.providerId,
|
|
||||||
modelId: dto.modelId,
|
|
||||||
});
|
|
||||||
return { selection: stored };
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof HarnessOperationError) {
|
|
||||||
throw new HttpException(error.dto, HttpStatus.UNPROCESSABLE_ENTITY);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1,90 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import type { HarnessSelection } from '@mosaicstack/types';
|
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
|
||||||
import {
|
|
||||||
HarnessAdapterUnavailableError,
|
|
||||||
HarnessRegistry,
|
|
||||||
operationError,
|
|
||||||
} from './harness.registry.js';
|
|
||||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
|
||||||
import { readContextFromScope } from './harness.dto.js';
|
|
||||||
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Selection logic for the Slice-Zero chat-preferences surface. It validates the
|
|
||||||
* requested harness/provider/model tuple against the live catalog with NO
|
|
||||||
* fallback substitution, then persists it owner-scoped. The stored selection is
|
|
||||||
* only ever mutated when the tuple is valid AND available.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class HarnessSelectionService {
|
|
||||||
constructor(
|
|
||||||
@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry,
|
|
||||||
private readonly repository: HarnessSelectionRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
getSelection(scope: ActorTenantScope): HarnessSelection | null {
|
|
||||||
return this.repository.get(scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setSelection(
|
|
||||||
scope: ActorTenantScope,
|
|
||||||
selection: HarnessSelection,
|
|
||||||
): Promise<HarnessSelection> {
|
|
||||||
// Throws HarnessOperationError (selection_invalid / model_unavailable) with the
|
|
||||||
// requested tuple echoed back unchanged. The store is untouched on any throw.
|
|
||||||
await this.assertSelectionAvailable(scope, selection);
|
|
||||||
return this.repository.set(scope, selection);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertSelectionAvailable(
|
|
||||||
scope: ActorTenantScope,
|
|
||||||
selection: HarnessSelection,
|
|
||||||
): Promise<void> {
|
|
||||||
const correlationId = randomUUID();
|
|
||||||
|
|
||||||
let adapter;
|
|
||||||
try {
|
|
||||||
adapter = this.registry.get(selection.harnessId);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof HarnessAdapterUnavailableError) {
|
|
||||||
// An unknown harness makes the whole tuple invalid — no fallback adapter.
|
|
||||||
throw operationError(
|
|
||||||
'selection_invalid',
|
|
||||||
'The requested harness/provider/model tuple is not in the catalog.',
|
|
||||||
selection,
|
|
||||||
correlationId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const catalog = await adapter.catalog(readContextFromScope(scope));
|
|
||||||
const entry = catalog.models.find(
|
|
||||||
(candidate) =>
|
|
||||||
candidate.harnessId === selection.harnessId &&
|
|
||||||
candidate.providerId === selection.providerId &&
|
|
||||||
candidate.modelId === selection.modelId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!entry) {
|
|
||||||
// No first-row / first-provider fallback: reject the requested tuple unchanged.
|
|
||||||
throw operationError(
|
|
||||||
'selection_invalid',
|
|
||||||
'The requested harness/provider/model tuple is not in the catalog.',
|
|
||||||
selection,
|
|
||||||
correlationId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (entry.availability === 'unavailable') {
|
|
||||||
throw operationError(
|
|
||||||
'model_unavailable',
|
|
||||||
'The requested model is currently unavailable.',
|
|
||||||
selection,
|
|
||||||
correlationId,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
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, describe, expect, it } from 'vitest';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { HarnessRegistry } from './harness.registry.js';
|
|
||||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
|
||||||
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
|
||||||
// The real module under test — importing it (not a hand-listed controllers/mocks
|
|
||||||
// list) is what makes an unresolved provider fail loudly at app.init() (#1145 guard).
|
|
||||||
import { HarnessModule } from './harness.module.js';
|
|
||||||
|
|
||||||
// Fields that must NEVER surface on a browser-facing catalog/list response.
|
|
||||||
const FORBIDDEN_KEYS = [
|
|
||||||
'executable',
|
|
||||||
'executablePath',
|
|
||||||
'home',
|
|
||||||
'homeDir',
|
|
||||||
'cwd',
|
|
||||||
'workingDir',
|
|
||||||
'workingDirectory',
|
|
||||||
'nativeSessionPath',
|
|
||||||
'sessionPath',
|
|
||||||
'env',
|
|
||||||
'secret',
|
|
||||||
'secrets',
|
|
||||||
'token',
|
|
||||||
'apiKey',
|
|
||||||
];
|
|
||||||
|
|
||||||
function assertNoForbiddenLeak(payload: unknown): void {
|
|
||||||
const serialized = JSON.stringify(payload).toLowerCase();
|
|
||||||
for (const key of FORBIDDEN_KEYS) {
|
|
||||||
expect(serialized).not.toContain(key.toLowerCase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const authGuard: CanActivate = {
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
|
||||||
requestContext.user = { id: 'user-1' };
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function registryWithFake(): HarnessRegistry {
|
|
||||||
const registry = new HarnessRegistry();
|
|
||||||
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
|
||||||
return registry;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Harness catalog HTTP surface', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
imports: [HarnessModule],
|
|
||||||
})
|
|
||||||
.overrideGuard(AuthGuard)
|
|
||||||
.useValue(authGuard)
|
|
||||||
.overrideProvider(HARNESS_REGISTRY)
|
|
||||||
.useValue(registryWithFake())
|
|
||||||
.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();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('boots the real HarnessModule so all providers resolve at app.init()', () => {
|
|
||||||
// If HarnessModule failed to resolve a provider, beforeAll's app.init() would
|
|
||||||
// have thrown and this suite would never reach here.
|
|
||||||
expect(app).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/harnesses returns 200 with safe fields only', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/harnesses');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(Array.isArray(response.body)).toBe(true);
|
|
||||||
expect(response.body.length).toBeGreaterThan(0);
|
|
||||||
const summary = response.body[0];
|
|
||||||
expect(Object.keys(summary).sort()).toEqual(['capabilities', 'displayName', 'id']);
|
|
||||||
expect(summary.id).toBe('fake');
|
|
||||||
expect(typeof summary.displayName).toBe('string');
|
|
||||||
expect(Array.isArray(summary.capabilities)).toBe(true);
|
|
||||||
assertNoForbiddenLeak(response.body);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/harnesses/:harnessId/catalog returns 200 with safe catalog fields only', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/harnesses/fake/catalog');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body.harnessId).toBe('fake');
|
|
||||||
expect(typeof response.body.version).toBe('string');
|
|
||||||
expect(typeof response.body.fingerprint).toBe('string');
|
|
||||||
expect(Array.isArray(response.body.models)).toBe(true);
|
|
||||||
expect(response.body.models.length).toBeGreaterThan(0);
|
|
||||||
const entry = response.body.models[0];
|
|
||||||
// Whitelisted catalog-entry fields only (no executables/paths/secrets).
|
|
||||||
expect(Object.keys(entry).sort()).toEqual(
|
|
||||||
[
|
|
||||||
'authState',
|
|
||||||
'availability',
|
|
||||||
'displayName',
|
|
||||||
'harnessId',
|
|
||||||
'inputTypes',
|
|
||||||
'modelId',
|
|
||||||
'providerId',
|
|
||||||
'reasoningCapability',
|
|
||||||
].sort(),
|
|
||||||
);
|
|
||||||
assertNoForbiddenLeak(response.body);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET catalog for an unknown harnessId returns a typed adapter_unavailable error, never a fallback catalog', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/harnesses/ghost-harness/catalog');
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
expect(response.body.code).toBe('adapter_unavailable');
|
|
||||||
// A fallback catalog would carry a models array; a typed error must not.
|
|
||||||
expect(response.body.models).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
HttpException,
|
|
||||||
HttpStatus,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
|
||||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
|
||||||
import { HarnessAdapterUnavailableError, HarnessRegistry } from './harness.registry.js';
|
|
||||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
|
||||||
import {
|
|
||||||
readContextFromScope,
|
|
||||||
toHarnessSummary,
|
|
||||||
toSafeCatalog,
|
|
||||||
type HarnessCatalogDto,
|
|
||||||
type HarnessSummaryDto,
|
|
||||||
} from './harness.dto.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic harness catalog surface. It exposes only harness-neutral, browser-safe
|
|
||||||
* fields (identity, capabilities, provider/model catalog) — never executables,
|
|
||||||
* native paths, home/cwd, env, or secrets. There is NO provider-probe route here;
|
|
||||||
* `/api/providers` and `POST /api/providers/test` are intentionally out of scope.
|
|
||||||
*/
|
|
||||||
@Controller('api/harnesses')
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
export class HarnessController {
|
|
||||||
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
async list(@CurrentUser() user: AuthenticatedUserLike): Promise<HarnessSummaryDto[]> {
|
|
||||||
const context = readContextFromScope(scopeFromUser(user));
|
|
||||||
const summaries: HarnessSummaryDto[] = [];
|
|
||||||
for (const adapter of this.registry.list()) {
|
|
||||||
summaries.push(toHarnessSummary(await adapter.describe(context)));
|
|
||||||
}
|
|
||||||
return summaries;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':harnessId/catalog')
|
|
||||||
async catalog(
|
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
|
||||||
@Param('harnessId') harnessId: string,
|
|
||||||
): Promise<HarnessCatalogDto> {
|
|
||||||
const context = readContextFromScope(scopeFromUser(user));
|
|
||||||
let adapter;
|
|
||||||
try {
|
|
||||||
adapter = this.registry.get(harnessId);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof HarnessAdapterUnavailableError) {
|
|
||||||
// Typed failure — NEVER a fallback catalog for an unknown harness id.
|
|
||||||
throw new HttpException(
|
|
||||||
{ code: error.code, message: error.message, harnessId },
|
|
||||||
HttpStatus.NOT_FOUND,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return toSafeCatalog(await adapter.catalog(context));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { IsNotEmpty, IsString } from 'class-validator';
|
|
||||||
import type {
|
|
||||||
HarnessActorContext,
|
|
||||||
HarnessAuthState,
|
|
||||||
HarnessCapability,
|
|
||||||
HarnessCatalog,
|
|
||||||
HarnessCatalogEntry,
|
|
||||||
HarnessDescriptor,
|
|
||||||
HarnessInputType,
|
|
||||||
HarnessModelAvailability,
|
|
||||||
HarnessSelection,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Structured selection tuple accepted on `PUT /api/chat/preferences/selection`.
|
|
||||||
*
|
|
||||||
* The body is a STRUCTURED tuple (harness + provider + model), never a free-text
|
|
||||||
* model string. With `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })`
|
|
||||||
* any extra property — including smuggled server-authority fields such as
|
|
||||||
* `seatId`, `tenantId`, `userId`, `nativeSessionPath`, `executable`, `home`, `cwd` —
|
|
||||||
* is rejected with 400. There is deliberately no field through which a caller can
|
|
||||||
* name a scope; scope is derived on the server from the authenticated session.
|
|
||||||
*/
|
|
||||||
export class HarnessSelectionInputDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
harnessId!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
providerId!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
modelId!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Browser-safe harness summary — identity and capabilities only. */
|
|
||||||
export interface HarnessSummaryDto {
|
|
||||||
readonly id: string;
|
|
||||||
readonly displayName: string;
|
|
||||||
readonly capabilities: readonly HarnessCapability[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Browser-safe catalog entry — no executables, paths, secrets, or env. */
|
|
||||||
export interface HarnessCatalogEntryDto {
|
|
||||||
readonly harnessId: string;
|
|
||||||
readonly providerId: string;
|
|
||||||
readonly modelId: string;
|
|
||||||
readonly displayName: string;
|
|
||||||
readonly reasoningCapability: boolean;
|
|
||||||
readonly inputTypes: readonly HarnessInputType[];
|
|
||||||
readonly authState: HarnessAuthState;
|
|
||||||
readonly availability: HarnessModelAvailability;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Browser-safe catalog envelope. */
|
|
||||||
export interface HarnessCatalogDto {
|
|
||||||
readonly harnessId: string;
|
|
||||||
readonly version: string;
|
|
||||||
readonly fingerprint: string;
|
|
||||||
readonly models: readonly HarnessCatalogEntryDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Response envelope for the caller's current selection (null when unset). */
|
|
||||||
export interface SelectionResponseDto {
|
|
||||||
readonly selection: HarnessSelection | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive a server-trusted {@link HarnessActorContext} for read operations from the
|
|
||||||
* session-derived {@link ActorTenantScope}. All authority originates on the server;
|
|
||||||
* nothing here is caller-supplied. A fresh correlation id is minted per call.
|
|
||||||
*/
|
|
||||||
export function readContextFromScope(scope: ActorTenantScope): HarnessActorContext {
|
|
||||||
return {
|
|
||||||
actorId: scope.userId,
|
|
||||||
tenantId: scope.tenantId,
|
|
||||||
seatId: scope.userId,
|
|
||||||
correlationId: randomUUID(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Project a descriptor onto the browser-safe summary shape (whitelist by construction). */
|
|
||||||
export function toHarnessSummary(descriptor: HarnessDescriptor): HarnessSummaryDto {
|
|
||||||
return {
|
|
||||||
id: descriptor.id,
|
|
||||||
displayName: descriptor.displayName,
|
|
||||||
capabilities: [...descriptor.capabilities],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Project a catalog onto the browser-safe shape (whitelist by construction). */
|
|
||||||
export function toSafeCatalog(catalog: HarnessCatalog): HarnessCatalogDto {
|
|
||||||
return {
|
|
||||||
harnessId: catalog.harnessId,
|
|
||||||
version: catalog.version,
|
|
||||||
fingerprint: catalog.fingerprint,
|
|
||||||
models: catalog.models.map(toSafeCatalogEntry),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toSafeCatalogEntry(entry: HarnessCatalogEntry): HarnessCatalogEntryDto {
|
|
||||||
return {
|
|
||||||
harnessId: entry.harnessId,
|
|
||||||
providerId: entry.providerId,
|
|
||||||
modelId: entry.modelId,
|
|
||||||
displayName: entry.displayName,
|
|
||||||
reasoningCapability: entry.reasoningCapability,
|
|
||||||
inputTypes: [...entry.inputTypes],
|
|
||||||
authState: entry.authState,
|
|
||||||
availability: entry.availability,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { HarnessRegistry } from './harness.registry.js';
|
|
||||||
import { HarnessService } from './harness.service.js';
|
|
||||||
import { HARNESS_REGISTRY, HARNESS_SERVICE } from './harness.tokens.js';
|
|
||||||
import { HarnessController } from './harness.controller.js';
|
|
||||||
import { HarnessSelectionController } from './harness-selection.controller.js';
|
|
||||||
import { HarnessSelectionService } from './harness-selection.service.js';
|
|
||||||
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wires the harness-neutral registry/service (Task Two) together with the
|
|
||||||
* Slice-Zero catalog and selection HTTP surfaces (Task Three).
|
|
||||||
*
|
|
||||||
* The registry is provided empty here; real harness adapters are registered in a
|
|
||||||
* later task. Because the controllers/services resolve their collaborators through
|
|
||||||
* this real module graph, an unresolved provider fails loudly at `app.init()`.
|
|
||||||
*/
|
|
||||||
@Module({
|
|
||||||
controllers: [HarnessController, HarnessSelectionController],
|
|
||||||
providers: [
|
|
||||||
{ provide: HARNESS_REGISTRY, useFactory: () => new HarnessRegistry() },
|
|
||||||
{ provide: HARNESS_SERVICE, useClass: HarnessService },
|
|
||||||
HarnessSelectionRepository,
|
|
||||||
HarnessSelectionService,
|
|
||||||
],
|
|
||||||
exports: [HARNESS_REGISTRY, HARNESS_SERVICE],
|
|
||||||
})
|
|
||||||
export class HarnessModule {}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
HarnessAdapterUnavailableError,
|
|
||||||
HarnessRegistrationError,
|
|
||||||
HarnessRegistry,
|
|
||||||
} from './harness.registry.js';
|
|
||||||
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
|
||||||
|
|
||||||
describe('HarnessRegistry', () => {
|
|
||||||
it('registers and looks up an adapter by harness id', () => {
|
|
||||||
const registry = new HarnessRegistry();
|
|
||||||
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
|
||||||
|
|
||||||
registry.register(adapter);
|
|
||||||
|
|
||||||
expect(registry.get('fake')).toBe(adapter);
|
|
||||||
expect(registry.has('fake')).toBe(true);
|
|
||||||
expect(registry.list().map((entry) => entry.id)).toEqual(['fake']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a blank adapter id', () => {
|
|
||||||
const registry = new HarnessRegistry();
|
|
||||||
|
|
||||||
let error: unknown;
|
|
||||||
try {
|
|
||||||
registry.register(new FakeHarnessAdapter({ id: ' ' }));
|
|
||||||
} catch (caught) {
|
|
||||||
error = caught;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(HarnessRegistrationError);
|
|
||||||
expect((error as HarnessRegistrationError).reason).toBe('blank_id');
|
|
||||||
expect(registry.list()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a duplicate adapter id', () => {
|
|
||||||
const registry = new HarnessRegistry();
|
|
||||||
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
|
||||||
|
|
||||||
let error: unknown;
|
|
||||||
try {
|
|
||||||
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
|
||||||
} catch (caught) {
|
|
||||||
error = caught;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(HarnessRegistrationError);
|
|
||||||
expect((error as HarnessRegistrationError).reason).toBe('duplicate_id');
|
|
||||||
expect((error as HarnessRegistrationError).harnessId).toBe('fake');
|
|
||||||
// The original registration is untouched.
|
|
||||||
expect(registry.list()).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns adapter_unavailable for an unknown harness id', () => {
|
|
||||||
const registry = new HarnessRegistry();
|
|
||||||
|
|
||||||
let error: unknown;
|
|
||||||
try {
|
|
||||||
registry.get('missing');
|
|
||||||
} catch (caught) {
|
|
||||||
error = caught;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(HarnessAdapterUnavailableError);
|
|
||||||
expect((error as HarnessAdapterUnavailableError).code).toBe('adapter_unavailable');
|
|
||||||
expect((error as HarnessAdapterUnavailableError).harnessId).toBe('missing');
|
|
||||||
expect(registry.has('missing')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import type {
|
|
||||||
HarnessAdapter,
|
|
||||||
HarnessErrorCode,
|
|
||||||
HarnessErrorDto,
|
|
||||||
HarnessSelection,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A typed harness operation failure that carries a fully-formed, browser-safe
|
|
||||||
* {@link HarnessErrorDto}. The DTO's `selection` is always the exact requested
|
|
||||||
* tuple — there is no field through which a substituted "effective" selection
|
|
||||||
* could ever be reported.
|
|
||||||
*/
|
|
||||||
export class HarnessOperationError extends Error {
|
|
||||||
readonly code: HarnessErrorCode;
|
|
||||||
readonly dto: HarnessErrorDto;
|
|
||||||
|
|
||||||
constructor(dto: HarnessErrorDto) {
|
|
||||||
super(dto.message);
|
|
||||||
this.name = 'HarnessOperationError';
|
|
||||||
this.code = dto.code;
|
|
||||||
this.dto = dto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a {@link HarnessOperationError} that echoes the requested selection unchanged. */
|
|
||||||
export function operationError(
|
|
||||||
code: HarnessErrorCode,
|
|
||||||
message: string,
|
|
||||||
selection: HarnessSelection,
|
|
||||||
correlationId: string,
|
|
||||||
retryable = false,
|
|
||||||
): HarnessOperationError {
|
|
||||||
return new HarnessOperationError({ code, message, retryable, correlationId, selection });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Raised when an unknown harness id is looked up. Discriminated by `code`. */
|
|
||||||
export class HarnessAdapterUnavailableError extends Error {
|
|
||||||
readonly code = 'adapter_unavailable' as const satisfies HarnessErrorCode;
|
|
||||||
|
|
||||||
constructor(readonly harnessId: string) {
|
|
||||||
super(`No harness adapter is registered for id "${harnessId}".`);
|
|
||||||
this.name = 'HarnessAdapterUnavailableError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type HarnessRegistrationFailure = 'blank_id' | 'duplicate_id';
|
|
||||||
|
|
||||||
/** Raised when an adapter cannot be registered (blank or duplicate id). */
|
|
||||||
export class HarnessRegistrationError extends Error {
|
|
||||||
constructor(
|
|
||||||
readonly reason: HarnessRegistrationFailure,
|
|
||||||
readonly harnessId: string,
|
|
||||||
) {
|
|
||||||
super(
|
|
||||||
reason === 'blank_id'
|
|
||||||
? 'A harness adapter id must be a non-empty string.'
|
|
||||||
: `A harness adapter is already registered for id "${harnessId}".`,
|
|
||||||
);
|
|
||||||
this.name = 'HarnessRegistrationError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Harness-neutral adapter registry. Adapters are keyed by their harness id.
|
|
||||||
* Registration rejects blank and duplicate ids; lookup of an unknown id fails
|
|
||||||
* with {@link HarnessAdapterUnavailableError} (`adapter_unavailable`).
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class HarnessRegistry {
|
|
||||||
private readonly adapters = new Map<string, HarnessAdapter>();
|
|
||||||
|
|
||||||
register(adapter: HarnessAdapter): void {
|
|
||||||
const id = adapter.id;
|
|
||||||
if (typeof id !== 'string' || id.trim().length === 0) {
|
|
||||||
throw new HarnessRegistrationError('blank_id', id ?? '');
|
|
||||||
}
|
|
||||||
if (this.adapters.has(id)) {
|
|
||||||
throw new HarnessRegistrationError('duplicate_id', id);
|
|
||||||
}
|
|
||||||
this.adapters.set(id, adapter);
|
|
||||||
}
|
|
||||||
|
|
||||||
get(harnessId: string): HarnessAdapter {
|
|
||||||
const adapter = this.adapters.get(harnessId);
|
|
||||||
if (!adapter) {
|
|
||||||
throw new HarnessAdapterUnavailableError(harnessId);
|
|
||||||
}
|
|
||||||
return adapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
has(harnessId: string): boolean {
|
|
||||||
return this.adapters.has(harnessId);
|
|
||||||
}
|
|
||||||
|
|
||||||
list(): readonly HarnessAdapter[] {
|
|
||||||
return [...this.adapters.values()];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { HarnessActorContext, HarnessCapability, HarnessSelection } from '@mosaicstack/types';
|
|
||||||
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
|
|
||||||
import { HarnessOperationError, HarnessRegistry } from './harness.registry.js';
|
|
||||||
import {
|
|
||||||
HarnessScopeViolationError,
|
|
||||||
HarnessService,
|
|
||||||
type TrustedGatewayScope,
|
|
||||||
} from './harness.service.js';
|
|
||||||
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
|
||||||
|
|
||||||
const SCOPE: TrustedGatewayScope = {
|
|
||||||
actorId: 'actor-trusted',
|
|
||||||
tenantId: 'tenant-trusted',
|
|
||||||
seatId: 'seat-trusted',
|
|
||||||
correlationId: 'correlation-trusted',
|
|
||||||
};
|
|
||||||
|
|
||||||
const READ_CONTEXT: HarnessActorContext = {
|
|
||||||
actorId: SCOPE.actorId,
|
|
||||||
tenantId: SCOPE.tenantId,
|
|
||||||
seatId: SCOPE.seatId,
|
|
||||||
correlationId: SCOPE.correlationId,
|
|
||||||
};
|
|
||||||
|
|
||||||
function setup(capabilities?: readonly HarnessCapability[]) {
|
|
||||||
const registry = new HarnessRegistry();
|
|
||||||
const adapter = new FakeHarnessAdapter({ id: 'fake', capabilities });
|
|
||||||
registry.register(adapter);
|
|
||||||
const service = new HarnessService(registry);
|
|
||||||
return { registry, adapter, service };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function availableSelection(adapter: FakeHarnessAdapter): Promise<HarnessSelection> {
|
|
||||||
const catalog = await adapter.catalog(READ_CONTEXT);
|
|
||||||
const entry = catalog.models.find((model) => model.availability === 'available');
|
|
||||||
if (!entry) {
|
|
||||||
throw new Error('fixture requires an available model');
|
|
||||||
}
|
|
||||||
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('HarnessService', () => {
|
|
||||||
it('derives the actor context from trusted scope on create', async () => {
|
|
||||||
const { service, adapter } = setup();
|
|
||||||
const selection = await availableSelection(adapter);
|
|
||||||
|
|
||||||
const snapshot = await service.createSession(SCOPE, {
|
|
||||||
conversationId: 'conversation-1',
|
|
||||||
selection,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(snapshot.seatId).toBe(SCOPE.seatId);
|
|
||||||
expect(snapshot.state).toBe('idle');
|
|
||||||
expect(snapshot.selection).toEqual(selection);
|
|
||||||
expect(snapshot.nativeSessionId).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects server-authority fields supplied by an external caller', async () => {
|
|
||||||
const { service, adapter } = setup();
|
|
||||||
const selection = await availableSelection(adapter);
|
|
||||||
|
|
||||||
const hostile = {
|
|
||||||
conversationId: 'conversation-1',
|
|
||||||
selection,
|
|
||||||
seatId: 'attacker-seat',
|
|
||||||
executablePath: '/usr/bin/evil',
|
|
||||||
home: '/home/attacker',
|
|
||||||
cwd: '/tmp/attacker',
|
|
||||||
nativeSessionPath: '/var/native/attacker.jsonl',
|
|
||||||
} as unknown as Parameters<HarnessService['createSession']>[1];
|
|
||||||
|
|
||||||
let error: unknown;
|
|
||||||
try {
|
|
||||||
await service.createSession(SCOPE, hostile);
|
|
||||||
} catch (caught) {
|
|
||||||
error = caught;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(HarnessScopeViolationError);
|
|
||||||
expect((error as HarnessScopeViolationError).field).toBe('seatId');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns adapter_unavailable for an unknown harness id, echoing the requested tuple', async () => {
|
|
||||||
const { service } = setup();
|
|
||||||
const selection: HarnessSelection = {
|
|
||||||
harnessId: 'ghost-harness',
|
|
||||||
providerId: 'p',
|
|
||||||
modelId: 'm',
|
|
||||||
};
|
|
||||||
|
|
||||||
let error: unknown;
|
|
||||||
try {
|
|
||||||
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
|
||||||
} catch (caught) {
|
|
||||||
error = caught;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
|
||||||
const dto = (error as HarnessOperationError).dto;
|
|
||||||
expect(dto.code).toBe('adapter_unavailable');
|
|
||||||
expect(dto.selection).toEqual(selection);
|
|
||||||
expect(dto.correlationId).toBe(SCOPE.correlationId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns selection_invalid for an unknown provider/model tuple, unchanged', async () => {
|
|
||||||
const { service } = setup();
|
|
||||||
const selection: HarnessSelection = {
|
|
||||||
harnessId: 'fake',
|
|
||||||
providerId: 'ghost-provider',
|
|
||||||
modelId: 'ghost-model',
|
|
||||||
};
|
|
||||||
|
|
||||||
let error: unknown;
|
|
||||||
try {
|
|
||||||
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
|
||||||
} catch (caught) {
|
|
||||||
error = caught;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
|
||||||
const dto = (error as HarnessOperationError).dto;
|
|
||||||
expect(dto.code).toBe('selection_invalid');
|
|
||||||
expect(dto.selection).toEqual(selection);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns model_unavailable without falling back for a known unavailable model', async () => {
|
|
||||||
const { service, adapter } = setup();
|
|
||||||
const catalog = await adapter.catalog(READ_CONTEXT);
|
|
||||||
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
|
|
||||||
expect(unavailable).toBeDefined();
|
|
||||||
const selection: HarnessSelection = {
|
|
||||||
harnessId: unavailable!.harnessId,
|
|
||||||
providerId: unavailable!.providerId,
|
|
||||||
modelId: unavailable!.modelId,
|
|
||||||
};
|
|
||||||
|
|
||||||
let error: unknown;
|
|
||||||
try {
|
|
||||||
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
|
||||||
} catch (caught) {
|
|
||||||
error = caught;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
|
||||||
const dto = (error as HarnessOperationError).dto;
|
|
||||||
expect(dto.code).toBe('model_unavailable');
|
|
||||||
// No substitution: the DTO tuple is exactly what was requested.
|
|
||||||
expect(dto.selection).toEqual(selection);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gives create, resume, detach, evict, and end distinct observable effects', async () => {
|
|
||||||
const { service, adapter } = setup();
|
|
||||||
const selection = await availableSelection(adapter);
|
|
||||||
|
|
||||||
const created = await service.createSession(SCOPE, {
|
|
||||||
conversationId: 'conversation-create',
|
|
||||||
selection,
|
|
||||||
});
|
|
||||||
expect(created.state).toBe('idle');
|
|
||||||
expect(created.processId).toBeTruthy();
|
|
||||||
expect(created.attachedClientIds).toEqual([]);
|
|
||||||
|
|
||||||
const resumed = await service.resumeSession(SCOPE, {
|
|
||||||
conversationId: 'conversation-resume',
|
|
||||||
nativeSessionId: 'native-preexisting-123',
|
|
||||||
selection,
|
|
||||||
});
|
|
||||||
// Resume binds the supplied native session; create mints a fresh one.
|
|
||||||
expect(resumed.nativeSessionId).toBe('native-preexisting-123');
|
|
||||||
expect(resumed.nativeSessionId).not.toBe(created.nativeSessionId);
|
|
||||||
|
|
||||||
await service.attach(SCOPE, {
|
|
||||||
conversationId: 'conversation-create',
|
|
||||||
clientId: 'browser-1',
|
|
||||||
});
|
|
||||||
const afterAttach = await service.snapshot(SCOPE, 'conversation-create');
|
|
||||||
expect(afterAttach.attachedClientIds).toEqual(['browser-1']);
|
|
||||||
|
|
||||||
const afterDetach = await service.detach(SCOPE, {
|
|
||||||
conversationId: 'conversation-create',
|
|
||||||
clientId: 'browser-1',
|
|
||||||
});
|
|
||||||
// Detach removes the browser attachment only; the process stays alive.
|
|
||||||
expect(afterDetach.attachedClientIds).toEqual([]);
|
|
||||||
expect(afterDetach.state).toBe('idle');
|
|
||||||
expect(afterDetach.processId).toBeTruthy();
|
|
||||||
|
|
||||||
const afterEvict = await service.evict(SCOPE, {
|
|
||||||
conversationId: 'conversation-create',
|
|
||||||
reason: 'idle_timeout',
|
|
||||||
});
|
|
||||||
// Evict stops the process but retains the resumable native session.
|
|
||||||
expect(afterEvict.state).toBe('evicted');
|
|
||||||
expect(afterEvict.processId).toBeUndefined();
|
|
||||||
expect(afterEvict.nativeSessionId).toBe(created.nativeSessionId);
|
|
||||||
|
|
||||||
const afterEnd = await service.end(SCOPE, {
|
|
||||||
conversationId: 'conversation-create',
|
|
||||||
reason: 'session_ended',
|
|
||||||
});
|
|
||||||
// End destructively terminates the native session.
|
|
||||||
expect(afterEnd.state).toBe('ended');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails typed when an unsupported capability is exercised', async () => {
|
|
||||||
const withoutExtensionUi = HARNESS_CAPABILITIES.filter(
|
|
||||||
(capability) => capability !== 'extensionUi',
|
|
||||||
);
|
|
||||||
const { service, adapter } = setup(withoutExtensionUi);
|
|
||||||
const selection = await availableSelection(adapter);
|
|
||||||
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
|
||||||
|
|
||||||
let error: unknown;
|
|
||||||
try {
|
|
||||||
await service.respondInteraction(SCOPE, {
|
|
||||||
conversationId: 'conversation-1',
|
|
||||||
response: { requestId: 'interaction-1', type: 'confirm', accepted: true },
|
|
||||||
});
|
|
||||||
} catch (caught) {
|
|
||||||
error = caught;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
|
||||||
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import type {
|
|
||||||
HarnessActorContext,
|
|
||||||
HarnessAdapter,
|
|
||||||
HarnessCatalog,
|
|
||||||
HarnessCloseReason,
|
|
||||||
HarnessInteractionResponse,
|
|
||||||
HarnessSelection,
|
|
||||||
HarnessSessionHandle,
|
|
||||||
HarnessSessionSnapshot,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import {
|
|
||||||
HarnessAdapterUnavailableError,
|
|
||||||
HarnessRegistry,
|
|
||||||
operationError,
|
|
||||||
} from './harness.registry.js';
|
|
||||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trusted, server-derived authority. In production this is produced by the
|
|
||||||
* Gateway from the authenticated session — never from a browser/caller DTO.
|
|
||||||
*/
|
|
||||||
export interface TrustedGatewayScope {
|
|
||||||
readonly actorId: string;
|
|
||||||
readonly tenantId: string;
|
|
||||||
readonly seatId: string;
|
|
||||||
readonly correlationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Server-authority fields that must never arrive from an external request DTO. */
|
|
||||||
const FORBIDDEN_REQUEST_FIELDS = [
|
|
||||||
'actorId',
|
|
||||||
'tenantId',
|
|
||||||
'correlationId',
|
|
||||||
'seatId',
|
|
||||||
'seat',
|
|
||||||
'executable',
|
|
||||||
'executablePath',
|
|
||||||
'home',
|
|
||||||
'homeDir',
|
|
||||||
'cwd',
|
|
||||||
'workingDir',
|
|
||||||
'workingDirectory',
|
|
||||||
'nativeSessionPath',
|
|
||||||
'sessionPath',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/** Raised when an external request DTO smuggles a server-authority field. */
|
|
||||||
export class HarnessScopeViolationError extends Error {
|
|
||||||
constructor(readonly field: string) {
|
|
||||||
super(`External request supplied server-authority field "${field}".`);
|
|
||||||
this.name = 'HarnessScopeViolationError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateHarnessSessionRequest {
|
|
||||||
readonly conversationId: string;
|
|
||||||
readonly selection: HarnessSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResumeHarnessSessionRequest {
|
|
||||||
readonly conversationId: string;
|
|
||||||
readonly nativeSessionId: string;
|
|
||||||
readonly selection: HarnessSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AttachClientRequest {
|
|
||||||
readonly conversationId: string;
|
|
||||||
readonly clientId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetachClientRequest {
|
|
||||||
readonly conversationId: string;
|
|
||||||
readonly clientId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EvictSessionRequest {
|
|
||||||
readonly conversationId: string;
|
|
||||||
readonly reason: HarnessCloseReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EndSessionRequest {
|
|
||||||
readonly conversationId: string;
|
|
||||||
readonly reason: HarnessCloseReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RespondInteractionRequest {
|
|
||||||
readonly conversationId: string;
|
|
||||||
readonly response: HarnessInteractionResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActiveSession {
|
|
||||||
readonly harnessId: string;
|
|
||||||
readonly handle: HarnessSessionHandle;
|
|
||||||
readonly correlationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Harness-neutral service. It derives the {@link HarnessActorContext} strictly
|
|
||||||
* from trusted Gateway scope, validates the selected provider/model tuple with
|
|
||||||
* NO fallback substitution, and exposes distinct create/resume/detach/evict/end
|
|
||||||
* lifecycle operations.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class HarnessService {
|
|
||||||
private readonly sessions = new Map<string, ActiveSession>();
|
|
||||||
|
|
||||||
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
|
|
||||||
|
|
||||||
async createSession(
|
|
||||||
scope: TrustedGatewayScope,
|
|
||||||
request: CreateHarnessSessionRequest,
|
|
||||||
): Promise<HarnessSessionSnapshot> {
|
|
||||||
assertTrustedRequest(request);
|
|
||||||
const { conversationId, selection } = request;
|
|
||||||
const adapter = this.resolveAdapter(scope, selection);
|
|
||||||
const context = deriveActorContext(scope);
|
|
||||||
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
|
|
||||||
|
|
||||||
const handle = await adapter.create({ context, conversationId, selection });
|
|
||||||
this.sessions.set(conversationId, {
|
|
||||||
harnessId: selection.harnessId,
|
|
||||||
handle,
|
|
||||||
correlationId: scope.correlationId,
|
|
||||||
});
|
|
||||||
return handle.snapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
async resumeSession(
|
|
||||||
scope: TrustedGatewayScope,
|
|
||||||
request: ResumeHarnessSessionRequest,
|
|
||||||
): Promise<HarnessSessionSnapshot> {
|
|
||||||
assertTrustedRequest(request);
|
|
||||||
const { conversationId, nativeSessionId, selection } = request;
|
|
||||||
const adapter = this.resolveAdapter(scope, selection);
|
|
||||||
const context = deriveActorContext(scope);
|
|
||||||
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
|
|
||||||
|
|
||||||
const handle = await adapter.resume({ context, conversationId, nativeSessionId, selection });
|
|
||||||
this.sessions.set(conversationId, {
|
|
||||||
harnessId: selection.harnessId,
|
|
||||||
handle,
|
|
||||||
correlationId: scope.correlationId,
|
|
||||||
});
|
|
||||||
return handle.snapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
async attach(
|
|
||||||
scope: TrustedGatewayScope,
|
|
||||||
request: AttachClientRequest,
|
|
||||||
): Promise<HarnessSessionSnapshot> {
|
|
||||||
assertTrustedRequest(request);
|
|
||||||
const handle = this.requireHandle(scope, request.conversationId);
|
|
||||||
await handle.attach({ clientId: request.clientId });
|
|
||||||
return handle.snapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
async detach(
|
|
||||||
scope: TrustedGatewayScope,
|
|
||||||
request: DetachClientRequest,
|
|
||||||
): Promise<HarnessSessionSnapshot> {
|
|
||||||
assertTrustedRequest(request);
|
|
||||||
const handle = this.requireHandle(scope, request.conversationId);
|
|
||||||
await handle.detach(request.clientId);
|
|
||||||
return handle.snapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
async evict(
|
|
||||||
scope: TrustedGatewayScope,
|
|
||||||
request: EvictSessionRequest,
|
|
||||||
): Promise<HarnessSessionSnapshot> {
|
|
||||||
assertTrustedRequest(request);
|
|
||||||
const handle = this.requireHandle(scope, request.conversationId);
|
|
||||||
await handle.evictProcess(request.reason);
|
|
||||||
return handle.snapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
async end(
|
|
||||||
scope: TrustedGatewayScope,
|
|
||||||
request: EndSessionRequest,
|
|
||||||
): Promise<HarnessSessionSnapshot> {
|
|
||||||
assertTrustedRequest(request);
|
|
||||||
const handle = this.requireHandle(scope, request.conversationId);
|
|
||||||
await handle.endSession(request.reason);
|
|
||||||
const snapshot = await handle.snapshot();
|
|
||||||
this.sessions.delete(request.conversationId);
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
async respondInteraction(
|
|
||||||
scope: TrustedGatewayScope,
|
|
||||||
request: RespondInteractionRequest,
|
|
||||||
): Promise<void> {
|
|
||||||
assertTrustedRequest(request);
|
|
||||||
const handle = this.requireHandle(scope, request.conversationId);
|
|
||||||
await handle.respondInteraction(request.response);
|
|
||||||
}
|
|
||||||
|
|
||||||
async snapshot(
|
|
||||||
scope: TrustedGatewayScope,
|
|
||||||
conversationId: string,
|
|
||||||
): Promise<HarnessSessionSnapshot> {
|
|
||||||
const handle = this.requireHandle(scope, conversationId);
|
|
||||||
return handle.snapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveAdapter(scope: TrustedGatewayScope, selection: HarnessSelection): HarnessAdapter {
|
|
||||||
try {
|
|
||||||
return this.registry.get(selection.harnessId);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof HarnessAdapterUnavailableError) {
|
|
||||||
throw operationError('adapter_unavailable', error.message, selection, scope.correlationId);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertSelectionAvailable(
|
|
||||||
scope: TrustedGatewayScope,
|
|
||||||
catalogPromise: Promise<HarnessCatalog>,
|
|
||||||
selection: HarnessSelection,
|
|
||||||
): Promise<void> {
|
|
||||||
const catalog = await catalogPromise;
|
|
||||||
const entry = catalog.models.find(
|
|
||||||
(candidate) =>
|
|
||||||
candidate.harnessId === selection.harnessId &&
|
|
||||||
candidate.providerId === selection.providerId &&
|
|
||||||
candidate.modelId === selection.modelId,
|
|
||||||
);
|
|
||||||
if (!entry) {
|
|
||||||
// No first-row fallback: reject the requested tuple unchanged.
|
|
||||||
throw operationError(
|
|
||||||
'selection_invalid',
|
|
||||||
'The requested harness/provider/model tuple is not in the catalog.',
|
|
||||||
selection,
|
|
||||||
scope.correlationId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (entry.availability === 'unavailable') {
|
|
||||||
throw operationError(
|
|
||||||
'model_unavailable',
|
|
||||||
'The requested model is currently unavailable.',
|
|
||||||
selection,
|
|
||||||
scope.correlationId,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private requireHandle(scope: TrustedGatewayScope, conversationId: string): HarnessSessionHandle {
|
|
||||||
const active = this.sessions.get(conversationId);
|
|
||||||
if (!active) {
|
|
||||||
throw operationError(
|
|
||||||
'session_not_found',
|
|
||||||
`No active harness session for conversation "${conversationId}".`,
|
|
||||||
{ harnessId: '', providerId: '', modelId: '' },
|
|
||||||
scope.correlationId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return active.handle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the actor context strictly from trusted scope. No caller data leaks in. */
|
|
||||||
export function deriveActorContext(scope: TrustedGatewayScope): HarnessActorContext {
|
|
||||||
return {
|
|
||||||
actorId: scope.actorId,
|
|
||||||
tenantId: scope.tenantId,
|
|
||||||
seatId: scope.seatId,
|
|
||||||
correlationId: scope.correlationId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reject any request object that carries a server-authority field. */
|
|
||||||
function assertTrustedRequest(request: object): void {
|
|
||||||
for (const field of FORBIDDEN_REQUEST_FIELDS) {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(request, field)) {
|
|
||||||
throw new HarnessScopeViolationError(field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export the typed operation error so callers importing from the service
|
|
||||||
// have the discriminated failure type without reaching into the registry.
|
|
||||||
export { HarnessOperationError } from './harness.registry.js';
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* Nest dependency-injection tokens for the harness-neutral registry and service.
|
|
||||||
*
|
|
||||||
* String tokens follow the existing Gateway convention (see `memory/memory.tokens.ts`)
|
|
||||||
* and remain valid Nest `InjectionToken`s for `@Inject(...)`.
|
|
||||||
*/
|
|
||||||
export const HARNESS_REGISTRY = 'HARNESS_REGISTRY' as const;
|
|
||||||
export const HARNESS_SERVICE = 'HARNESS_SERVICE' as const;
|
|
||||||
|
|
||||||
export type HarnessRegistryToken = typeof HARNESS_REGISTRY;
|
|
||||||
export type HarnessServiceToken = typeof HARNESS_SERVICE;
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { HarnessActorContext, HarnessSelection } from '@mosaicstack/types';
|
|
||||||
import { HarnessOperationError } from '../harness.registry.js';
|
|
||||||
import { FakeHarnessAdapter } from './fake-harness.adapter.js';
|
|
||||||
import { runHarnessAdapterContract } from './harness-adapter.contract.js';
|
|
||||||
|
|
||||||
const CONTEXT: HarnessActorContext = {
|
|
||||||
actorId: 'actor-1',
|
|
||||||
tenantId: 'tenant-1',
|
|
||||||
seatId: 'seat-1',
|
|
||||||
correlationId: 'correlation-1',
|
|
||||||
};
|
|
||||||
|
|
||||||
// The reusable conformance suite. Task 13 re-runs it against the native Pi adapter.
|
|
||||||
runHarnessAdapterContract('FakeHarnessAdapter', () => new FakeHarnessAdapter({ id: 'fake' }));
|
|
||||||
|
|
||||||
describe('FakeHarnessAdapter no-substitution', () => {
|
|
||||||
it('never substitutes the first catalog row when a bogus selection is requested', async () => {
|
|
||||||
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
|
||||||
const catalog = await adapter.catalog(CONTEXT);
|
|
||||||
const firstRow = catalog.models[0];
|
|
||||||
if (!firstRow) {
|
|
||||||
throw new Error('fixture requires a catalog model');
|
|
||||||
}
|
|
||||||
const available = catalog.models.find(
|
|
||||||
(entry) => entry.availability === 'available' && entry.modelId !== firstRow.modelId,
|
|
||||||
);
|
|
||||||
expect(available).toBeDefined();
|
|
||||||
const selected: HarnessSelection = {
|
|
||||||
harnessId: available!.harnessId,
|
|
||||||
providerId: available!.providerId,
|
|
||||||
modelId: available!.modelId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const handle = await adapter.create({
|
|
||||||
context: CONTEXT,
|
|
||||||
conversationId: 'conversation-1',
|
|
||||||
selection: selected,
|
|
||||||
});
|
|
||||||
|
|
||||||
const bogus: HarnessSelection = {
|
|
||||||
harnessId: 'fake',
|
|
||||||
providerId: 'ghost-provider',
|
|
||||||
modelId: 'ghost-model',
|
|
||||||
};
|
|
||||||
|
|
||||||
let error: unknown;
|
|
||||||
try {
|
|
||||||
await handle.setModel(bogus);
|
|
||||||
} catch (caught) {
|
|
||||||
error = caught;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
|
||||||
const dto = (error as HarnessOperationError).dto;
|
|
||||||
expect(dto.code).toBe('selection_invalid');
|
|
||||||
// The DTO echoes the exact requested tuple, unchanged.
|
|
||||||
expect(dto.selection).toEqual(bogus);
|
|
||||||
// No substitution to the first catalog row.
|
|
||||||
expect(dto.selection).not.toEqual({
|
|
||||||
harnessId: firstRow.harnessId,
|
|
||||||
providerId: firstRow.providerId,
|
|
||||||
modelId: firstRow.modelId,
|
|
||||||
});
|
|
||||||
// The active selection is untouched by the rejected request.
|
|
||||||
expect((await handle.snapshot()).selection).toEqual(selected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports model_unavailable with the unchanged tuple for a known but unavailable model', async () => {
|
|
||||||
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
|
||||||
const catalog = await adapter.catalog(CONTEXT);
|
|
||||||
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
|
|
||||||
const available = catalog.models.find((entry) => entry.availability === 'available');
|
|
||||||
expect(unavailable).toBeDefined();
|
|
||||||
expect(available).toBeDefined();
|
|
||||||
|
|
||||||
const startingSelection: HarnessSelection = {
|
|
||||||
harnessId: available!.harnessId,
|
|
||||||
providerId: available!.providerId,
|
|
||||||
modelId: available!.modelId,
|
|
||||||
};
|
|
||||||
const handle = await adapter.create({
|
|
||||||
context: CONTEXT,
|
|
||||||
conversationId: 'conversation-2',
|
|
||||||
selection: startingSelection,
|
|
||||||
});
|
|
||||||
|
|
||||||
const requested: HarnessSelection = {
|
|
||||||
harnessId: unavailable!.harnessId,
|
|
||||||
providerId: unavailable!.providerId,
|
|
||||||
modelId: unavailable!.modelId,
|
|
||||||
};
|
|
||||||
|
|
||||||
let error: unknown;
|
|
||||||
try {
|
|
||||||
await handle.setModel(requested);
|
|
||||||
} catch (caught) {
|
|
||||||
error = caught;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
|
||||||
const dto = (error as HarnessOperationError).dto;
|
|
||||||
expect(dto.code).toBe('model_unavailable');
|
|
||||||
expect(dto.selection).toEqual(requested);
|
|
||||||
expect((await handle.snapshot()).selection).toEqual(startingSelection);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
import type {
|
|
||||||
AttachClient,
|
|
||||||
CreateHarnessSession,
|
|
||||||
HarnessAdapter,
|
|
||||||
HarnessActorContext,
|
|
||||||
HarnessCapability,
|
|
||||||
HarnessCatalog,
|
|
||||||
HarnessCatalogEntry,
|
|
||||||
HarnessCloseReason,
|
|
||||||
HarnessDescriptor,
|
|
||||||
HarnessEvent,
|
|
||||||
HarnessInteractionResponse,
|
|
||||||
HarnessPrompt,
|
|
||||||
HarnessPromptReceipt,
|
|
||||||
HarnessSelection,
|
|
||||||
HarnessSessionHandle,
|
|
||||||
HarnessSessionSnapshot,
|
|
||||||
HarnessSessionState,
|
|
||||||
ResumeHarnessSession,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
|
|
||||||
import { operationError } from '../harness.registry.js';
|
|
||||||
|
|
||||||
export interface FakeHarnessAdapterOptions {
|
|
||||||
readonly id: string;
|
|
||||||
readonly capabilities?: readonly HarnessCapability[];
|
|
||||||
readonly catalog?: readonly HarnessCatalogEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const FAKE_PROVIDER = 'fake-openai';
|
|
||||||
|
|
||||||
function defaultCatalog(harnessId: string): readonly HarnessCatalogEntry[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
harnessId,
|
|
||||||
providerId: FAKE_PROVIDER,
|
|
||||||
modelId: 'fake-mini',
|
|
||||||
displayName: 'Fake Mini',
|
|
||||||
reasoningCapability: false,
|
|
||||||
inputTypes: ['text'],
|
|
||||||
authState: 'ready',
|
|
||||||
availability: 'available',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
harnessId,
|
|
||||||
providerId: FAKE_PROVIDER,
|
|
||||||
modelId: 'fake-pro',
|
|
||||||
displayName: 'Fake Pro',
|
|
||||||
reasoningCapability: true,
|
|
||||||
inputTypes: ['text', 'image'],
|
|
||||||
authState: 'ready',
|
|
||||||
availability: 'available',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
harnessId,
|
|
||||||
providerId: FAKE_PROVIDER,
|
|
||||||
modelId: 'fake-legacy',
|
|
||||||
displayName: 'Fake Legacy',
|
|
||||||
reasoningCapability: false,
|
|
||||||
inputTypes: ['text'],
|
|
||||||
authState: 'unavailable',
|
|
||||||
availability: 'unavailable',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function matches(entry: HarnessCatalogEntry, selection: HarnessSelection): boolean {
|
|
||||||
return (
|
|
||||||
entry.harnessId === selection.harnessId &&
|
|
||||||
entry.providerId === selection.providerId &&
|
|
||||||
entry.modelId === selection.modelId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory harness session handle used by the fake adapter and by the shared
|
|
||||||
* conformance suite. It enforces the two invariants the real adapters must also
|
|
||||||
* honor: model selection is validated against the catalog and is NEVER
|
|
||||||
* substituted, and unsupported capabilities fail with a typed error.
|
|
||||||
*/
|
|
||||||
export class FakeHarnessSessionHandle implements HarnessSessionHandle {
|
|
||||||
private state: HarnessSessionState = 'idle';
|
|
||||||
private processId: string | undefined;
|
|
||||||
private readonly attachedClientIds = new Set<string>();
|
|
||||||
private readonly listeners = new Set<(event: HarnessEvent) => void>();
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly conversationId: string,
|
|
||||||
private readonly nativeSessionId: string,
|
|
||||||
private readonly seatId: string,
|
|
||||||
private selection: HarnessSelection,
|
|
||||||
private readonly correlationId: string,
|
|
||||||
private readonly capabilities: readonly HarnessCapability[],
|
|
||||||
private readonly catalog: readonly HarnessCatalogEntry[],
|
|
||||||
) {
|
|
||||||
this.processId = `process-${nativeSessionId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async snapshot(): Promise<HarnessSessionSnapshot> {
|
|
||||||
return {
|
|
||||||
conversationId: this.conversationId,
|
|
||||||
nativeSessionId: this.nativeSessionId,
|
|
||||||
processId: this.processId,
|
|
||||||
seatId: this.seatId,
|
|
||||||
selection: this.selection,
|
|
||||||
state: this.state,
|
|
||||||
attachedClientIds: [...this.attachedClientIds],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async attach(input: AttachClient): Promise<void> {
|
|
||||||
this.attachedClientIds.add(input.clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async detach(clientId: string): Promise<void> {
|
|
||||||
// Removes the browser attachment only; the process and native session persist.
|
|
||||||
this.attachedClientIds.delete(clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async prompt(input: HarnessPrompt & { idempotencyKey: string }): Promise<HarnessPromptReceipt> {
|
|
||||||
return {
|
|
||||||
conversationId: this.conversationId,
|
|
||||||
turnId: input.turnId,
|
|
||||||
correlationId: input.correlationId,
|
|
||||||
state: 'accepted',
|
|
||||||
selection: this.selection,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async setModel(selection: HarnessSelection): Promise<HarnessSelection> {
|
|
||||||
const entry = this.catalog.find((candidate) => matches(candidate, selection));
|
|
||||||
if (!entry) {
|
|
||||||
// No fallback to the first catalog row: reject with the requested tuple, unchanged.
|
|
||||||
throw operationError(
|
|
||||||
'selection_invalid',
|
|
||||||
'The requested harness/provider/model tuple is not in the catalog.',
|
|
||||||
selection,
|
|
||||||
this.correlationId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (entry.availability === 'unavailable') {
|
|
||||||
throw operationError(
|
|
||||||
'model_unavailable',
|
|
||||||
'The requested model is currently unavailable.',
|
|
||||||
selection,
|
|
||||||
this.correlationId,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.selection = selection;
|
|
||||||
return this.selection;
|
|
||||||
}
|
|
||||||
|
|
||||||
async abort(_turnId: string): Promise<void> {
|
|
||||||
// No active turn machinery in the fake; abort is a no-op acknowledgement.
|
|
||||||
}
|
|
||||||
|
|
||||||
async respondInteraction(_input: HarnessInteractionResponse): Promise<void> {
|
|
||||||
if (!this.capabilities.includes('extensionUi')) {
|
|
||||||
throw operationError(
|
|
||||||
'interaction_unsupported',
|
|
||||||
'This harness does not support interactive responses.',
|
|
||||||
this.selection,
|
|
||||||
this.correlationId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
events(listener: (event: HarnessEvent) => void): () => void {
|
|
||||||
this.listeners.add(listener);
|
|
||||||
return () => {
|
|
||||||
this.listeners.delete(listener);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async evictProcess(_reason: HarnessCloseReason): Promise<void> {
|
|
||||||
// Stop the process but keep the resumable native session.
|
|
||||||
this.processId = undefined;
|
|
||||||
this.state = 'evicted';
|
|
||||||
}
|
|
||||||
|
|
||||||
async endSession(_reason: HarnessCloseReason): Promise<void> {
|
|
||||||
// Destructively end the native session.
|
|
||||||
this.processId = undefined;
|
|
||||||
this.state = 'ended';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal in-memory {@link HarnessAdapter} for Slice Zero. It mints a fresh
|
|
||||||
* native session id on `create` and binds the supplied one on `resume`, so the
|
|
||||||
* two paths are observably distinct.
|
|
||||||
*/
|
|
||||||
export class FakeHarnessAdapter implements HarnessAdapter {
|
|
||||||
readonly id: string;
|
|
||||||
private readonly capabilities: readonly HarnessCapability[];
|
|
||||||
private readonly catalogEntries: readonly HarnessCatalogEntry[];
|
|
||||||
private createdCount = 0;
|
|
||||||
|
|
||||||
constructor(options: FakeHarnessAdapterOptions) {
|
|
||||||
this.id = options.id;
|
|
||||||
this.capabilities = options.capabilities ?? [...HARNESS_CAPABILITIES];
|
|
||||||
this.catalogEntries = options.catalog ?? defaultCatalog(options.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async describe(_context: HarnessActorContext): Promise<HarnessDescriptor> {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
displayName: `Fake harness (${this.id})`,
|
|
||||||
capabilities: this.capabilities,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async catalog(_context: HarnessActorContext): Promise<HarnessCatalog> {
|
|
||||||
return {
|
|
||||||
harnessId: this.id,
|
|
||||||
version: '1.0.0',
|
|
||||||
fingerprint: `fake-${this.id}-${this.catalogEntries.length}`,
|
|
||||||
models: this.catalogEntries,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(input: CreateHarnessSession): Promise<HarnessSessionHandle> {
|
|
||||||
this.createdCount += 1;
|
|
||||||
const nativeSessionId = `native-${input.conversationId}-${this.createdCount}`;
|
|
||||||
return new FakeHarnessSessionHandle(
|
|
||||||
input.conversationId,
|
|
||||||
nativeSessionId,
|
|
||||||
input.context.seatId,
|
|
||||||
input.selection,
|
|
||||||
input.context.correlationId,
|
|
||||||
this.capabilities,
|
|
||||||
this.catalogEntries,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async resume(input: ResumeHarnessSession): Promise<HarnessSessionHandle> {
|
|
||||||
return new FakeHarnessSessionHandle(
|
|
||||||
input.conversationId,
|
|
||||||
input.nativeSessionId,
|
|
||||||
input.context.seatId,
|
|
||||||
input.selection,
|
|
||||||
input.context.correlationId,
|
|
||||||
this.capabilities,
|
|
||||||
this.catalogEntries,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type {
|
|
||||||
HarnessActorContext,
|
|
||||||
HarnessAdapter,
|
|
||||||
HarnessCatalogEntry,
|
|
||||||
HarnessSelection,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import { HarnessOperationError } from '../harness.registry.js';
|
|
||||||
|
|
||||||
const CONTEXT: HarnessActorContext = {
|
|
||||||
actorId: 'contract-actor',
|
|
||||||
tenantId: 'contract-tenant',
|
|
||||||
seatId: 'contract-seat',
|
|
||||||
correlationId: 'contract-correlation',
|
|
||||||
};
|
|
||||||
|
|
||||||
function toSelection(entry: HarnessCatalogEntry): HarnessSelection {
|
|
||||||
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickAvailable(models: readonly HarnessCatalogEntry[]): HarnessCatalogEntry {
|
|
||||||
const entry = models.find((candidate) => candidate.availability === 'available') ?? models[0];
|
|
||||||
if (!entry) {
|
|
||||||
throw new Error('contract fixture requires at least one catalog model');
|
|
||||||
}
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function captureError(run: () => Promise<unknown>): Promise<unknown> {
|
|
||||||
try {
|
|
||||||
await run();
|
|
||||||
return undefined;
|
|
||||||
} catch (caught) {
|
|
||||||
return caught;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared conformance suite every {@link HarnessAdapter} must pass. Slice Zero
|
|
||||||
* runs it against the fake adapter; Task 13 re-runs the identical suite against
|
|
||||||
* the native Pi adapter so both share one behavioral contract.
|
|
||||||
*/
|
|
||||||
export function runHarnessAdapterContract(
|
|
||||||
label: string,
|
|
||||||
createAdapter: () => HarnessAdapter,
|
|
||||||
): void {
|
|
||||||
describe(`harness adapter contract: ${label}`, () => {
|
|
||||||
it('mints a fresh native session on create and binds the supplied one on resume', async () => {
|
|
||||||
const adapter = createAdapter();
|
|
||||||
const catalog = await adapter.catalog(CONTEXT);
|
|
||||||
const selection = toSelection(pickAvailable(catalog.models));
|
|
||||||
|
|
||||||
const created = await (
|
|
||||||
await adapter.create({ context: CONTEXT, conversationId: 'conv-create', selection })
|
|
||||||
).snapshot();
|
|
||||||
const resumed = await (
|
|
||||||
await adapter.resume({
|
|
||||||
context: CONTEXT,
|
|
||||||
conversationId: 'conv-resume',
|
|
||||||
nativeSessionId: 'native-supplied-1',
|
|
||||||
selection,
|
|
||||||
})
|
|
||||||
).snapshot();
|
|
||||||
|
|
||||||
expect(created.nativeSessionId).toBeTruthy();
|
|
||||||
expect(resumed.nativeSessionId).toBe('native-supplied-1');
|
|
||||||
expect(created.nativeSessionId).not.toBe(resumed.nativeSessionId);
|
|
||||||
expect(created.seatId).toBe(CONTEXT.seatId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gives detach, evict, and end distinct effects (not aliases)', async () => {
|
|
||||||
const adapter = createAdapter();
|
|
||||||
const catalog = await adapter.catalog(CONTEXT);
|
|
||||||
const selection = toSelection(pickAvailable(catalog.models));
|
|
||||||
const handle = await adapter.create({
|
|
||||||
context: CONTEXT,
|
|
||||||
conversationId: 'conv-lifecycle',
|
|
||||||
selection,
|
|
||||||
});
|
|
||||||
|
|
||||||
await handle.attach({ clientId: 'browser-1' });
|
|
||||||
await handle.detach('browser-1');
|
|
||||||
const afterDetach = await handle.snapshot();
|
|
||||||
expect(afterDetach.attachedClientIds).toEqual([]);
|
|
||||||
expect(afterDetach.state).not.toBe('evicted');
|
|
||||||
expect(afterDetach.state).not.toBe('ended');
|
|
||||||
|
|
||||||
await handle.evictProcess('idle_timeout');
|
|
||||||
const afterEvict = await handle.snapshot();
|
|
||||||
expect(afterEvict.state).toBe('evicted');
|
|
||||||
// The native session survives eviction (resumable); the process does not.
|
|
||||||
expect(afterEvict.nativeSessionId).toBe(afterDetach.nativeSessionId);
|
|
||||||
expect(afterEvict.processId).toBeUndefined();
|
|
||||||
|
|
||||||
await handle.endSession('session_ended');
|
|
||||||
const afterEnd = await handle.snapshot();
|
|
||||||
expect(afterEnd.state).toBe('ended');
|
|
||||||
// End is not an alias of evict.
|
|
||||||
expect(afterEnd.state).not.toBe(afterEvict.state);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never substitutes the first catalog row for an unknown selection', async () => {
|
|
||||||
const adapter = createAdapter();
|
|
||||||
const catalog = await adapter.catalog(CONTEXT);
|
|
||||||
const firstRow = catalog.models[0];
|
|
||||||
if (!firstRow) {
|
|
||||||
throw new Error('contract fixture requires a catalog model');
|
|
||||||
}
|
|
||||||
const start = toSelection(pickAvailable(catalog.models));
|
|
||||||
const handle = await adapter.create({
|
|
||||||
context: CONTEXT,
|
|
||||||
conversationId: 'conv-nosub',
|
|
||||||
selection: start,
|
|
||||||
});
|
|
||||||
|
|
||||||
const bogus: HarnessSelection = {
|
|
||||||
harnessId: adapter.id,
|
|
||||||
providerId: 'contract-ghost-provider',
|
|
||||||
modelId: 'contract-ghost-model',
|
|
||||||
};
|
|
||||||
const error = await captureError(() => handle.setModel(bogus));
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
|
||||||
const dto = (error as HarnessOperationError).dto;
|
|
||||||
expect(dto.code).toBe('selection_invalid');
|
|
||||||
expect(dto.selection).toEqual(bogus);
|
|
||||||
expect(dto.selection).not.toEqual(toSelection(firstRow));
|
|
||||||
expect((await handle.snapshot()).selection).toEqual(start);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('validates capability-gated interactions with a typed error, not a silent no-op', async () => {
|
|
||||||
const adapter = createAdapter();
|
|
||||||
const descriptor = await adapter.describe(CONTEXT);
|
|
||||||
const catalog = await adapter.catalog(CONTEXT);
|
|
||||||
const selection = toSelection(pickAvailable(catalog.models));
|
|
||||||
const handle = await adapter.create({
|
|
||||||
context: CONTEXT,
|
|
||||||
conversationId: 'conv-interaction',
|
|
||||||
selection,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
requestId: 'interaction-1',
|
|
||||||
type: 'confirm',
|
|
||||||
accepted: true,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
if (descriptor.capabilities.includes('extensionUi')) {
|
|
||||||
await expect(handle.respondInteraction(response)).resolves.toBeUndefined();
|
|
||||||
} else {
|
|
||||||
const error = await captureError(() => handle.respondInteraction(response));
|
|
||||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
|
||||||
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(CronService.name);
|
private readonly logger = new Logger(CronService.name);
|
||||||
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
|
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||||
@@ -26,12 +26,6 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
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 summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||||
|
|
||||||
@@ -45,7 +39,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
||||||
await this.summarization.runSummarization();
|
await this.summarization.runSummarization();
|
||||||
});
|
});
|
||||||
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
|
this.registeredWorkers.push(summarizationWorker);
|
||||||
|
|
||||||
// M6-005: Tier management repeatable job
|
// M6-005: Tier management repeatable job
|
||||||
await this.queueService.addRepeatableJob(
|
await this.queueService.addRepeatableJob(
|
||||||
@@ -57,7 +51,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
||||||
await this.summarization.runTierManagement();
|
await this.summarization.runTierManagement();
|
||||||
});
|
});
|
||||||
if (tierWorker) this.registeredWorkers.push(tierWorker);
|
this.registeredWorkers.push(tierWorker);
|
||||||
|
|
||||||
// Retire any repeatable global GC schedule created by older deployments.
|
// Retire any repeatable global GC schedule created by older deployments.
|
||||||
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
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,5 +1,18 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import './env.js';
|
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 './tracing.js';
|
import './tracing.js';
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
@@ -13,7 +26,6 @@ import { mountAuthHandler } from './auth/auth.controller.js';
|
|||||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||||
import { McpService } from './mcp/mcp.service.js';
|
import { McpService } from './mcp/mcp.service.js';
|
||||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||||
import { resolveGatewayConfigPath } from './env.js';
|
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const logger = new Logger('Bootstrap');
|
const logger = new Logger('Bootstrap');
|
||||||
@@ -25,7 +37,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
// Pre-flight: assert all external services required by the configured tier
|
// Pre-flight: assert all external services required by the configured tier
|
||||||
// are reachable. Runs before NestFactory.create() so failures are visible
|
// are reachable. Runs before NestFactory.create() so failures are visible
|
||||||
// immediately with actionable remediation hints.
|
// immediately with actionable remediation hints.
|
||||||
const mosaicConfig = loadConfig(resolveGatewayConfigPath());
|
const mosaicConfig = loadConfig();
|
||||||
try {
|
try {
|
||||||
await detectAndAssertTier(mosaicConfig);
|
await detectAndAssertTier(mosaicConfig);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { McpClientService } from './mcp-client.service.js';
|
|
||||||
|
|
||||||
const MCP_LEAK_MARKER = 'MCP_LEAK_MARKER /srv/secret';
|
|
||||||
|
|
||||||
describe('McpClientService — failed connect error sanitization', () => {
|
|
||||||
const originalMcpServers = process.env['MCP_SERVERS'];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env['MCP_SERVERS'] = JSON.stringify([
|
|
||||||
{ name: 'leaky-server', url: 'http://localhost:9999/mcp' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
if (originalMcpServers === undefined) {
|
|
||||||
delete process.env['MCP_SERVERS'];
|
|
||||||
} else {
|
|
||||||
process.env['MCP_SERVERS'] = originalMcpServers;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores a generic serverEntry.error while logging the raw exception server-side', async () => {
|
|
||||||
vi.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error(MCP_LEAK_MARKER));
|
|
||||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
const service = new McpClientService();
|
|
||||||
await service.onModuleInit();
|
|
||||||
|
|
||||||
const statuses = service.getServerStatuses();
|
|
||||||
expect(statuses).toHaveLength(1);
|
|
||||||
expect(statuses[0]?.connected).toBe(false);
|
|
||||||
expect(statuses[0]?.error).toBe('Connection failed (see server logs).');
|
|
||||||
expect(statuses[0]?.error).not.toContain(MCP_LEAK_MARKER);
|
|
||||||
|
|
||||||
const loggedRawMarker = errorSpy.mock.calls.some((call) =>
|
|
||||||
call.some((arg) => typeof arg === 'string' && arg.includes(MCP_LEAK_MARKER)),
|
|
||||||
);
|
|
||||||
expect(loggedRawMarker).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -189,7 +189,7 @@ export class McpClientService implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
serverEntry.error = 'Connection failed (see server logs).';
|
serverEntry.error = message;
|
||||||
serverEntry.connected = false;
|
serverEntry.connected = false;
|
||||||
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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,8 +1,6 @@
|
|||||||
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
|
||||||
|
|
||||||
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
||||||
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
||||||
@@ -17,45 +15,16 @@ interface OverrideFragment {
|
|||||||
addedAt: number;
|
addedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LocalOverrideEntry {
|
|
||||||
condensed: string;
|
|
||||||
fragments: OverrideFragment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SystemOverrideService implements OnApplicationShutdown {
|
export class SystemOverrideService {
|
||||||
private readonly logger = new Logger(SystemOverrideService.name);
|
private readonly logger = new Logger(SystemOverrideService.name);
|
||||||
private readonly handle: QueueHandle | null;
|
private readonly handle: QueueHandle;
|
||||||
/** Local-tier fallback, keyed by the same tenant/user/session scope as Redis. */
|
|
||||||
private readonly localStore = new Map<string, LocalOverrideEntry>();
|
|
||||||
|
|
||||||
constructor(
|
constructor() {
|
||||||
@Optional()
|
this.handle = createQueue();
|
||||||
@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> {
|
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
|
// Load existing fragments
|
||||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
||||||
const fragments: OverrideFragment[] = existing
|
const fragments: OverrideFragment[] = existing
|
||||||
@@ -85,14 +54,10 @@ export class SystemOverrideService implements OnApplicationShutdown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
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));
|
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||||
if (!this.handle) return;
|
|
||||||
const pipeline = this.handle.redis.pipeline();
|
const pipeline = this.handle.redis.pipeline();
|
||||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
@@ -100,11 +65,6 @@ export class SystemOverrideService implements OnApplicationShutdown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
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(
|
await this.handle.redis.del(
|
||||||
SESSION_SYSTEM_KEY(sessionId, scope),
|
SESSION_SYSTEM_KEY(sessionId, scope),
|
||||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
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,9 +8,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
||||||
import type { LogService } from '@mosaicstack/log';
|
import type { LogService } from '@mosaicstack/log';
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
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';
|
import type { JobDto, JobStatus } from './queue-admin.dto.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -110,42 +108,21 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly connection: ConnectionOptions;
|
private readonly connection: ConnectionOptions;
|
||||||
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
||||||
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
||||||
/** False on Local tier — BullMQ/Redis operations become no-ops. */
|
|
||||||
private readonly enabled: boolean;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(LOG_SERVICE)
|
@Inject(LOG_SERVICE)
|
||||||
private readonly logService: LogService | null,
|
private readonly logService: LogService | null,
|
||||||
@Optional()
|
|
||||||
@Inject(MOSAIC_CONFIG)
|
|
||||||
private readonly mosaicConfig: MosaicConfig | null,
|
|
||||||
) {
|
) {
|
||||||
this.enabled = this.mosaicConfig?.queue?.type !== 'local';
|
this.connection = getConnection();
|
||||||
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 {
|
onModuleInit(): void {
|
||||||
if (this.enabled) {
|
this.logger.log('QueueService initialised (BullMQ)');
|
||||||
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> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
if (this.enabled) {
|
await this.closeAll();
|
||||||
await this.closeAll();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -154,10 +131,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a BullMQ Queue for the given queue name.
|
* 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> | null {
|
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
|
||||||
if (!this.enabled) return null;
|
|
||||||
let queue = this.queues.get(name) as Queue<T> | undefined;
|
let queue = this.queues.get(name) as Queue<T> | undefined;
|
||||||
if (!queue) {
|
if (!queue) {
|
||||||
queue = new Queue<T>(name, { connection: this.connection });
|
queue = new Queue<T>(name, { connection: this.connection });
|
||||||
@@ -169,7 +144,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* Add a BullMQ repeatable job (cron-style).
|
* Add a BullMQ repeatable job (cron-style).
|
||||||
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
||||||
* No-op on Local tier.
|
|
||||||
*/
|
*/
|
||||||
async addRepeatableJob<T extends MosaicJobData>(
|
async addRepeatableJob<T extends MosaicJobData>(
|
||||||
queueName: string,
|
queueName: string,
|
||||||
@@ -177,13 +151,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
data: T,
|
data: T,
|
||||||
cronExpression: string,
|
cronExpression: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this.enabled) {
|
const queue = this.getQueue<T>(queueName);
|
||||||
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
await (queue as Queue<any>).add(jobName, data, {
|
await (queue as Queue<any>).add(jobName, data, {
|
||||||
repeat: { pattern: cronExpression },
|
repeat: { pattern: cronExpression },
|
||||||
@@ -199,14 +167,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* safe retirement of previously registered system-wide jobs.
|
* safe retirement of previously registered system-wide jobs.
|
||||||
*/
|
*/
|
||||||
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
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);
|
const queue = this.getQueue(queueName);
|
||||||
if (!queue) return 0;
|
|
||||||
const jobs = await queue.getRepeatableJobs();
|
const jobs = await queue.getRepeatableJobs();
|
||||||
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
||||||
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
||||||
@@ -221,18 +182,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* Register a Worker for the given queue name with error handling and
|
* Register a Worker for the given queue name with error handling and
|
||||||
* exponential backoff.
|
* exponential backoff.
|
||||||
* Returns null on Local tier where BullMQ is disabled.
|
|
||||||
*/
|
*/
|
||||||
registerWorker<T extends MosaicJobData>(
|
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
|
||||||
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>(
|
const worker = new Worker<T>(
|
||||||
queueName,
|
queueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
@@ -289,12 +240,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Return queue health statistics for all managed queues.
|
* Return queue health statistics for all managed queues.
|
||||||
* Returns an empty healthy result on Local tier.
|
|
||||||
*/
|
*/
|
||||||
async getHealthStatus(): Promise<QueueHealthStatus> {
|
async getHealthStatus(): Promise<QueueHealthStatus> {
|
||||||
if (!this.enabled) {
|
|
||||||
return { queues: {}, healthy: true };
|
|
||||||
}
|
|
||||||
const queues: QueueHealthStatus['queues'] = {};
|
const queues: QueueHealthStatus['queues'] = {};
|
||||||
let healthy = true;
|
let healthy = true;
|
||||||
|
|
||||||
@@ -325,10 +272,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* List jobs across all managed queues, optionally filtered by status.
|
* List jobs across all managed queues, optionally filtered by status.
|
||||||
* BullMQ jobs are fetched by state type from each queue.
|
* BullMQ jobs are fetched by state type from each queue.
|
||||||
* Returns empty array on Local tier.
|
|
||||||
*/
|
*/
|
||||||
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
||||||
if (!this.enabled) return [];
|
|
||||||
const jobs: JobDto[] = [];
|
const jobs: JobDto[] = [];
|
||||||
const states: JobStatus[] = status
|
const states: JobStatus[] = status
|
||||||
? [status]
|
? [status]
|
||||||
@@ -355,10 +300,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
|
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
|
||||||
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
||||||
* job IDs are not globally unique — they are scoped to their queue.
|
* 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 }> {
|
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('__');
|
const sep = compositeId.lastIndexOf('__');
|
||||||
if (sep === -1) {
|
if (sep === -1) {
|
||||||
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
|
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
|
||||||
@@ -390,7 +333,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Pause a queue by name.
|
* Pause a queue by name.
|
||||||
*/
|
*/
|
||||||
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
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);
|
const queue = this.queues.get(name);
|
||||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||||
await queue.pause();
|
await queue.pause();
|
||||||
@@ -402,7 +344,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Resume a paused queue by name.
|
* Resume a paused queue by name.
|
||||||
*/
|
*/
|
||||||
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
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);
|
const queue = this.queues.get(name);
|
||||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||||
await queue.resume();
|
await queue.resume();
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import type { SlashCommandPayload, SystemReloadPayload } from '@mosaicstack/types';
|
|
||||||
import { ReloadService } from './reload.service.js';
|
import { ReloadService } from './reload.service.js';
|
||||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
|
||||||
|
|
||||||
function createMockCommandRegistry() {
|
function createMockCommandRegistry() {
|
||||||
return {
|
return {
|
||||||
@@ -107,85 +104,3 @@ describe('ReloadService', () => {
|
|||||||
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
|
||||||
it('generic per-plugin errors reach the chat surface while raw markers stay server-side only', async () => {
|
|
||||||
const registry = {
|
|
||||||
getManifest: vi.fn().mockReturnValue({
|
|
||||||
version: 1,
|
|
||||||
commands: [
|
|
||||||
{ name: 'reload', aliases: [], scope: 'core', execution: 'socket', available: true },
|
|
||||||
],
|
|
||||||
skills: [],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const reloadService = new ReloadService(registry as never);
|
|
||||||
|
|
||||||
const RELOAD_LOAD_LEAK_MARKER = 'RELOAD_LOAD_LEAK_MARKER /srv/load-secret';
|
|
||||||
const RELOAD_UNLOAD_LEAK_MARKER = 'RELOAD_UNLOAD_LEAK_MARKER /srv/unload-secret';
|
|
||||||
|
|
||||||
reloadService.registerPlugin('unload-fails', {
|
|
||||||
pluginName: 'unload-fails',
|
|
||||||
onLoad: vi.fn().mockResolvedValue(undefined),
|
|
||||||
onUnload: vi.fn().mockRejectedValue(new Error(RELOAD_UNLOAD_LEAK_MARKER)),
|
|
||||||
});
|
|
||||||
reloadService.registerPlugin('load-fails', {
|
|
||||||
pluginName: 'load-fails',
|
|
||||||
onLoad: vi.fn().mockRejectedValue(new Error(RELOAD_LOAD_LEAK_MARKER)),
|
|
||||||
onUnload: vi.fn().mockResolvedValue(undefined),
|
|
||||||
});
|
|
||||||
|
|
||||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
const broadcastReload = vi.fn();
|
|
||||||
const mockChatGateway = { broadcastReload };
|
|
||||||
const mockAgentService = { getSession: vi.fn(), applyAgentConfig: vi.fn() };
|
|
||||||
const mockSystemOverride = { set: vi.fn(), get: vi.fn(), clear: vi.fn() };
|
|
||||||
const mockSessionGC = { sweepOrphans: vi.fn() };
|
|
||||||
const mockBrain = { agents: { findByName: vi.fn(), findById: vi.fn(), create: vi.fn() } };
|
|
||||||
|
|
||||||
const mockMcpClient = {
|
|
||||||
getServerStatuses: vi.fn(() => []),
|
|
||||||
getToolDefinitions: vi.fn(() => []),
|
|
||||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
const executor = new CommandExecutorService(
|
|
||||||
registry as never,
|
|
||||||
mockAgentService as never,
|
|
||||||
mockSystemOverride as never,
|
|
||||||
mockSessionGC as never,
|
|
||||||
null,
|
|
||||||
mockBrain as never,
|
|
||||||
reloadService,
|
|
||||||
mockChatGateway as never,
|
|
||||||
mockMcpClient as never,
|
|
||||||
);
|
|
||||||
|
|
||||||
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
|
||||||
const result = await executor.execute(payload, { userId: 'user-1', tenantId: 'user-1' });
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.message).toContain('unload-fails: unload failed (internal error)');
|
|
||||||
expect(result.message).toContain('load-fails: load failed (internal error)');
|
|
||||||
expect(result.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
|
|
||||||
expect(result.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
|
|
||||||
|
|
||||||
expect(broadcastReload).toHaveBeenCalledOnce();
|
|
||||||
const broadcastPayload = broadcastReload.mock.calls[0]?.[0] as SystemReloadPayload;
|
|
||||||
expect(broadcastPayload.message).toContain('unload-fails: unload failed (internal error)');
|
|
||||||
expect(broadcastPayload.message).toContain('load-fails: load failed (internal error)');
|
|
||||||
expect(broadcastPayload.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
|
|
||||||
expect(broadcastPayload.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
|
|
||||||
|
|
||||||
const loggedUnloadMarker = errorSpy.mock.calls.some((call) =>
|
|
||||||
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_UNLOAD_LEAK_MARKER)),
|
|
||||||
);
|
|
||||||
const loggedLoadMarker = errorSpy.mock.calls.some((call) =>
|
|
||||||
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_LOAD_LEAK_MARKER)),
|
|
||||||
);
|
|
||||||
expect(loggedUnloadMarker).toBe(true);
|
|
||||||
expect(loggedLoadMarker).toBe(true);
|
|
||||||
|
|
||||||
errorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
|
|||||||
await plugin.onUnload();
|
await plugin.onUnload();
|
||||||
reloaded.push(name);
|
reloaded.push(name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Plugin "${name}" failed during onUnload: ${err}`);
|
errors.push(`${name}: unload failed — ${err}`);
|
||||||
errors.push(`${name}: unload failed (internal error)`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,8 +69,7 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
|
|||||||
try {
|
try {
|
||||||
await plugin.onLoad();
|
await plugin.onLoad();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Plugin "${name}" failed during onLoad: ${err}`);
|
errors.push(`${name}: load failed — ${err}`);
|
||||||
errors.push(`${name}: load failed (internal error)`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
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,11 +1,7 @@
|
|||||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import {
|
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||||
ProjectBootstrapService,
|
|
||||||
type BootstrapProjectResult,
|
|
||||||
} from './project-bootstrap.service.js';
|
|
||||||
import { CreateWorkspaceDto } from './workspace.dto.js';
|
|
||||||
|
|
||||||
@Controller('api/workspaces')
|
@Controller('api/workspaces')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -15,14 +11,20 @@ export class WorkspaceController {
|
|||||||
@Post()
|
@Post()
|
||||||
async create(
|
async create(
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
@Body() dto: CreateWorkspaceDto,
|
@Body()
|
||||||
): Promise<BootstrapProjectResult> {
|
body: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
teamId?: string;
|
||||||
|
repoUrl?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
return this.bootstrap.bootstrap({
|
return this.bootstrap.bootstrap({
|
||||||
name: dto.name,
|
name: body.name,
|
||||||
description: dto.description,
|
description: body.description,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: dto.teamId,
|
teamId: body.teamId,
|
||||||
repoUrl: dto.repoUrl,
|
repoUrl: body.repoUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
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,33 +1,11 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
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';
|
import { WorkspaceService } from './workspace.service.js';
|
||||||
|
import path from 'node:path';
|
||||||
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', () => {
|
describe('WorkspaceService', () => {
|
||||||
let service: WorkspaceService;
|
let service: WorkspaceService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
execFileMock.mockReset();
|
|
||||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
|
||||||
callback(null, '', '');
|
|
||||||
});
|
|
||||||
service = new WorkspaceService();
|
service = new WorkspaceService();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,69 +76,4 @@ 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,30 +1,10 @@
|
|||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
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 {
|
export interface WorkspaceProject {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -59,32 +39,14 @@ export class WorkspaceService {
|
|||||||
* If repoUrl is provided, clone instead of init.
|
* If repoUrl is provided, clone instead of init.
|
||||||
*/
|
*/
|
||||||
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
||||||
if (repoUrl !== undefined) {
|
|
||||||
assertAllowedRepositoryUrl(repoUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspacePath = this.resolvePath(project);
|
const workspacePath = this.resolvePath(project);
|
||||||
|
|
||||||
// Create directory
|
// Create directory
|
||||||
await fs.mkdir(workspacePath, { recursive: true });
|
await fs.mkdir(workspacePath, { recursive: true });
|
||||||
|
|
||||||
if (repoUrl !== undefined) {
|
if (repoUrl) {
|
||||||
// Clone existing repo. Defense in depth keeps dangerous local helpers
|
// Clone existing repo
|
||||||
// disabled and terminates option parsing before positional arguments.
|
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath });
|
||||||
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}`);
|
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
||||||
} else {
|
} else {
|
||||||
// Init new git repo
|
// Init new git repo
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
<!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
-10
@@ -3,26 +3,22 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node ../../scripts/build-web.mjs",
|
"build": "next build",
|
||||||
"build:vite": "vite build",
|
"dev": "next dev",
|
||||||
"dev": "next dev -p 3101",
|
|
||||||
"dev:vite": "vite",
|
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"start": "next start -p 3101"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/design-tokens": "workspace:^",
|
"@mosaicstack/design-tokens": "workspace:^",
|
||||||
"@mosaicstack/types": "workspace:^",
|
|
||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.18.2",
|
|
||||||
"socket.io-client": "^4.8.0",
|
"socket.io-client": "^4.8.0",
|
||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
@@ -32,11 +28,9 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^6.0.5",
|
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vite": "^8.2.1",
|
"vitest": "^2.0.0"
|
||||||
"vitest": "^3.2.7"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,56 +3,41 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useSearchParams } from 'next/navigation';
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
|
||||||
import { signIn } from '@/lib/auth-client';
|
import { signIn } from '@/lib/auth-client';
|
||||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
import { getSsoProvider } from '@/lib/sso-providers';
|
||||||
|
|
||||||
export default function AuthProviderRedirectPage(): React.ReactElement {
|
export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||||
const params = useParams<{ provider: string }>();
|
const params = useParams<{ provider: string }>();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
||||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
const provider = getSsoProvider(providerId);
|
||||||
const [providerName, setProviderName] = useState<string | null>(null);
|
const callbackURL = searchParams.get('callbackURL') ?? '/chat';
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
let cancelled = false;
|
||||||
|
|
||||||
async function redirectToProvider(): Promise<void> {
|
async function redirectToProvider(): Promise<void> {
|
||||||
try {
|
const result = await signIn.oauth2({
|
||||||
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
providerId: activeProvider.id,
|
||||||
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
callbackURL,
|
||||||
if (cancelled) return;
|
});
|
||||||
|
|
||||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
if (!cancelled && result?.error) {
|
||||||
if (!provider) {
|
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
|
||||||
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.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,22 +46,19 @@ export default function AuthProviderRedirectPage(): React.ReactElement {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [providerId, requestedCallbackURL]);
|
}, [callbackURL, provider]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
<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>
|
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||||
<p className="mt-2 text-sm text-text-secondary">
|
<p className="mt-2 text-sm text-text-secondary">
|
||||||
{providerName
|
{provider
|
||||||
? `Redirecting you to ${providerName}...`
|
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...`
|
||||||
: 'Preparing your sign-in request...'}
|
: 'Preparing your sign-in request...'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div
|
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error">
|
||||||
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>
|
<p>{error}</p>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { api } from './api';
|
|
||||||
|
|
||||||
describe('api', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches the supplied relative path with credentials and a JSON body', async () => {
|
|
||||||
const fetchMock = vi.fn<typeof fetch>();
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ ok: true }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
api<{ ok: boolean }>('/api/projects', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { name: 'Mosaic' },
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({ ok: true });
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledOnce();
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
'/api/projects',
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ name: 'Mosaic' }),
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws the gateway JSON error with its statusCode', async () => {
|
|
||||||
const fetchMock = vi.fn<typeof fetch>();
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ statusCode: 403, message: 'Forbidden' }), {
|
|
||||||
status: 403,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
await expect(api('/api/admin/users')).rejects.toMatchObject({
|
|
||||||
name: 'Error',
|
|
||||||
message: 'Forbidden',
|
|
||||||
statusCode: 403,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
||||||
|
|
||||||
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
}
|
}
|
||||||
@@ -23,7 +25,7 @@ export async function api<T>(path: string, init?: ApiRequestInit): Promise<T> {
|
|||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(path, {
|
const res = await fetch(`${GATEWAY_URL}${path}`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
...rest,
|
...rest,
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
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,9 +1,8 @@
|
|||||||
import { createAuthClient } from 'better-auth/react';
|
import { createAuthClient } from 'better-auth/react';
|
||||||
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
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({
|
export const authClient = createAuthClient({
|
||||||
|
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242',
|
||||||
plugins: [adminClient(), genericOAuthClient()],
|
plugins: [adminClient(), genericOAuthClient()],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
// Centralizes the type-only import of the shared `/chat` Socket.IO contract from
|
|
||||||
// the public `@mosaicstack/types` package. `import type` is erased at compile
|
|
||||||
// time, so this introduces no runtime dependency — it only reuses the exact
|
|
||||||
// payload shapes instead of redeclaring them.
|
|
||||||
import type { Socket } from 'socket.io-client';
|
|
||||||
import type {
|
|
||||||
AbortPayload,
|
|
||||||
AgentEndPayload,
|
|
||||||
AgentStartPayload,
|
|
||||||
AgentTextPayload,
|
|
||||||
AgentThinkingPayload,
|
|
||||||
ChatMessagePayload,
|
|
||||||
ClientToServerEvents,
|
|
||||||
CommandDef,
|
|
||||||
CommandManifest,
|
|
||||||
CommandManifestPayload,
|
|
||||||
ErrorPayload,
|
|
||||||
MessageAckPayload,
|
|
||||||
RoutingDecisionInfo,
|
|
||||||
ServerToClientEvents,
|
|
||||||
SessionInfoPayload,
|
|
||||||
SessionUsagePayload,
|
|
||||||
SetThinkingPayload,
|
|
||||||
SkillCommandDef,
|
|
||||||
SlashCommandApprovalResultPayload,
|
|
||||||
SlashCommandPayload,
|
|
||||||
SlashCommandResultPayload,
|
|
||||||
SystemReloadPayload,
|
|
||||||
ToolEndPayload,
|
|
||||||
ToolStartPayload,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
AbortPayload,
|
|
||||||
AgentEndPayload,
|
|
||||||
AgentStartPayload,
|
|
||||||
AgentTextPayload,
|
|
||||||
AgentThinkingPayload,
|
|
||||||
ChatMessagePayload,
|
|
||||||
ClientToServerEvents,
|
|
||||||
CommandDef,
|
|
||||||
CommandManifest,
|
|
||||||
CommandManifestPayload,
|
|
||||||
ErrorPayload,
|
|
||||||
MessageAckPayload,
|
|
||||||
RoutingDecisionInfo,
|
|
||||||
ServerToClientEvents,
|
|
||||||
SessionInfoPayload,
|
|
||||||
SessionUsagePayload,
|
|
||||||
SetThinkingPayload,
|
|
||||||
SkillCommandDef,
|
|
||||||
SlashCommandApprovalResultPayload,
|
|
||||||
SlashCommandPayload,
|
|
||||||
SlashCommandResultPayload,
|
|
||||||
SystemReloadPayload,
|
|
||||||
ToolEndPayload,
|
|
||||||
ToolStartPayload,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The `/chat` namespace socket, narrowed to the exact typed event contract. */
|
|
||||||
export type ChatSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const { ioMock } = vi.hoisted(() => ({
|
|
||||||
ioMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('socket.io-client', () => ({
|
|
||||||
io: ioMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { destroySocket, getSocket } from './socket';
|
|
||||||
|
|
||||||
interface MockChatSocket {
|
|
||||||
on: ReturnType<typeof vi.fn>;
|
|
||||||
offAny: ReturnType<typeof vi.fn>;
|
|
||||||
disconnect: ReturnType<typeof vi.fn>;
|
|
||||||
/** Test-only helper: fires every handler registered for `event` via
|
|
||||||
* `.on`, mirroring how a real socket.io-client instance invokes its own
|
|
||||||
* listeners (e.g. calling the registered `disconnect` handler(s) on a
|
|
||||||
* real transient disconnect). */
|
|
||||||
trigger(event: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockSocket(): MockChatSocket {
|
|
||||||
const handlers = new Map<string, Set<() => void>>();
|
|
||||||
const mockSocket: MockChatSocket = {
|
|
||||||
on: vi.fn((event: string, handler: () => void) => {
|
|
||||||
if (!handlers.has(event)) handlers.set(event, new Set());
|
|
||||||
handlers.get(event)?.add(handler);
|
|
||||||
return mockSocket;
|
|
||||||
}),
|
|
||||||
offAny: vi.fn(() => mockSocket),
|
|
||||||
disconnect: vi.fn(() => mockSocket),
|
|
||||||
trigger(event: string): void {
|
|
||||||
for (const handler of handlers.get(event) ?? []) handler();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return mockSocket;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentMock!: MockChatSocket;
|
|
||||||
|
|
||||||
describe('chat socket', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
ioMock.mockReset();
|
|
||||||
// A fresh object per io() call so identity assertions (same singleton vs.
|
|
||||||
// a genuinely new instance) are meaningful.
|
|
||||||
ioMock.mockImplementation(() => {
|
|
||||||
currentMock = createMockSocket();
|
|
||||||
return currentMock;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
destroySocket();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates one same-origin /chat namespace socket', () => {
|
|
||||||
const first = getSocket();
|
|
||||||
const second = getSocket();
|
|
||||||
|
|
||||||
expect(first).toBe(second);
|
|
||||||
expect(ioMock).toHaveBeenCalledOnce();
|
|
||||||
expect(ioMock).toHaveBeenCalledWith('/chat', {
|
|
||||||
withCredentials: true,
|
|
||||||
autoConnect: false,
|
|
||||||
transports: ['websocket', 'polling'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the same singleton instance across a transient disconnect', () => {
|
|
||||||
const first = getSocket();
|
|
||||||
|
|
||||||
// socket.ts must not react to a real socket's `disconnect` event by
|
|
||||||
// nulling the singleton — it registers no such handler at all now.
|
|
||||||
// Actually fire every handler registered via `.on('disconnect', ...)`
|
|
||||||
// (mirroring a real socket.io-client reconnect) instead of merely
|
|
||||||
// calling getSocket() again: this is what makes the test fail if
|
|
||||||
// production reintroduces `socket.on('disconnect', () => { socket =
|
|
||||||
// null; })`, since that handler would run here and null the singleton
|
|
||||||
// before the next getSocket() call.
|
|
||||||
currentMock.trigger('disconnect');
|
|
||||||
const second = getSocket();
|
|
||||||
|
|
||||||
expect(second).toBe(first);
|
|
||||||
expect(ioMock).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only creates a new singleton after an explicit destroySocket()', () => {
|
|
||||||
const first = getSocket();
|
|
||||||
|
|
||||||
destroySocket();
|
|
||||||
const second = getSocket();
|
|
||||||
|
|
||||||
expect(second).not.toBe(first);
|
|
||||||
expect(ioMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+13
-17
@@ -1,27 +1,23 @@
|
|||||||
import { io } from 'socket.io-client';
|
import { io, type Socket } from 'socket.io-client';
|
||||||
import type { ChatSocket } from './chat-contract';
|
|
||||||
|
|
||||||
let socket: ChatSocket | null = null;
|
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
||||||
|
|
||||||
export function getSocket(): ChatSocket {
|
let socket: Socket | null = null;
|
||||||
|
|
||||||
|
export function getSocket(): Socket {
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
// socket.io-client 4.8.3's `io()` factory declaration always returns the
|
socket = io(`${GATEWAY_URL}/chat`, {
|
||||||
// default unparameterized Socket (it accepts no <ListenEvents, EmitEvents>
|
|
||||||
// generics), so this one cast is the unavoidable boundary between that and the
|
|
||||||
// typed `/chat` contract. Every other call site uses the resulting ChatSocket
|
|
||||||
// with no further assertions.
|
|
||||||
socket = io('/chat', {
|
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
}) as unknown as ChatSocket;
|
});
|
||||||
|
|
||||||
// A transient `disconnect` (network blip, server restart) must NOT null
|
// Reset singleton reference when socket is fully closed so the next
|
||||||
// the singleton: socket.io-client auto-reconnects this same instance,
|
// getSocket() call creates a fresh instance instead of returning a
|
||||||
// and its listeners stay registered across that reconnect. Nulling here
|
// closed/dead socket.
|
||||||
// previously orphaned those listeners on the next getSocket() call by
|
socket.on('disconnect', () => {
|
||||||
// handing back a brand-new, unconnected instance. Only destroySocket()
|
socket = null;
|
||||||
// (an explicit, intentional teardown) may reset the singleton.
|
});
|
||||||
}
|
}
|
||||||
return socket;
|
return socket;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,42 +1,3 @@
|
|||||||
import type {
|
|
||||||
HarnessAuthState,
|
|
||||||
HarnessModelAvailability,
|
|
||||||
HarnessSelection,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
|
|
||||||
// The exact harness/provider/model tuple and its closed enum companions are the
|
|
||||||
// shared domain types — re-exported here so web consumers (and the runtime
|
|
||||||
// guards) import one shape, never a divergent local redefinition.
|
|
||||||
export type { HarnessSelection, HarnessAuthState, HarnessModelAvailability };
|
|
||||||
|
|
||||||
/** Harness summary row from `GET /api/harnesses` (the `HarnessSummaryDto`). The
|
|
||||||
* harness id is kept distinct from any provider id — they are never merged. */
|
|
||||||
export interface HarnessSummary {
|
|
||||||
id: string;
|
|
||||||
displayName: string;
|
|
||||||
capabilities: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One selectable model in a harness catalog. Extends the `{harnessId,
|
|
||||||
* providerId, modelId}` tuple with the display/availability metadata the UI
|
|
||||||
* needs; `inputTypes` is kept as a plain `string[]` on the client boundary
|
|
||||||
* because it arrives from untrusted JSON and is only ever displayed. */
|
|
||||||
export interface HarnessCatalogEntry extends HarnessSelection {
|
|
||||||
displayName: string;
|
|
||||||
reasoningCapability: boolean;
|
|
||||||
inputTypes: string[];
|
|
||||||
authState: HarnessAuthState;
|
|
||||||
availability: HarnessModelAvailability;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Harness-scoped catalog from `GET /api/harnesses/:harnessId/catalog`. */
|
|
||||||
export interface HarnessCatalog {
|
|
||||||
harnessId: string;
|
|
||||||
version: string;
|
|
||||||
fingerprint: string;
|
|
||||||
models: HarnessCatalogEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Conversation returned by the gateway API. */
|
/** Conversation returned by the gateway API. */
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
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>,
|
|
||||||
);
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-router-dom';
|
|
||||||
import { LoginPage } from '@/spa/pages/login';
|
|
||||||
import { RegisterPage } from '@/spa/pages/register';
|
|
||||||
import { SsoCallbackPage } from '@/spa/pages/sso-callback';
|
|
||||||
import { ChatPage } from '@/spa/pages/chat';
|
|
||||||
import { ChatRouteErrorBoundary } from '@/spa/pages/chat-error-boundary';
|
|
||||||
import { ProjectDetailPage } from '@/spa/pages/project-detail';
|
|
||||||
import { ProjectsPage } from '@/spa/pages/projects';
|
|
||||||
import {
|
|
||||||
ProjectDetailRouteErrorBoundary,
|
|
||||||
ProjectsRouteErrorBoundary,
|
|
||||||
TasksRouteErrorBoundary,
|
|
||||||
} from '@/spa/pages/resource-route-error-boundaries';
|
|
||||||
import { TasksPage } from '@/spa/pages/tasks';
|
|
||||||
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
|
||||||
import { Placeholder } from '@/spa/placeholder';
|
|
||||||
|
|
||||||
function GuestLayout(): ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-surface-bg px-4 py-8">
|
|
||||||
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const routes: RouteObject[] = [
|
|
||||||
{
|
|
||||||
element: <GuestGuard />,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
element: <GuestLayout />,
|
|
||||||
children: [
|
|
||||||
{ path: '/login', element: <LoginPage /> },
|
|
||||||
{ path: '/register', element: <RegisterPage /> },
|
|
||||||
{ path: '/auth/provider/:provider', element: <SsoCallbackPage /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
element: <AuthGuard />,
|
|
||||||
children: [
|
|
||||||
{ path: '/', element: <Navigate to="/chat" replace /> },
|
|
||||||
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
|
||||||
{
|
|
||||||
path: '/projects',
|
|
||||||
element: <ProjectsPage />,
|
|
||||||
errorElement: <ProjectsRouteErrorBoundary />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/projects/:id',
|
|
||||||
element: <ProjectDetailPage />,
|
|
||||||
errorElement: <ProjectDetailRouteErrorBoundary />,
|
|
||||||
},
|
|
||||||
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
|
||||||
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
|
||||||
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function createAppRouter(): ReturnType<typeof createBrowserRouter> {
|
|
||||||
return createBrowserRouter(routes);
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
fetchCatalog,
|
|
||||||
fetchHarnesses,
|
|
||||||
fetchPersistedSelection,
|
|
||||||
persistSelection,
|
|
||||||
} from './chat-api';
|
|
||||||
|
|
||||||
function json(body: unknown, status = 200): Response {
|
|
||||||
return new Response(JSON.stringify(body), {
|
|
||||||
status,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function stubFetch(): ReturnType<typeof vi.fn> {
|
|
||||||
const fetchMock = vi.fn();
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
return fetchMock;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every URL the client actually requested, across all calls. */
|
|
||||||
function requestedUrls(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
|
||||||
return fetchMock.mock.calls.map((call) => String(call[0]));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('chat-api', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetchHarnesses GETs /api/harnesses and returns typed summaries (harness id separate from provider)', async () => {
|
|
||||||
const fetchMock = stubFetch();
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
json([
|
|
||||||
{ id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
|
|
||||||
{ id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const harnesses = await fetchHarnesses();
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledOnce();
|
|
||||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses');
|
|
||||||
expect(harnesses).toEqual([
|
|
||||||
{ id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
|
|
||||||
{ id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetchCatalog GETs the harness-scoped catalog and returns only its model entries', async () => {
|
|
||||||
const fetchMock = stubFetch();
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
json({
|
|
||||||
harnessId: 'pi',
|
|
||||||
version: '2026-08-11',
|
|
||||||
fingerprint: 'abc123',
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
harnessId: 'pi',
|
|
||||||
providerId: 'openai',
|
|
||||||
modelId: 'gpt-5',
|
|
||||||
displayName: 'GPT-5',
|
|
||||||
reasoningCapability: true,
|
|
||||||
inputTypes: ['text'],
|
|
||||||
authState: 'ready',
|
|
||||||
availability: 'available',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await fetchCatalog('pi');
|
|
||||||
|
|
||||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses/pi/catalog');
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
if (!result.ok) throw new Error('expected ok catalog');
|
|
||||||
expect(result.catalog.harnessId).toBe('pi');
|
|
||||||
expect(result.catalog.models).toHaveLength(1);
|
|
||||||
expect(result.catalog.models[0]).toMatchObject({
|
|
||||||
harnessId: 'pi',
|
|
||||||
providerId: 'openai',
|
|
||||||
modelId: 'gpt-5',
|
|
||||||
availability: 'available',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes a catalog 404 into a typed catalog_unavailable result without surfacing the raw body', async () => {
|
|
||||||
const fetchMock = stubFetch();
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
json(
|
|
||||||
{
|
|
||||||
code: 'adapter_unavailable',
|
|
||||||
message: 'raw gateway detail that must not leak verbatim',
|
|
||||||
harnessId: 'attacker-echo',
|
|
||||||
extra: { hostile: 'blob' },
|
|
||||||
},
|
|
||||||
404,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await fetchCatalog('ghost');
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
if (result.ok) throw new Error('expected unavailable result');
|
|
||||||
expect(result.code).toBe('catalog_unavailable');
|
|
||||||
// harnessId comes from the request, never the (untrusted) response body.
|
|
||||||
expect(result.harnessId).toBe('ghost');
|
|
||||||
expect(typeof result.message).toBe('string');
|
|
||||||
// The raw response body is never rendered/returned verbatim.
|
|
||||||
expect(JSON.stringify(result)).not.toContain('hostile');
|
|
||||||
expect(JSON.stringify(result)).not.toContain('attacker-echo');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetchPersistedSelection returns the stored tuple, or null when unset', async () => {
|
|
||||||
const fetchMock = stubFetch();
|
|
||||||
fetchMock.mockResolvedValueOnce(
|
|
||||||
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
|
||||||
);
|
|
||||||
await expect(fetchPersistedSelection()).resolves.toEqual({
|
|
||||||
harnessId: 'pi',
|
|
||||||
providerId: 'openai',
|
|
||||||
modelId: 'gpt-5',
|
|
||||||
});
|
|
||||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/chat/preferences/selection');
|
|
||||||
|
|
||||||
fetchMock.mockResolvedValueOnce(json({ selection: null }));
|
|
||||||
await expect(fetchPersistedSelection()).resolves.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('persistSelection PUTs the structured tuple (not free text) and returns the confirmed selection', async () => {
|
|
||||||
const fetchMock = stubFetch();
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await persistSelection({
|
|
||||||
harnessId: 'pi',
|
|
||||||
providerId: 'openai',
|
|
||||||
modelId: 'gpt-5',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
const call = fetchMock.mock.calls[0];
|
|
||||||
expect(String(call?.[0])).toBe('/api/chat/preferences/selection');
|
|
||||||
const init = call?.[1] as RequestInit;
|
|
||||||
expect(String(init.method).toUpperCase()).toBe('PUT');
|
|
||||||
// The body is exactly the structured tuple — harness/provider/model kept distinct.
|
|
||||||
expect(JSON.parse(String(init.body))).toEqual({
|
|
||||||
harnessId: 'pi',
|
|
||||||
providerId: 'openai',
|
|
||||||
modelId: 'gpt-5',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes a selection 422 into a typed error preserving the requested tuple exactly', async () => {
|
|
||||||
const fetchMock = stubFetch();
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
json(
|
|
||||||
{
|
|
||||||
code: 'model_unavailable',
|
|
||||||
message: 'raw detail that must not leak',
|
|
||||||
selection: { harnessId: 'x', providerId: 'y', modelId: 'z' },
|
|
||||||
},
|
|
||||||
422,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const requested = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' };
|
|
||||||
const result = await persistSelection(requested);
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
if (result.ok) throw new Error('expected failed persist');
|
|
||||||
expect(['selection_invalid', 'model_unavailable']).toContain(result.code);
|
|
||||||
// The requested tuple is preserved unchanged — not replaced by the body's echo.
|
|
||||||
expect(result.requested).toEqual(requested);
|
|
||||||
expect(JSON.stringify(result)).not.toContain('raw detail');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never requests any /api/providers* endpoint', async () => {
|
|
||||||
const fetchMock = stubFetch();
|
|
||||||
fetchMock.mockResolvedValue(json([]));
|
|
||||||
await fetchHarnesses();
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
json({ harnessId: 'pi', version: '1', fingerprint: 'f', models: [] }),
|
|
||||||
);
|
|
||||||
await fetchCatalog('pi');
|
|
||||||
fetchMock.mockResolvedValue(json({ selection: null }));
|
|
||||||
await fetchPersistedSelection();
|
|
||||||
|
|
||||||
for (const url of requestedUrls(fetchMock)) {
|
|
||||||
expect(url).not.toContain('/api/providers');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
/**
|
|
||||||
* Typed fetch wrappers for the Task-3 harness HTTP contract the chat selection
|
|
||||||
* UI depends on. Every response body is untrusted and is normalized through the
|
|
||||||
* runtime guards before it reaches state — a 404 (catalog) and a 422 (selection)
|
|
||||||
* are mapped to typed, body-free error results so a raw gateway body is never
|
|
||||||
* rendered, and the caller's requested tuple is preserved verbatim on failure.
|
|
||||||
*
|
|
||||||
* This module talks ONLY to the harness/chat-preferences endpoints. It never
|
|
||||||
* calls `/api/providers*` — provider identity lives inside the harness catalog.
|
|
||||||
*/
|
|
||||||
import { asHarnessCatalog, asHarnessSelection, asHarnessSummaries } from './runtime-guards';
|
|
||||||
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
|
|
||||||
|
|
||||||
/** A catalog fetch either yields the typed catalog or a typed unavailability —
|
|
||||||
* never a thrown raw body. */
|
|
||||||
export type CatalogResult =
|
|
||||||
| { ok: true; catalog: HarnessCatalog }
|
|
||||||
| { ok: false; code: 'catalog_unavailable'; harnessId: string; message: string };
|
|
||||||
|
|
||||||
export type SelectionErrorCode = 'selection_invalid' | 'model_unavailable';
|
|
||||||
|
|
||||||
/** A persist either confirms the stored tuple or reports a typed domain failure
|
|
||||||
* that echoes back the exact tuple the caller requested. */
|
|
||||||
export type SelectionPersistResult =
|
|
||||||
| { ok: true; selection: HarnessSelection }
|
|
||||||
| { ok: false; code: SelectionErrorCode; message: string; requested: HarnessSelection };
|
|
||||||
|
|
||||||
/** A safe, generic message for an unavailable catalog — the raw 404 body is
|
|
||||||
* never surfaced. */
|
|
||||||
const CATALOG_UNAVAILABLE_MESSAGE = 'This harness catalog is currently unavailable.';
|
|
||||||
|
|
||||||
/** A safe, generic message for a rejected selection. The untrusted 422 body's
|
|
||||||
* own `message` is deliberately NEVER surfaced — only this fixed copy — so a
|
|
||||||
* raw gateway detail can never leak into the UI. Only the closed `code` enum is
|
|
||||||
* read from the body. */
|
|
||||||
const SELECTION_REJECTED_MESSAGE = 'This selection was rejected.';
|
|
||||||
|
|
||||||
async function readJson(response: Response): Promise<unknown> {
|
|
||||||
return response.json().catch(() => null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeSelectionCode(body: unknown): SelectionErrorCode {
|
|
||||||
if (typeof body === 'object' && body !== null && 'code' in body) {
|
|
||||||
const code = (body as { code: unknown }).code;
|
|
||||||
if (code === 'selection_invalid' || code === 'model_unavailable') return code;
|
|
||||||
}
|
|
||||||
// Default to the more conservative "invalid" classification for anything
|
|
||||||
// unrecognized rather than guessing "model_unavailable".
|
|
||||||
return 'selection_invalid';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `GET /api/harnesses` → the list of harness summaries. A non-OK response
|
|
||||||
* normalizes to an empty list (the UI then has no harness to select). */
|
|
||||||
export async function fetchHarnesses(): Promise<HarnessSummary[]> {
|
|
||||||
const response = await fetch('/api/harnesses', {
|
|
||||||
credentials: 'include',
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
if (!response.ok) return [];
|
|
||||||
return asHarnessSummaries(await readJson(response));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `GET /api/harnesses/:harnessId/catalog` → the harness-scoped catalog. A 404
|
|
||||||
* (or any non-OK) becomes a typed `catalog_unavailable` result rather than a
|
|
||||||
* fallback catalog or a rendered raw body. */
|
|
||||||
export async function fetchCatalog(harnessId: string): Promise<CatalogResult> {
|
|
||||||
const response = await fetch(`/api/harnesses/${encodeURIComponent(harnessId)}/catalog`, {
|
|
||||||
credentials: 'include',
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
code: 'catalog_unavailable',
|
|
||||||
// Scoped to the requested harness id, never the untrusted body's echo.
|
|
||||||
harnessId,
|
|
||||||
message: CATALOG_UNAVAILABLE_MESSAGE,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { ok: true, catalog: asHarnessCatalog(await readJson(response), harnessId) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `GET /api/chat/preferences/selection` → the persisted tuple, or null when
|
|
||||||
* unset or malformed. */
|
|
||||||
export async function fetchPersistedSelection(): Promise<HarnessSelection | null> {
|
|
||||||
const response = await fetch('/api/chat/preferences/selection', {
|
|
||||||
credentials: 'include',
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
if (!response.ok) return null;
|
|
||||||
const body = await readJson(response);
|
|
||||||
if (typeof body !== 'object' || body === null) return null;
|
|
||||||
return asHarnessSelection((body as { selection?: unknown }).selection);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `PUT /api/chat/preferences/selection` with the structured tuple as the body.
|
|
||||||
* On success returns the confirmed selection; on a typed domain failure (422)
|
|
||||||
* or validation error, returns a typed result carrying the EXACT requested
|
|
||||||
* tuple — never the body's echo — and never the raw body text. */
|
|
||||||
export async function persistSelection(
|
|
||||||
selection: HarnessSelection,
|
|
||||||
): Promise<SelectionPersistResult> {
|
|
||||||
const requested: HarnessSelection = {
|
|
||||||
harnessId: selection.harnessId,
|
|
||||||
providerId: selection.providerId,
|
|
||||||
modelId: selection.modelId,
|
|
||||||
};
|
|
||||||
const response = await fetch('/api/chat/preferences/selection', {
|
|
||||||
method: 'PUT',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(requested),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await readJson(response);
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
code: safeSelectionCode(body),
|
|
||||||
// Fixed copy only — the untrusted body's message is never surfaced.
|
|
||||||
message: SELECTION_REJECTED_MESSAGE,
|
|
||||||
requested,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const body = await readJson(response);
|
|
||||||
const confirmed =
|
|
||||||
typeof body === 'object' && body !== null
|
|
||||||
? asHarnessSelection((body as { selection?: unknown }).selection)
|
|
||||||
: null;
|
|
||||||
// A malformed 2xx body is treated as a confirmation of exactly what we sent —
|
|
||||||
// the server accepted the tuple, so the requested tuple is the source of truth.
|
|
||||||
return { ok: true, selection: confirmed ?? requested };
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { CommandsPanel } from './commands-panel';
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
let root: Root | null;
|
|
||||||
let container: HTMLElement | null;
|
|
||||||
|
|
||||||
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
|
||||||
container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(node);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
document.body.replaceChildren();
|
|
||||||
root = null;
|
|
||||||
container = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CommandsPanel', () => {
|
|
||||||
it('shows the frozen local pendingApproval args in the confirmation area, regardless of misleading server message text', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[]}
|
|
||||||
approval={{
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'deploy', // matches pendingApproval — this is a legitimately approved request
|
|
||||||
success: true,
|
|
||||||
approvalId: 'ap1',
|
|
||||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
// Free-text server message claims a different, less alarming target
|
|
||||||
// than what will actually be sent — the UI must not rely on this.
|
|
||||||
message: 'This will only affect the staging environment.',
|
|
||||||
}}
|
|
||||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
|
||||||
hasConversation
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The exact frozen combined action is visible...
|
|
||||||
expect(container?.textContent).toContain('/deploy');
|
|
||||||
expect(container?.textContent).toContain('prod');
|
|
||||||
// ...and the misleading server free-text is never shown next to it.
|
|
||||||
expect(container?.textContent).not.toContain('staging environment');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not throw when a manifest commands entry is null', async () => {
|
|
||||||
const manifest = {
|
|
||||||
commands: [
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
name: 'model',
|
|
||||||
aliases: [],
|
|
||||||
description: 'Change the active model',
|
|
||||||
scope: 'core',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
skills: [null],
|
|
||||||
version: 1,
|
|
||||||
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={manifest}
|
|
||||||
results={[]}
|
|
||||||
approval={null}
|
|
||||||
pendingApproval={null}
|
|
||||||
hasConversation={false}
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
),
|
|
||||||
).resolves.not.toThrow();
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain('model');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows an explicit no-args fallback when the frozen pendingApproval has no args', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[]}
|
|
||||||
approval={{
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'deploy',
|
|
||||||
success: true,
|
|
||||||
approvalId: 'ap1',
|
|
||||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
}}
|
|
||||||
pendingApproval={{ command: 'deploy' }}
|
|
||||||
hasConversation
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container?.textContent?.toLowerCase()).toContain('no args');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders skills from a skills-only manifest', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={{
|
|
||||||
commands: [],
|
|
||||||
skills: [{ name: 'brave-search', description: 'Search the web', available: true }],
|
|
||||||
version: 1,
|
|
||||||
}}
|
|
||||||
results={[]}
|
|
||||||
approval={null}
|
|
||||||
pendingApproval={null}
|
|
||||||
hasConversation={false}
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain('brave-search');
|
|
||||||
expect(container?.textContent).toContain('Search the web');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not show the Run affordance when approval.success/approvalId are objects, even though command matches pendingApproval', async () => {
|
|
||||||
const approval = {
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'deploy',
|
|
||||||
success: { truthy: 'object' },
|
|
||||||
approvalId: { also: 'object' },
|
|
||||||
} as unknown as Parameters<typeof CommandsPanel>[0]['approval'];
|
|
||||||
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[]}
|
|
||||||
approval={approval}
|
|
||||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
|
||||||
hasConversation
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
[...(container?.querySelectorAll('button') ?? [])].some((button) =>
|
|
||||||
button.textContent?.includes('Run approved command'),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the guarded server-provided denial reason for a denied approval', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[]}
|
|
||||||
approval={{
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'deploy',
|
|
||||||
success: false,
|
|
||||||
message: 'Not authorized',
|
|
||||||
}}
|
|
||||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
|
||||||
hasConversation
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain('Not authorized');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to a stable "Denied." copy when a denial has no usable message', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[]}
|
|
||||||
approval={{ conversationId: 'c1', command: 'deploy', success: false }}
|
|
||||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
|
||||||
hasConversation
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain('Denied.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the guarded contract-provided reason for a failed command result, falling back to a stable copy only when absent', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[
|
|
||||||
{ conversationId: 'c1', command: 'model', success: false, message: 'Unknown model' },
|
|
||||||
{ conversationId: 'c1', command: 'deploy', success: false },
|
|
||||||
]}
|
|
||||||
approval={null}
|
|
||||||
pendingApproval={null}
|
|
||||||
hasConversation={false}
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain('Unknown model');
|
|
||||||
expect(container?.textContent).toContain('Command failed.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bounds an oversized command result message at the render site as defense-in-depth', async () => {
|
|
||||||
const hostileMessage = 'y'.repeat(50_000);
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[
|
|
||||||
{ conversationId: 'c1', command: 'model', success: false, message: hostileMessage },
|
|
||||||
]}
|
|
||||||
approval={null}
|
|
||||||
pendingApproval={null}
|
|
||||||
hasConversation={false}
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const text = container?.textContent ?? '';
|
|
||||||
expect(text.length).toBeLessThan(hostileMessage.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not throw when the manifest fields are malformed (non-array commands/skills)', async () => {
|
|
||||||
const manifest = {
|
|
||||||
commands: 'not-an-array',
|
|
||||||
skills: null,
|
|
||||||
version: 1,
|
|
||||||
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={manifest}
|
|
||||||
results={[]}
|
|
||||||
approval={null}
|
|
||||||
pendingApproval={null}
|
|
||||||
hasConversation={false}
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
),
|
|
||||||
).resolves.not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import { useState, type ReactElement } from 'react';
|
|
||||||
import type { PendingApproval } from './use-chat-connection';
|
|
||||||
import { MAX_COMMAND_MESSAGE_CHARS } from './limits';
|
|
||||||
import { asNonEmptyString, asString } from './runtime-guards';
|
|
||||||
import type {
|
|
||||||
CommandManifest,
|
|
||||||
SlashCommandApprovalResultPayload,
|
|
||||||
SlashCommandResultPayload,
|
|
||||||
} from '@/lib/chat-contract';
|
|
||||||
|
|
||||||
/** Stable fallback copy shown for a failed command only when the server's
|
|
||||||
* own guarded, non-empty `message` (e.g. "Unknown model") is absent or
|
|
||||||
* malformed — the structured contract reason itself is otherwise shown
|
|
||||||
* directly, never a raw thrown exception, stack trace, or object value. */
|
|
||||||
const COMMAND_FAILURE_COPY = 'Command failed.';
|
|
||||||
|
|
||||||
/** Render-site defense-in-depth: `use-chat-connection.ts` already bounds a
|
|
||||||
* stored command:result message at ingestion, but this component must never
|
|
||||||
* assume every caller went through that path — bounding again here means a
|
|
||||||
* hostile/oversized message can never force an unbounded render. */
|
|
||||||
function boundMessage(value: string): string {
|
|
||||||
return value.length > MAX_COMMAND_MESSAGE_CHARS
|
|
||||||
? value.slice(0, MAX_COMMAND_MESSAGE_CHARS)
|
|
||||||
: value;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommandsPanelProps {
|
|
||||||
manifest: CommandManifest | null;
|
|
||||||
results: SlashCommandResultPayload[];
|
|
||||||
approval: SlashCommandApprovalResultPayload | null;
|
|
||||||
pendingApproval: PendingApproval | null;
|
|
||||||
hasConversation: boolean;
|
|
||||||
onExecute: (input: { command: string; args?: string }) => void;
|
|
||||||
onApprove: (input: { command: string; args?: string }) => void;
|
|
||||||
onRunApproved: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommandsPanel({
|
|
||||||
manifest,
|
|
||||||
results,
|
|
||||||
approval,
|
|
||||||
pendingApproval,
|
|
||||||
hasConversation,
|
|
||||||
onExecute,
|
|
||||||
onApprove,
|
|
||||||
onRunApproved,
|
|
||||||
}: CommandsPanelProps): ReactElement {
|
|
||||||
const [command, setCommand] = useState('');
|
|
||||||
const [args, setArgs] = useState('');
|
|
||||||
|
|
||||||
// Defense-in-depth: the reducer already normalizes success/approvalId
|
|
||||||
// before storing `approval`, but a matching command string alone must
|
|
||||||
// never be trusted here either — require the literal boolean `true` and a
|
|
||||||
// non-empty string approvalId, not merely truthy values.
|
|
||||||
const canRunApproved =
|
|
||||||
approval?.success === true &&
|
|
||||||
typeof approval.approvalId === 'string' &&
|
|
||||||
approval.approvalId.length > 0 &&
|
|
||||||
!!pendingApproval &&
|
|
||||||
pendingApproval.command === approval.command;
|
|
||||||
|
|
||||||
// A manifest arrives from the server as untyped JSON at runtime — guard
|
|
||||||
// both collections before mapping so a malformed manifest cannot throw.
|
|
||||||
const commands = Array.isArray(manifest?.commands) ? manifest.commands : [];
|
|
||||||
const skills = Array.isArray(manifest?.skills) ? manifest.skills : [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section aria-label="Commands" className="flex flex-col gap-2 border-b px-4 py-3 text-xs">
|
|
||||||
{commands.length > 0 ? (
|
|
||||||
<ul aria-label="Available commands" className="flex flex-col gap-1">
|
|
||||||
{commands.map((cmd, index) => (
|
|
||||||
<li key={asString(cmd?.name) || `cmd-${index}`}>
|
|
||||||
<strong>/{asString(cmd?.name)}</strong> — {asString(cmd?.description)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{skills.length > 0 ? (
|
|
||||||
<ul aria-label="Available skills" className="flex flex-col gap-1">
|
|
||||||
{skills.map((skill, index) => (
|
|
||||||
<li key={asString(skill?.name) || `skill-${index}`}>
|
|
||||||
<strong>/skill:{asString(skill?.name)}</strong> — {asString(skill?.description)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<input
|
|
||||||
aria-label="Command name"
|
|
||||||
value={command}
|
|
||||||
onChange={(event) => setCommand(event.target.value)}
|
|
||||||
placeholder="command"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
aria-label="Command arguments"
|
|
||||||
value={args}
|
|
||||||
onChange={(event) => setArgs(event.target.value)}
|
|
||||||
placeholder="args (optional)"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!hasConversation || !command.trim()}
|
|
||||||
onClick={() => onExecute({ command: command.trim(), args: args.trim() || undefined })}
|
|
||||||
>
|
|
||||||
Run command
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!hasConversation || !command.trim()}
|
|
||||||
onClick={() => onApprove({ command: command.trim(), args: args.trim() || undefined })}
|
|
||||||
>
|
|
||||||
Request approval
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{approval ? (
|
|
||||||
<div role={approval.success ? 'status' : 'alert'} className="flex items-center gap-2">
|
|
||||||
{/* A successful approval shows stable client copy only — never
|
|
||||||
the server-controlled approval.message or echoed
|
|
||||||
approval.command as the primary confirmation. The frozen local
|
|
||||||
pendingApproval below (not this line) is the sole authoritative
|
|
||||||
statement of what will run. A denial, by contrast, is not an
|
|
||||||
execution authority and safely surfaces the guarded structured
|
|
||||||
reason the server gave (e.g. "Not authorized"), falling back to
|
|
||||||
a stable copy only when absent/malformed. */}
|
|
||||||
<span>
|
|
||||||
{approval.success ? 'Approved.' : asNonEmptyString(approval.message, 'Denied.')}
|
|
||||||
</span>
|
|
||||||
{canRunApproved && pendingApproval ? (
|
|
||||||
<>
|
|
||||||
{/* Authoritative frozen local command+args — what the click below
|
|
||||||
will actually emit. The server's `approval` above is display-only
|
|
||||||
and must never be trusted to represent the executed payload. */}
|
|
||||||
<span>
|
|
||||||
Will run: /{pendingApproval.command}{' '}
|
|
||||||
{pendingApproval.args ? pendingApproval.args : '(no args)'}
|
|
||||||
</span>
|
|
||||||
<button type="button" onClick={onRunApproved}>
|
|
||||||
Run approved command
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{results.length > 0 ? (
|
|
||||||
<ul aria-label="Command results" className="flex flex-col gap-1">
|
|
||||||
{results.map((result, index) => (
|
|
||||||
<li key={`${result.command}-${index}`} role={result.success ? 'status' : 'alert'}>
|
|
||||||
/{asString(result.command)}: {result.success ? 'success' : 'failed'}
|
|
||||||
{result.success
|
|
||||||
? typeof result.message === 'string' && result.message
|
|
||||||
? ` — ${boundMessage(result.message)}`
|
|
||||||
: ''
|
|
||||||
: ` — ${boundMessage(asNonEmptyString(result.message, COMMAND_FAILURE_COPY))}`}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
|
||||||
import type { HarnessSelectionValue } from './use-harness-selection';
|
|
||||||
|
|
||||||
interface ComposerProps {
|
|
||||||
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
|
|
||||||
onStop: () => void;
|
|
||||||
streaming: boolean;
|
|
||||||
/** True from local send time through server turn startup/ack and
|
|
||||||
* throughout streaming — a superset of `streaming` that also covers the
|
|
||||||
* pre-ack window where a second send could otherwise slip through. */
|
|
||||||
sending: boolean;
|
|
||||||
hasConversation: boolean;
|
|
||||||
/** Structured harness/provider/model selection state. The composer never
|
|
||||||
* accepts free-text provider/model — every sendable tuple is a validated,
|
|
||||||
* persisted catalog entry, and the send projection is derived from it. */
|
|
||||||
harness: HarnessSelectionValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The distinct provider ids present in the current catalog, in first-seen
|
|
||||||
* order — the provider select is catalog-derived, never a hardcoded list. */
|
|
||||||
function providerOptions(harness: HarnessSelectionValue): string[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const out: string[] = [];
|
|
||||||
for (const model of harness.catalog?.models ?? []) {
|
|
||||||
if (seen.has(model.providerId)) continue;
|
|
||||||
seen.add(model.providerId);
|
|
||||||
out.push(model.providerId);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Composer({
|
|
||||||
onSend,
|
|
||||||
onStop,
|
|
||||||
streaming,
|
|
||||||
sending,
|
|
||||||
hasConversation,
|
|
||||||
harness,
|
|
||||||
}: ComposerProps): ReactElement {
|
|
||||||
const [content, setContent] = useState('');
|
|
||||||
const busy = streaming || sending;
|
|
||||||
|
|
||||||
function submit(): void {
|
|
||||||
if (busy) return;
|
|
||||||
// Send is gated on a validated, persisted catalog tuple — a draft or unset
|
|
||||||
// selection can never emit, so provider/model never travel as free text.
|
|
||||||
if (!harness.canSend) return;
|
|
||||||
const trimmed = content.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
onSend({ content: trimmed, ...harness.projection });
|
|
||||||
setContent('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
|
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
submit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scope the model options to the intentionally selected provider. With no
|
|
||||||
// provider chosen (`providerId === ''`) nothing matches, so the model select
|
|
||||||
// offers only the placeholder — never a cross-provider row.
|
|
||||||
const models = (harness.catalog?.models ?? []).filter(
|
|
||||||
(model) => model.providerId === harness.providerId,
|
|
||||||
);
|
|
||||||
// A collision-safe composite option identity covering the full provider+model
|
|
||||||
// tuple. The controlled select mirrors the same identity so the exact catalog
|
|
||||||
// row highlights (a bare modelId would collide across providers).
|
|
||||||
const modelOptionValue = (model: { providerId: string; modelId: string }): string =>
|
|
||||||
`${model.providerId}:${model.modelId}`;
|
|
||||||
const selectedModelValue = harness.modelId ? `${harness.providerId}:${harness.modelId}` : '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
submit();
|
|
||||||
}}
|
|
||||||
className="flex flex-col gap-2 border-t p-4"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<select
|
|
||||||
aria-label="Harness"
|
|
||||||
value={harness.harnessId}
|
|
||||||
onChange={(event) => harness.selectHarness(event.target.value)}
|
|
||||||
className="rounded border px-2 py-1 text-xs"
|
|
||||||
>
|
|
||||||
<option value="">Select a harness…</option>
|
|
||||||
{harness.harnesses.map((item) => (
|
|
||||||
<option key={item.id} value={item.id}>
|
|
||||||
{item.displayName}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
aria-label="Provider"
|
|
||||||
value={harness.providerId}
|
|
||||||
onChange={(event) => harness.selectProvider(event.target.value)}
|
|
||||||
disabled={harness.catalogUnavailable || providerOptions(harness).length === 0}
|
|
||||||
className="rounded border px-2 py-1 text-xs"
|
|
||||||
>
|
|
||||||
<option value="">Select a provider…</option>
|
|
||||||
{providerOptions(harness).map((providerId) => (
|
|
||||||
<option key={providerId} value={providerId}>
|
|
||||||
{providerId}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
aria-label="Model"
|
|
||||||
value={selectedModelValue}
|
|
||||||
onChange={(event) => {
|
|
||||||
// Resolve the composite option identity back to the exact catalog
|
|
||||||
// row and persist that row's own provider+model — never a bare id.
|
|
||||||
const selected = models.find((model) => modelOptionValue(model) === event.target.value);
|
|
||||||
if (selected) harness.selectModel(selected.providerId, selected.modelId);
|
|
||||||
}}
|
|
||||||
disabled={harness.catalogUnavailable || models.length === 0}
|
|
||||||
className="rounded border px-2 py-1 text-xs"
|
|
||||||
>
|
|
||||||
<option value="">Select a model…</option>
|
|
||||||
{models.map((model) => (
|
|
||||||
<option key={modelOptionValue(model)} value={modelOptionValue(model)}>
|
|
||||||
{model.displayName}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{harness.catalogUnavailable ? (
|
|
||||||
<p role="status" className="text-xs opacity-70">
|
|
||||||
This harness catalog is currently unavailable.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{harness.isStale ? (
|
|
||||||
<p role="status" className="text-xs opacity-70">
|
|
||||||
The saved model is no longer available — pick another to continue.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{harness.persistError ? (
|
|
||||||
<p role="alert" className="text-xs">
|
|
||||||
{harness.persistError.message}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<textarea
|
|
||||||
aria-label="Message"
|
|
||||||
value={content}
|
|
||||||
onChange={(event) => setContent(event.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
rows={2}
|
|
||||||
placeholder="Message… (Enter to send, Shift+Enter for a new line)"
|
|
||||||
className="flex-1 resize-none rounded border px-3 py-2 text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!content.trim() || busy || !harness.canSend}
|
|
||||||
className="rounded px-3 py-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Stop"
|
|
||||||
disabled={!hasConversation || !streaming}
|
|
||||||
onClick={onStop}
|
|
||||||
className="rounded px-3 py-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Bounds on server-fed chat state. A hostile or malfunctioning gateway can
|
|
||||||
* flood any of these collections; caps keep memory/render cost flat instead
|
|
||||||
* of growing unboundedly for the lifetime of the connection.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Max characters retained for the in-flight streamed text/thinking buffers. */
|
|
||||||
export const MAX_STREAM_CHARS = 20_000;
|
|
||||||
/** Max transcript turns retained (oldest dropped first). */
|
|
||||||
export const MAX_MESSAGES = 500;
|
|
||||||
/** Max tool-call entries (including anomaly entries) retained per turn history. */
|
|
||||||
export const MAX_TOOLS = 200;
|
|
||||||
/** Max slash-command results retained. */
|
|
||||||
export const MAX_COMMAND_RESULTS = 200;
|
|
||||||
/** Max commands/skills accepted from a single manifest push. */
|
|
||||||
export const MAX_MANIFEST_ITEMS = 500;
|
|
||||||
/** Max executed approval IDs remembered for single-flight dedup. */
|
|
||||||
export const MAX_EXECUTED_APPROVAL_IDS = 200;
|
|
||||||
/** Max characters retained for a single command:result message — a hostile
|
|
||||||
* or malfunctioning gateway must not be able to push an unbounded curated
|
|
||||||
* success/failure reason into state (or, defensively, onto the page). */
|
|
||||||
export const MAX_COMMAND_MESSAGE_CHARS = 1_000;
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import type { ChatTranscriptMessage } from './use-chat-connection';
|
|
||||||
|
|
||||||
interface MessageTranscriptProps {
|
|
||||||
messages: ChatTranscriptMessage[];
|
|
||||||
streaming: boolean;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MessageTranscript({
|
|
||||||
messages,
|
|
||||||
streaming,
|
|
||||||
text,
|
|
||||||
}: MessageTranscriptProps): ReactElement {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="log"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-label="Conversation"
|
|
||||||
className="flex flex-1 flex-col gap-3 overflow-y-auto p-4"
|
|
||||||
>
|
|
||||||
{messages.map((message) => (
|
|
||||||
<div key={message.id} data-role={message.role} className="whitespace-pre-wrap text-sm">
|
|
||||||
<span className="font-medium">{message.role === 'user' ? 'You' : 'Assistant'}: </span>
|
|
||||||
<span>{message.text}</span>
|
|
||||||
{message.thinking ? (
|
|
||||||
<div className="pt-1 text-xs italic opacity-70">{message.thinking}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{streaming ? (
|
|
||||||
<div data-role="assistant-streaming" className="whitespace-pre-wrap text-sm">
|
|
||||||
<span className="font-medium">Assistant: </span>
|
|
||||||
<span>{text || 'Thinking…'}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
/**
|
|
||||||
* Socket.IO payloads are only statically typed at the call site — a
|
|
||||||
* misbehaving or compromised gateway can send anything at runtime. These
|
|
||||||
* guards protect the dereference sites that would otherwise throw (`.map` on
|
|
||||||
* a non-array, `.toFixed` on a non-number) or render an object as a React
|
|
||||||
* child.
|
|
||||||
*/
|
|
||||||
import type {
|
|
||||||
HarnessAuthState,
|
|
||||||
HarnessCatalog,
|
|
||||||
HarnessCatalogEntry,
|
|
||||||
HarnessModelAvailability,
|
|
||||||
HarnessSelection,
|
|
||||||
HarnessSummary,
|
|
||||||
} from '@/lib/types';
|
|
||||||
|
|
||||||
export function asString(value: unknown, fallback = ''): string {
|
|
||||||
return typeof value === 'string' ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Like `asString`, but an empty string also falls back — used for guarded
|
|
||||||
* contract-provided reason strings (e.g. a denial or failure message) where
|
|
||||||
* an empty string is not a meaningful value to display in place of the
|
|
||||||
* stable fallback copy. */
|
|
||||||
export function asNonEmptyString(value: unknown, fallback: string): string {
|
|
||||||
return typeof value === 'string' && value.length > 0 ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function asFiniteNumber(value: unknown, fallback = 0): number {
|
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Like `asFiniteNumber`, but returns `null` on failure instead of a numeric
|
|
||||||
* fallback — callers that must not fabricate a plausible-looking value (e.g.
|
|
||||||
* `0 tokens` / `$0.0000` for genuinely unknown usage) use this to render an
|
|
||||||
* honest "unavailable" label instead. */
|
|
||||||
export function asFiniteNumberOrNull(value: unknown): number | null {
|
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function asStringArray(value: unknown): string[] {
|
|
||||||
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The HTTP harness/catalog/selection JSON bodies are as untrusted as the socket
|
|
||||||
* payloads above — a misbehaving or compromised gateway can send anything. The
|
|
||||||
* guards below normalize those bodies into the typed client shapes without ever
|
|
||||||
* rendering a raw body, so a 404/422/malformed response can never inject an
|
|
||||||
* object into React or a non-tuple into the selection state.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Normalizes an untrusted `authState` to the closed set, defaulting to the
|
|
||||||
* safest value (`unavailable`) for anything unrecognized. */
|
|
||||||
export function asHarnessAuthState(value: unknown): HarnessAuthState {
|
|
||||||
return value === 'ready' || value === 'auth_required' || value === 'unavailable'
|
|
||||||
? value
|
|
||||||
: 'unavailable';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalizes an untrusted `availability` to the closed set, defaulting to
|
|
||||||
* `unavailable` so a malformed row can never present as sendable. */
|
|
||||||
export function asHarnessAvailability(value: unknown): HarnessModelAvailability {
|
|
||||||
return value === 'available' ? 'available' : 'unavailable';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A tuple is valid only when all three ids are non-empty strings — a partial
|
|
||||||
* or malformed selection is rejected (null) rather than half-adopted. */
|
|
||||||
export function asHarnessSelection(value: unknown): HarnessSelection | null {
|
|
||||||
if (!isRecord(value)) return null;
|
|
||||||
const harnessId = value.harnessId;
|
|
||||||
const providerId = value.providerId;
|
|
||||||
const modelId = value.modelId;
|
|
||||||
if (
|
|
||||||
typeof harnessId !== 'string' ||
|
|
||||||
typeof providerId !== 'string' ||
|
|
||||||
typeof modelId !== 'string' ||
|
|
||||||
harnessId.length === 0 ||
|
|
||||||
providerId.length === 0 ||
|
|
||||||
modelId.length === 0
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { harnessId, providerId, modelId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalizes an untrusted array into typed harness summaries, dropping any row
|
|
||||||
* without a usable id. */
|
|
||||||
export function asHarnessSummaries(value: unknown): HarnessSummary[] {
|
|
||||||
if (!Array.isArray(value)) return [];
|
|
||||||
const out: HarnessSummary[] = [];
|
|
||||||
for (const item of value) {
|
|
||||||
if (!isRecord(item)) continue;
|
|
||||||
const id = asString(item.id);
|
|
||||||
if (id.length === 0) continue;
|
|
||||||
out.push({
|
|
||||||
id,
|
|
||||||
displayName: asNonEmptyString(item.displayName, id),
|
|
||||||
capabilities: asStringArray(item.capabilities),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asHarnessCatalogEntry(value: unknown): HarnessCatalogEntry | null {
|
|
||||||
const selection = asHarnessSelection(value);
|
|
||||||
if (selection === null || !isRecord(value)) return null;
|
|
||||||
return {
|
|
||||||
...selection,
|
|
||||||
displayName: asNonEmptyString(value.displayName, selection.modelId),
|
|
||||||
reasoningCapability: value.reasoningCapability === true,
|
|
||||||
inputTypes: asStringArray(value.inputTypes),
|
|
||||||
authState: asHarnessAuthState(value.authState),
|
|
||||||
availability: asHarnessAvailability(value.availability),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalizes an untrusted catalog body into the typed client catalog. The
|
|
||||||
* caller supplies `harnessId` (from the request path) so the returned catalog
|
|
||||||
* is scoped to the harness that was actually requested, never a body-echoed id.
|
|
||||||
* Malformed model rows are dropped rather than invalidating the whole catalog. */
|
|
||||||
export function asHarnessCatalog(value: unknown, harnessId: string): HarnessCatalog {
|
|
||||||
const record = isRecord(value) ? value : {};
|
|
||||||
const rawModels = Array.isArray(record.models) ? record.models : [];
|
|
||||||
const models: HarnessCatalogEntry[] = [];
|
|
||||||
for (const row of rawModels) {
|
|
||||||
const entry = asHarnessCatalogEntry(row);
|
|
||||||
if (entry !== null) models.push(entry);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
harnessId,
|
|
||||||
version: asString(record.version),
|
|
||||||
fingerprint: asString(record.fingerprint),
|
|
||||||
models,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The single point of truth for what counts as a valid conversation ID
|
|
||||||
* anywhere a scoped server event may adopt one into state — a non-empty
|
|
||||||
* string, nothing else. Every site that establishes or compares
|
|
||||||
* `state.conversationId` against a raw socket payload must route through
|
|
||||||
* this guard so a malformed first frame (null/object/number/empty string)
|
|
||||||
* can never be adopted verbatim. */
|
|
||||||
export function asConversationId(value: unknown): string | null {
|
|
||||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import type { SessionInfoPayload } from '@/lib/chat-contract';
|
|
||||||
import { MAX_MANIFEST_ITEMS } from './limits';
|
|
||||||
import { asString, asStringArray } from './runtime-guards';
|
|
||||||
|
|
||||||
interface SessionPanelProps {
|
|
||||||
sessionInfo: SessionInfoPayload | null;
|
|
||||||
onSetThinking: (level: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const THINKING_LEVEL_UNAVAILABLE = '';
|
|
||||||
|
|
||||||
export function SessionPanel({
|
|
||||||
sessionInfo,
|
|
||||||
onSetThinking,
|
|
||||||
}: SessionPanelProps): ReactElement | null {
|
|
||||||
if (!sessionInfo) return null;
|
|
||||||
|
|
||||||
// The reducer already caps this before storing it, but the render site
|
|
||||||
// defends independently — a hostile payload must never be able to force
|
|
||||||
// this <select> to lay out an unbounded number of options.
|
|
||||||
const availableThinkingLevels = asStringArray(sessionInfo.availableThinkingLevels).slice(
|
|
||||||
0,
|
|
||||||
MAX_MANIFEST_ITEMS,
|
|
||||||
);
|
|
||||||
const hasThinkingLevels = availableThinkingLevels.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
aria-label="Session info"
|
|
||||||
className="flex flex-wrap items-center gap-3 border-b px-4 py-2 text-xs"
|
|
||||||
>
|
|
||||||
<span>{asString(sessionInfo.provider, 'unknown')}</span>
|
|
||||||
<span>{asString(sessionInfo.modelId, 'unknown')}</span>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<span>Thinking level</span>
|
|
||||||
<select
|
|
||||||
aria-label="Thinking level"
|
|
||||||
value={
|
|
||||||
hasThinkingLevels ? asString(sessionInfo.thinkingLevel) : THINKING_LEVEL_UNAVAILABLE
|
|
||||||
}
|
|
||||||
onChange={(event) => {
|
|
||||||
// The placeholder option is not a real, settable level — a
|
|
||||||
// malformed availableThinkingLevels list must never let the
|
|
||||||
// client emit set:thinking for it.
|
|
||||||
if (!hasThinkingLevels) return;
|
|
||||||
onSetThinking(event.target.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hasThinkingLevels ? (
|
|
||||||
availableThinkingLevels.map((level) => (
|
|
||||||
<option key={level} value={level}>
|
|
||||||
{level}
|
|
||||||
</option>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<option value={THINKING_LEVEL_UNAVAILABLE}>Thinking level unavailable</option>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{sessionInfo.routingDecision ? (
|
|
||||||
<span title={asString(sessionInfo.routingDecision.ruleName)}>
|
|
||||||
{asString(sessionInfo.routingDecision.reason)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { vi } from 'vitest';
|
|
||||||
import type { ClientToServerEvents, ServerToClientEvents } from '@/lib/chat-contract';
|
|
||||||
|
|
||||||
type ServerEvent = keyof ServerToClientEvents;
|
|
||||||
type ClientEvent = keyof ClientToServerEvents;
|
|
||||||
type ServerHandler<K extends ServerEvent> = ServerToClientEvents[K];
|
|
||||||
type ClientPayload<K extends ClientEvent> = Parameters<ClientToServerEvents[K]>[0];
|
|
||||||
|
|
||||||
export interface EmittedEvent<K extends ClientEvent = ClientEvent> {
|
|
||||||
event: K;
|
|
||||||
payload: ClientPayload<K>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */
|
|
||||||
export interface FakeChatSocket {
|
|
||||||
connected: boolean;
|
|
||||||
connect(): FakeChatSocket;
|
|
||||||
on<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
|
||||||
off<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
|
||||||
emit<K extends ClientEvent>(event: K, payload: ClientPayload<K>): FakeChatSocket;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A typed in-memory stand-in for `getSocket()`. Unlike a bare
|
|
||||||
* `(event: string, payload: unknown) => void` mock, every public method here is
|
|
||||||
* checked against the real `/chat` contract — a typo'd event name or a payload
|
|
||||||
* missing a required field fails to compile instead of silently no-op'ing at
|
|
||||||
* runtime.
|
|
||||||
*/
|
|
||||||
/** Socket.IO's built-in connection-state events. Not part of the app-level
|
|
||||||
* ServerToClientEvents contract, but real sockets always support them and
|
|
||||||
* `useChatConnection` registers a `disconnect` handler on the real socket. */
|
|
||||||
type LifecycleEvent = 'connect' | 'disconnect';
|
|
||||||
|
|
||||||
export function createFakeChatSocket(): {
|
|
||||||
socket: FakeChatSocket;
|
|
||||||
listeners: Map<ServerEvent, Set<(payload: never) => void>>;
|
|
||||||
emitted: EmittedEvent[];
|
|
||||||
serverEmit<K extends ServerEvent>(
|
|
||||||
event: K,
|
|
||||||
payload: Parameters<ServerToClientEvents[K]>[0],
|
|
||||||
): void;
|
|
||||||
/** Escape hatch for malformed-payload tests: bypasses the compile-time
|
|
||||||
* payload contract to simulate a genuinely untrusted runtime value from the
|
|
||||||
* server, e.g. a `session:info` with a non-array `availableThinkingLevels`. */
|
|
||||||
serverEmitRaw(event: ServerEvent, payload: unknown): void;
|
|
||||||
/** Simulates a transient Socket.IO `disconnect` — fires any handler(s)
|
|
||||||
* registered via `socket.on('disconnect', ...)` without clearing any
|
|
||||||
* listeners, mirroring how a real reconnecting socket behaves. */
|
|
||||||
simulateDisconnect(): void;
|
|
||||||
/** Simulates socket.io-client's automatic reconnect of the *same*
|
|
||||||
* instance after a transient disconnect: marks the socket connected again
|
|
||||||
* and fires any handler(s) registered via `socket.on('connect', ...)`,
|
|
||||||
* without clearing or replacing any listeners. */
|
|
||||||
simulateReconnect(): void;
|
|
||||||
} {
|
|
||||||
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
|
|
||||||
const emitted: EmittedEvent[] = [];
|
|
||||||
|
|
||||||
// Internal storage is intentionally keyed loosely (the per-event handler shape
|
|
||||||
// varies by K, which a single Map can't express); the generic signatures on the
|
|
||||||
// exported `socket`/`serverEmit` above and below are what keep test call sites
|
|
||||||
// type-checked against ServerToClientEvents/ClientToServerEvents.
|
|
||||||
const socket = {
|
|
||||||
connected: false,
|
|
||||||
connect: vi.fn(function connect(this: void) {
|
|
||||||
socket.connected = true;
|
|
||||||
return socket;
|
|
||||||
}),
|
|
||||||
on: vi.fn(function on(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
|
||||||
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
||||||
listeners.get(event)?.add(handler);
|
|
||||||
return socket;
|
|
||||||
}),
|
|
||||||
off: vi.fn(function off(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
|
||||||
listeners.get(event)?.delete(handler);
|
|
||||||
return socket;
|
|
||||||
}),
|
|
||||||
emit: vi.fn(function emit(this: void, event: ClientEvent, payload: unknown) {
|
|
||||||
emitted.push({ event, payload } as EmittedEvent);
|
|
||||||
return socket;
|
|
||||||
}),
|
|
||||||
} as unknown as FakeChatSocket;
|
|
||||||
|
|
||||||
function serverEmit<K extends ServerEvent>(
|
|
||||||
event: K,
|
|
||||||
payload: Parameters<ServerToClientEvents[K]>[0],
|
|
||||||
): void {
|
|
||||||
for (const handler of listeners.get(event) ?? []) {
|
|
||||||
(handler as (payload: Parameters<ServerToClientEvents[K]>[0]) => void)(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function serverEmitRaw(event: ServerEvent, payload: unknown): void {
|
|
||||||
for (const handler of listeners.get(event) ?? []) {
|
|
||||||
(handler as (payload: unknown) => void)(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function simulateDisconnect(): void {
|
|
||||||
socket.connected = false;
|
|
||||||
const lifecycleKey = 'disconnect' satisfies LifecycleEvent as unknown as ServerEvent;
|
|
||||||
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
|
||||||
(handler as () => void)();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function simulateReconnect(): void {
|
|
||||||
socket.connected = true;
|
|
||||||
const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent;
|
|
||||||
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
|
||||||
(handler as () => void)();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
socket,
|
|
||||||
listeners,
|
|
||||||
emitted,
|
|
||||||
serverEmit,
|
|
||||||
serverEmitRaw,
|
|
||||||
simulateDisconnect,
|
|
||||||
simulateReconnect,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { ToolCallList } from './tool-call-list';
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
let root: Root | null;
|
|
||||||
let container: HTMLElement | null;
|
|
||||||
|
|
||||||
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
|
||||||
container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(node);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
document.body.replaceChildren();
|
|
||||||
root = null;
|
|
||||||
container = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ToolCallList', () => {
|
|
||||||
it('renders two entries independently, without a duplicate-key warning, when a valid toolCallId is shared', async () => {
|
|
||||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
|
|
||||||
await render(
|
|
||||||
<ToolCallList
|
|
||||||
tools={[
|
|
||||||
{ toolCallId: 'dup', toolName: 'search', status: 'success' },
|
|
||||||
{ toolCallId: 'dup', toolName: 'search', status: 'running' },
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const items = [...(container?.querySelectorAll('li') ?? [])];
|
|
||||||
expect(items).toHaveLength(2);
|
|
||||||
expect(items[0]?.textContent).toContain('success');
|
|
||||||
expect(items[1]?.textContent).toContain('running');
|
|
||||||
|
|
||||||
const duplicateKeyWarning = consoleError.mock.calls.some((args) =>
|
|
||||||
args.some((arg) => typeof arg === 'string' && arg.includes('same key')),
|
|
||||||
);
|
|
||||||
expect(duplicateKeyWarning).toBe(false);
|
|
||||||
|
|
||||||
consoleError.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import type { ToolCallState } from './use-chat-connection';
|
|
||||||
|
|
||||||
export function ToolCallList({ tools }: { tools: ToolCallState[] }): ReactElement | null {
|
|
||||||
if (tools.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul aria-label="Tool calls" className="flex flex-col gap-1 px-4 pb-2 text-xs">
|
|
||||||
{tools.map((tool, index) => (
|
|
||||||
<li
|
|
||||||
// A valid server-controlled toolCallId can legitimately repeat
|
|
||||||
// (e.g. two tool:start events sharing one id) — keying on it alone
|
|
||||||
// would give React two identical keys. Pairing it with its
|
|
||||||
// (stable, append-only) render index keeps every key unique.
|
|
||||||
key={`${tool.toolCallId}-${index}`}
|
|
||||||
role={tool.status === 'error' || tool.status === 'anomaly' ? 'alert' : 'status'}
|
|
||||||
>
|
|
||||||
{tool.toolName} —{' '}
|
|
||||||
{tool.status === 'anomaly' ? 'unexpected end (unknown tool call)' : tool.status}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,445 +0,0 @@
|
|||||||
import { act, type ReactElement } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { useHarnessSelection, type HarnessSelectionValue } from './use-harness-selection';
|
|
||||||
|
|
||||||
function json(body: unknown, status = 200): Response {
|
|
||||||
return new Response(JSON.stringify(body), {
|
|
||||||
status,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Scenario {
|
|
||||||
harnesses?: unknown;
|
|
||||||
catalog?: { body: unknown; status?: number };
|
|
||||||
selection?: unknown;
|
|
||||||
/** When set, the PUT resolves only when this is called (for race tests). */
|
|
||||||
deferPut?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Deferred<T> {
|
|
||||||
promise: Promise<T>;
|
|
||||||
resolve: (value: T) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defer<T>(): Deferred<T> {
|
|
||||||
let resolve!: (value: T) => void;
|
|
||||||
const promise = new Promise<T>((r) => {
|
|
||||||
resolve = r;
|
|
||||||
});
|
|
||||||
return { promise, resolve };
|
|
||||||
}
|
|
||||||
|
|
||||||
let putBodies: unknown[] = [];
|
|
||||||
let putDeferred: Deferred<Response> | null = null;
|
|
||||||
|
|
||||||
function installFetch(scenario: Scenario): ReturnType<typeof vi.fn> {
|
|
||||||
putBodies = [];
|
|
||||||
putDeferred = scenario.deferPut ? defer<Response>() : null;
|
|
||||||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
|
||||||
const url = String(input);
|
|
||||||
const method = String(init?.method ?? 'GET').toUpperCase();
|
|
||||||
if (url === '/api/harnesses') return json(scenario.harnesses ?? []);
|
|
||||||
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
|
|
||||||
const spec = scenario.catalog ?? {
|
|
||||||
body: { harnessId: 'pi', version: '1', fingerprint: 'f', models: [] },
|
|
||||||
};
|
|
||||||
return json(spec.body, spec.status ?? 200);
|
|
||||||
}
|
|
||||||
if (url === '/api/chat/preferences/selection' && method === 'GET') {
|
|
||||||
return json({ selection: scenario.selection ?? null });
|
|
||||||
}
|
|
||||||
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
|
||||||
putBodies.push(JSON.parse(String(init?.body)));
|
|
||||||
const ok = json({ selection: JSON.parse(String(init?.body)) });
|
|
||||||
if (putDeferred) return putDeferred.promise;
|
|
||||||
return ok;
|
|
||||||
}
|
|
||||||
return new Response('not found', { status: 404 });
|
|
||||||
});
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
return fetchMock;
|
|
||||||
}
|
|
||||||
|
|
||||||
let latest: HarnessSelectionValue | null = null;
|
|
||||||
|
|
||||||
function Probe(): ReactElement | null {
|
|
||||||
latest = useHarnessSelection();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let root: Root | null;
|
|
||||||
let container: HTMLElement;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
latest = null;
|
|
||||||
container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
document.body.replaceChildren();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function mount(): Promise<void> {
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(<Probe />);
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function flush(times = 5): Promise<void> {
|
|
||||||
for (let i = 0; i < times; i += 1) {
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function value(): HarnessSelectionValue {
|
|
||||||
if (!latest) throw new Error('hook value not captured');
|
|
||||||
return latest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PI_CATALOG = {
|
|
||||||
harnessId: 'pi',
|
|
||||||
version: '2026-08-11',
|
|
||||||
fingerprint: 'fp',
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
harnessId: 'pi',
|
|
||||||
providerId: 'openai',
|
|
||||||
modelId: 'gpt-5',
|
|
||||||
displayName: 'GPT-5',
|
|
||||||
reasoningCapability: true,
|
|
||||||
inputTypes: ['text'],
|
|
||||||
authState: 'ready',
|
|
||||||
availability: 'available',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
harnessId: 'pi',
|
|
||||||
providerId: 'anthropic',
|
|
||||||
modelId: 'claude',
|
|
||||||
displayName: 'Claude',
|
|
||||||
reasoningCapability: true,
|
|
||||||
inputTypes: ['text'],
|
|
||||||
authState: 'ready',
|
|
||||||
availability: 'available',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('useHarnessSelection', () => {
|
|
||||||
it('loads harnesses and, once a harness is chosen, the model options come only from its catalog', async () => {
|
|
||||||
installFetch({
|
|
||||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
|
||||||
catalog: { body: PI_CATALOG },
|
|
||||||
selection: null,
|
|
||||||
});
|
|
||||||
await mount();
|
|
||||||
|
|
||||||
expect(value().harnesses).toEqual([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
|
||||||
expect(value().catalog).toBeNull();
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
value().selectHarness('pi');
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(value().catalog?.harnessId).toBe('pi');
|
|
||||||
expect(value().catalog?.models.map((m) => m.modelId)).toEqual(['gpt-5', 'claude']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not auto-select any catalog row when there is no persisted selection (no first-row fallback)', async () => {
|
|
||||||
const fetchMock = installFetch({
|
|
||||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
|
||||||
catalog: { body: PI_CATALOG },
|
|
||||||
selection: null,
|
|
||||||
});
|
|
||||||
await mount();
|
|
||||||
await act(async () => {
|
|
||||||
value().selectHarness('pi');
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(value().modelId).toBe('');
|
|
||||||
expect(value().persistedSelection).toBeNull();
|
|
||||||
expect(value().canSend).toBe(false);
|
|
||||||
// Nothing was persisted — no PUT fired for an unset selection.
|
|
||||||
const putCalls = fetchMock.mock.calls.filter(
|
|
||||||
(c) => String((c[1] as RequestInit)?.method).toUpperCase() === 'PUT',
|
|
||||||
);
|
|
||||||
expect(putCalls).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('persists the structured tuple and only enables send AFTER the PUT resolves (no race ahead of persistence)', async () => {
|
|
||||||
installFetch({
|
|
||||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
|
||||||
catalog: { body: PI_CATALOG },
|
|
||||||
selection: null,
|
|
||||||
deferPut: true,
|
|
||||||
});
|
|
||||||
await mount();
|
|
||||||
await act(async () => {
|
|
||||||
value().selectHarness('pi');
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
await act(async () => {
|
|
||||||
value().selectProvider('openai');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
value().selectModel('openai', 'gpt-5');
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
// PUT is in flight (deferred) — send MUST NOT be enabled yet.
|
|
||||||
expect(value().canSend).toBe(false);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
putDeferred?.resolve(
|
|
||||||
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(putBodies).toContainEqual({ harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' });
|
|
||||||
expect(value().persistedSelection).toEqual({
|
|
||||||
harnessId: 'pi',
|
|
||||||
providerId: 'openai',
|
|
||||||
modelId: 'gpt-5',
|
|
||||||
});
|
|
||||||
expect(value().canSend).toBe(true);
|
|
||||||
expect(value().projection).toEqual({ provider: 'openai', modelId: 'gpt-5' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps a stale/unavailable persisted selection visibly displayed rather than silently dropping it', async () => {
|
|
||||||
installFetch({
|
|
||||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
|
||||||
catalog: { body: PI_CATALOG },
|
|
||||||
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'retired-model' },
|
|
||||||
});
|
|
||||||
await mount();
|
|
||||||
|
|
||||||
// The persisted tuple is displayed even though its model is gone from the catalog.
|
|
||||||
expect(value().persistedSelection).toEqual({
|
|
||||||
harnessId: 'pi',
|
|
||||||
providerId: 'openai',
|
|
||||||
modelId: 'retired-model',
|
|
||||||
});
|
|
||||||
expect(value().modelId).toBe('retired-model');
|
|
||||||
expect(value().isStale).toBe(true);
|
|
||||||
// A stale model is not a valid catalog option, so send stays disabled.
|
|
||||||
expect(value().canSend).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables send for an empty catalog (no viable model) and never fabricates one', async () => {
|
|
||||||
installFetch({
|
|
||||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
|
||||||
catalog: { body: { harnessId: 'pi', version: '1', fingerprint: 'f', models: [] } },
|
|
||||||
selection: null,
|
|
||||||
});
|
|
||||||
await mount();
|
|
||||||
await act(async () => {
|
|
||||||
value().selectHarness('pi');
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(value().catalog?.models ?? []).toHaveLength(0);
|
|
||||||
expect(value().canSend).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks the catalog unavailable and disables send when the catalog request 404s', async () => {
|
|
||||||
installFetch({
|
|
||||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
|
||||||
catalog: {
|
|
||||||
body: { code: 'adapter_unavailable', message: 'x', harnessId: 'pi' },
|
|
||||||
status: 404,
|
|
||||||
},
|
|
||||||
selection: null,
|
|
||||||
});
|
|
||||||
await mount();
|
|
||||||
await act(async () => {
|
|
||||||
value().selectHarness('pi');
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(value().catalogUnavailable).toBe(true);
|
|
||||||
expect(value().canSend).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('on a 422 persist, keeps the requested tuple visible, surfaces a typed error, and leaves send disabled', async () => {
|
|
||||||
installFetch({
|
|
||||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
|
||||||
catalog: {
|
|
||||||
body: {
|
|
||||||
...PI_CATALOG,
|
|
||||||
models: [{ ...PI_CATALOG.models[0], availability: 'unavailable' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
selection: null,
|
|
||||||
});
|
|
||||||
// Override PUT to 422.
|
|
||||||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
|
||||||
const url = String(input);
|
|
||||||
const method = String(init?.method ?? 'GET').toUpperCase();
|
|
||||||
if (url === '/api/harnesses')
|
|
||||||
return json([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
|
||||||
if (url.endsWith('/catalog')) return json(PI_CATALOG);
|
|
||||||
if (url === '/api/chat/preferences/selection' && method === 'GET')
|
|
||||||
return json({ selection: null });
|
|
||||||
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
|
||||||
return json(
|
|
||||||
{
|
|
||||||
code: 'model_unavailable',
|
|
||||||
message: 'nope',
|
|
||||||
selection: { harnessId: 'a', providerId: 'b', modelId: 'c' },
|
|
||||||
},
|
|
||||||
422,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return new Response('nf', { status: 404 });
|
|
||||||
});
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
await mount();
|
|
||||||
await act(async () => {
|
|
||||||
value().selectHarness('pi');
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
await act(async () => {
|
|
||||||
value().selectProvider('openai');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
value().selectModel('openai', 'gpt-5');
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(value().modelId).toBe('gpt-5');
|
|
||||||
expect(value().persistError?.code).toBe('model_unavailable');
|
|
||||||
expect(value().persistError?.requested).toEqual({
|
|
||||||
harnessId: 'pi',
|
|
||||||
providerId: 'openai',
|
|
||||||
modelId: 'gpt-5',
|
|
||||||
});
|
|
||||||
expect(value().persistedSelection).toBeNull();
|
|
||||||
expect(value().canSend).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('invalidates the model on a provider change and keeps send disabled until the new tuple persists', async () => {
|
|
||||||
installFetch({
|
|
||||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
|
||||||
catalog: { body: PI_CATALOG },
|
|
||||||
selection: null,
|
|
||||||
});
|
|
||||||
await mount();
|
|
||||||
await act(async () => {
|
|
||||||
value().selectHarness('pi');
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
await act(async () => {
|
|
||||||
value().selectProvider('openai');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
value().selectModel('openai', 'gpt-5');
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
// A valid provider-A tuple has persisted.
|
|
||||||
expect(value().canSend).toBe(true);
|
|
||||||
expect(value().persistedSelection).toEqual({
|
|
||||||
harnessId: 'pi',
|
|
||||||
providerId: 'openai',
|
|
||||||
modelId: 'gpt-5',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Switching provider clears the model that no longer belongs to it.
|
|
||||||
await act(async () => {
|
|
||||||
value().selectProvider('anthropic');
|
|
||||||
});
|
|
||||||
expect(value().modelId).toBe('');
|
|
||||||
expect(value().canSend).toBe(false);
|
|
||||||
|
|
||||||
// Send stays disabled until the new exact provider-B tuple persists.
|
|
||||||
await act(async () => {
|
|
||||||
value().selectModel('anthropic', 'claude');
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
expect(value().canSend).toBe(true);
|
|
||||||
expect(value().persistedSelection).toEqual({
|
|
||||||
harnessId: 'pi',
|
|
||||||
providerId: 'anthropic',
|
|
||||||
modelId: 'claude',
|
|
||||||
});
|
|
||||||
expect(value().projection).toEqual({ provider: 'anthropic', modelId: 'claude' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not enable send on a model pick until the PUT for that exact new tuple resolves', async () => {
|
|
||||||
installFetch({
|
|
||||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
|
||||||
catalog: { body: PI_CATALOG },
|
|
||||||
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
|
||||||
deferPut: true,
|
|
||||||
});
|
|
||||||
await mount();
|
|
||||||
// The persisted, in-catalog tuple is sendable after mount (no PUT needed).
|
|
||||||
expect(value().canSend).toBe(true);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
value().selectProvider('anthropic');
|
|
||||||
});
|
|
||||||
expect(value().modelId).toBe('');
|
|
||||||
expect(value().canSend).toBe(false);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
value().selectModel('anthropic', 'claude');
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
// PUT for the new tuple is still in flight — send MUST stay disabled.
|
|
||||||
expect(value().canSend).toBe(false);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
putDeferred?.resolve(
|
|
||||||
json({ selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' } }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
expect(value().canSend).toBe(true);
|
|
||||||
expect(value().projection).toEqual({ provider: 'anthropic', modelId: 'claude' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never requests any /api/providers* endpoint across the whole flow', async () => {
|
|
||||||
const fetchMock = installFetch({
|
|
||||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
|
||||||
catalog: { body: PI_CATALOG },
|
|
||||||
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
|
||||||
});
|
|
||||||
await mount();
|
|
||||||
await act(async () => {
|
|
||||||
value().selectProvider('anthropic');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
value().selectModel('anthropic', 'claude');
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
for (const call of fetchMock.mock.calls) {
|
|
||||||
expect(String(call[0])).not.toContain('/api/providers');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import {
|
|
||||||
fetchCatalog,
|
|
||||||
fetchHarnesses,
|
|
||||||
fetchPersistedSelection,
|
|
||||||
persistSelection,
|
|
||||||
type SelectionErrorCode,
|
|
||||||
} from './chat-api';
|
|
||||||
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
|
|
||||||
|
|
||||||
export interface HarnessPersistError {
|
|
||||||
code: SelectionErrorCode;
|
|
||||||
message: string;
|
|
||||||
/** The exact tuple the user requested — preserved so the failed selection
|
|
||||||
* stays visible rather than being silently dropped. */
|
|
||||||
requested: HarnessSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessSelectionValue {
|
|
||||||
harnesses: HarnessSummary[];
|
|
||||||
catalog: HarnessCatalog | null;
|
|
||||||
/** True when the selected harness has no usable catalog (404/error). */
|
|
||||||
catalogUnavailable: boolean;
|
|
||||||
/** The working (displayed) selection, kept as three distinct ids. Empty
|
|
||||||
* strings mean "not chosen yet" — there is deliberately no first-row default. */
|
|
||||||
harnessId: string;
|
|
||||||
providerId: string;
|
|
||||||
modelId: string;
|
|
||||||
/** The last tuple confirmed persisted by the server, or null. */
|
|
||||||
persistedSelection: HarnessSelection | null;
|
|
||||||
/** True when a persisted selection references a model no longer present as an
|
|
||||||
* available catalog entry — it stays visibly displayed rather than dropped. */
|
|
||||||
isStale: boolean;
|
|
||||||
/** True ONLY once a full tuple has been confirmed persisted AND it is a
|
|
||||||
* currently-available catalog entry. Send stays disabled otherwise, so a send
|
|
||||||
* can never race ahead of successful persistence. */
|
|
||||||
canSend: boolean;
|
|
||||||
persistError: HarnessPersistError | null;
|
|
||||||
selectHarness: (harnessId: string) => void;
|
|
||||||
selectProvider: (providerId: string) => void;
|
|
||||||
/** Persist the EXACT catalog row's `{providerId, modelId}` — the caller
|
|
||||||
* resolves the composite option identity to the real entry and passes both
|
|
||||||
* ids, so a bare model id is never combined with ambient provider state. */
|
|
||||||
selectModel: (providerId: string, modelId: string) => void;
|
|
||||||
/** The compatibility `{provider, modelId}` projection for the legacy socket
|
|
||||||
* send path — derived ONLY from the validated persisted tuple, never from any
|
|
||||||
* free-text or unpersisted draft. Empty when nothing is sendable. */
|
|
||||||
projection: { provider?: string; modelId?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A tuple is a currently-usable catalog option only when the catalog holds a
|
|
||||||
* matching, available entry — the single gate that keeps a stale/unavailable
|
|
||||||
* model from ever counting as sendable. */
|
|
||||||
function isAvailableInCatalog(
|
|
||||||
selection: HarnessSelection | null,
|
|
||||||
catalog: HarnessCatalog | null,
|
|
||||||
): boolean {
|
|
||||||
if (selection === null || catalog === null) return false;
|
|
||||||
return catalog.models.some(
|
|
||||||
(model) =>
|
|
||||||
model.providerId === selection.providerId &&
|
|
||||||
model.modelId === selection.modelId &&
|
|
||||||
model.availability === 'available',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function tuplesEqual(a: HarnessSelection | null, b: HarnessSelection | null): boolean {
|
|
||||||
if (a === null || b === null) return a === b;
|
|
||||||
return a.harnessId === b.harnessId && a.providerId === b.providerId && a.modelId === b.modelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns the harness/catalog/selection state for the chat composer: loads the
|
|
||||||
* harness list and any persisted tuple on mount, loads a harness's catalog when
|
|
||||||
* chosen, and PUT-persists the full `{harnessId, providerId, modelId}` tuple
|
|
||||||
* when a model is picked. It never auto-selects a catalog row, keeps a
|
|
||||||
* stale/unavailable persisted tuple visible, and only reports `canSend` true
|
|
||||||
* once a full tuple has actually persisted as an available catalog entry.
|
|
||||||
*/
|
|
||||||
export function useHarnessSelection(): HarnessSelectionValue {
|
|
||||||
const [harnesses, setHarnesses] = useState<HarnessSummary[]>([]);
|
|
||||||
const [catalog, setCatalog] = useState<HarnessCatalog | null>(null);
|
|
||||||
const [catalogUnavailable, setCatalogUnavailable] = useState(false);
|
|
||||||
const [harnessId, setHarnessId] = useState('');
|
|
||||||
const [providerId, setProviderId] = useState('');
|
|
||||||
const [modelId, setModelId] = useState('');
|
|
||||||
const [persistedSelection, setPersistedSelection] = useState<HarnessSelection | null>(null);
|
|
||||||
const [persistError, setPersistError] = useState<HarnessPersistError | null>(null);
|
|
||||||
|
|
||||||
// Monotonic request ids so a slow in-flight catalog/persist response can never
|
|
||||||
// overwrite the result of a newer request the user has since triggered.
|
|
||||||
const catalogRequestRef = useRef(0);
|
|
||||||
const persistRequestRef = useRef(0);
|
|
||||||
|
|
||||||
const loadCatalog = useCallback(async (id: string): Promise<void> => {
|
|
||||||
const requestId = catalogRequestRef.current + 1;
|
|
||||||
catalogRequestRef.current = requestId;
|
|
||||||
setCatalog(null);
|
|
||||||
setCatalogUnavailable(false);
|
|
||||||
const result = await fetchCatalog(id);
|
|
||||||
if (catalogRequestRef.current !== requestId) return;
|
|
||||||
if (result.ok) {
|
|
||||||
setCatalog(result.catalog);
|
|
||||||
setCatalogUnavailable(false);
|
|
||||||
} else {
|
|
||||||
setCatalog(null);
|
|
||||||
setCatalogUnavailable(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
void (async (): Promise<void> => {
|
|
||||||
const [list, persisted] = await Promise.all([fetchHarnesses(), fetchPersistedSelection()]);
|
|
||||||
if (!active) return;
|
|
||||||
setHarnesses(list);
|
|
||||||
if (persisted !== null) {
|
|
||||||
// Adopt the persisted tuple as the displayed selection and load its
|
|
||||||
// catalog. If the model has since been retired, it still shows (stale).
|
|
||||||
setHarnessId(persisted.harnessId);
|
|
||||||
setProviderId(persisted.providerId);
|
|
||||||
setModelId(persisted.modelId);
|
|
||||||
setPersistedSelection(persisted);
|
|
||||||
await loadCatalog(persisted.harnessId);
|
|
||||||
}
|
|
||||||
// No persisted selection → nothing is auto-selected; the user must choose.
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [loadCatalog]);
|
|
||||||
|
|
||||||
const selectHarness = useCallback(
|
|
||||||
(id: string): void => {
|
|
||||||
setHarnessId(id);
|
|
||||||
// Changing harness invalidates the provider/model draft — never carry a
|
|
||||||
// model across harnesses.
|
|
||||||
setProviderId('');
|
|
||||||
setModelId('');
|
|
||||||
setPersistError(null);
|
|
||||||
void loadCatalog(id);
|
|
||||||
},
|
|
||||||
[loadCatalog],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectProvider = useCallback((id: string): void => {
|
|
||||||
setProviderId(id);
|
|
||||||
// A new provider invalidates the chosen model — no cross-provider carryover.
|
|
||||||
setModelId('');
|
|
||||||
setPersistError(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectModel = useCallback(
|
|
||||||
(selectedProviderId: string, selectedModelId: string): void => {
|
|
||||||
// Bind the model to the EXACT catalog row's provider — never to ambient
|
|
||||||
// provider state — so two providers exposing the same modelId can never
|
|
||||||
// collide or mis-resolve. Keep the displayed provider consistent with the
|
|
||||||
// resolved row.
|
|
||||||
setProviderId(selectedProviderId);
|
|
||||||
setModelId(selectedModelId);
|
|
||||||
setPersistError(null);
|
|
||||||
const requested: HarnessSelection = {
|
|
||||||
harnessId,
|
|
||||||
providerId: selectedProviderId,
|
|
||||||
modelId: selectedModelId,
|
|
||||||
};
|
|
||||||
const requestId = persistRequestRef.current + 1;
|
|
||||||
persistRequestRef.current = requestId;
|
|
||||||
void (async (): Promise<void> => {
|
|
||||||
const result = await persistSelection(requested);
|
|
||||||
if (persistRequestRef.current !== requestId) return;
|
|
||||||
if (result.ok) {
|
|
||||||
setPersistedSelection(result.selection);
|
|
||||||
setPersistError(null);
|
|
||||||
} else {
|
|
||||||
// Leave persistedSelection unchanged (send stays disabled) and surface
|
|
||||||
// the typed error carrying the exact requested tuple.
|
|
||||||
setPersistError({
|
|
||||||
code: result.code,
|
|
||||||
message: result.message,
|
|
||||||
requested: result.requested,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
[harnessId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const draft: HarnessSelection = { harnessId, providerId, modelId };
|
|
||||||
const isStale = persistedSelection !== null && !isAvailableInCatalog(persistedSelection, catalog);
|
|
||||||
const canSend =
|
|
||||||
persistedSelection !== null &&
|
|
||||||
!catalogUnavailable &&
|
|
||||||
tuplesEqual(draft, persistedSelection) &&
|
|
||||||
isAvailableInCatalog(persistedSelection, catalog);
|
|
||||||
const projection: { provider?: string; modelId?: string } = canSend
|
|
||||||
? { provider: persistedSelection.providerId, modelId: persistedSelection.modelId }
|
|
||||||
: {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
harnesses,
|
|
||||||
catalog,
|
|
||||||
catalogUnavailable,
|
|
||||||
harnessId,
|
|
||||||
providerId,
|
|
||||||
modelId,
|
|
||||||
persistedSelection,
|
|
||||||
isStale,
|
|
||||||
canSend,
|
|
||||||
persistError,
|
|
||||||
selectHarness,
|
|
||||||
selectProvider,
|
|
||||||
selectModel,
|
|
||||||
projection,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user