Compare commits
1 Commits
main
...
fix/idempo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a08da9164a |
12
.env.example
12
.env.example
@@ -23,8 +23,8 @@ VALKEY_URL=redis://localhost:6380
|
|||||||
|
|
||||||
|
|
||||||
# ─── Gateway ─────────────────────────────────────────────────────────────────
|
# ─── Gateway ─────────────────────────────────────────────────────────────────
|
||||||
# TCP port the NestJS/Fastify gateway listens on (default: 14242)
|
# TCP port the NestJS/Fastify gateway listens on (default: 4000)
|
||||||
GATEWAY_PORT=14242
|
GATEWAY_PORT=4000
|
||||||
|
|
||||||
# Comma-separated list of allowed CORS origins.
|
# Comma-separated list of allowed CORS origins.
|
||||||
# Must include the web app origin in production.
|
# Must include the web app origin in production.
|
||||||
@@ -37,12 +37,12 @@ GATEWAY_CORS_ORIGIN=http://localhost:3000
|
|||||||
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
||||||
|
|
||||||
# Public base URL of the gateway (used by BetterAuth for callback URLs)
|
# Public base URL of the gateway (used by BetterAuth for callback URLs)
|
||||||
BETTER_AUTH_URL=http://localhost:14242
|
BETTER_AUTH_URL=http://localhost:4000
|
||||||
|
|
||||||
|
|
||||||
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
|
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
|
||||||
# Public gateway URL — accessible from the browser, not just the server.
|
# Public gateway URL — accessible from the browser, not just the server.
|
||||||
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
|
NEXT_PUBLIC_GATEWAY_URL=http://localhost:4000
|
||||||
|
|
||||||
|
|
||||||
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
|
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
|
||||||
@@ -121,12 +121,12 @@ OTEL_SERVICE_NAME=mosaic-gateway
|
|||||||
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
|
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
|
||||||
# DISCORD_BOT_TOKEN=
|
# DISCORD_BOT_TOKEN=
|
||||||
# DISCORD_GUILD_ID=
|
# DISCORD_GUILD_ID=
|
||||||
# DISCORD_GATEWAY_URL=http://localhost:14242
|
# DISCORD_GATEWAY_URL=http://localhost:4000
|
||||||
|
|
||||||
|
|
||||||
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
|
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
|
||||||
# TELEGRAM_BOT_TOKEN=
|
# TELEGRAM_BOT_TOKEN=
|
||||||
# TELEGRAM_GATEWAY_URL=http://localhost:14242
|
# TELEGRAM_GATEWAY_URL=http://localhost:4000
|
||||||
|
|
||||||
|
|
||||||
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
|
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
|
||||||
|
|||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -9,16 +9,3 @@ coverage
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
docs/reports/
|
docs/reports/
|
||||||
|
|
||||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
|
||||||
infra/step-ca/dev-password
|
|
||||||
|
|
||||||
# Scratch dirs created by the framework git-wrapper shell test harnesses
|
|
||||||
.mosaic-test-work/
|
|
||||||
|
|
||||||
# Transient config files vite/vitest/esbuild write next to a *.config.ts while
|
|
||||||
# loading it, then unlink. They are untracked but were not ignored, so turbo's
|
|
||||||
# package traversal hashed them and intermittently failed CI with "Package
|
|
||||||
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
|
|
||||||
# file vanished mid-scan. Ignoring them removes the race.
|
|
||||||
*.timestamp-*.mjs
|
|
||||||
|
|||||||
6
.npmrc
6
.npmrc
@@ -1,5 +1 @@
|
|||||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
@mosaic:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
|
||||||
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
|
|
||||||
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
|
||||||
# instead of repopulating a fresh one.
|
|
||||||
store-dir=/root/.local/share/pnpm/store
|
|
||||||
|
|||||||
@@ -5,5 +5,3 @@ pnpm-lock.yaml
|
|||||||
**/drizzle
|
**/drizzle
|
||||||
**/.next
|
**/.next
|
||||||
.claude/
|
.claude/
|
||||||
docs/tess/TASKS.md
|
|
||||||
docs/scratchpads/
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
# Build & push the pre-baked CI base image (Dockerfile.ci) to the Gitea
|
|
||||||
# registry CI already publishes to. Reuses the exact kaniko + auth pattern
|
|
||||||
# from publish.yml (REGISTRY_USER/REGISTRY_PASS from_secret, /kaniko/.docker
|
|
||||||
# config.json). Other pipelines (ci.yml, publish.yml) pull `ci-base:latest`
|
|
||||||
# for their install step.
|
|
||||||
#
|
|
||||||
# Rebuild ONLY when the dependency set or the image recipe changes — a normal
|
|
||||||
# code push must not trigger a 25-min image build. `path` applies to push/PR
|
|
||||||
# events; `event: tag` (releases) rebuilds unconditionally so a tagged release
|
|
||||||
# always ships a fresh base.
|
|
||||||
when:
|
|
||||||
- event: tag
|
|
||||||
- event: [push, manual]
|
|
||||||
branch: main
|
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- 'pnpm-lock.yaml'
|
|
||||||
- 'Dockerfile.ci'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
build-ci-base:
|
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
|
||||||
environment:
|
|
||||||
REGISTRY_USER:
|
|
||||||
from_secret: gitea_username
|
|
||||||
REGISTRY_PASS:
|
|
||||||
from_secret: gitea_password
|
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
|
||||||
commands:
|
|
||||||
- mkdir -p /kaniko/.docker
|
|
||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
|
||||||
- |
|
|
||||||
# Lockfile-hash tag: an immutable identity for the exact dep set baked
|
|
||||||
# into this image. `:latest` is the mutable pointer pipelines consume.
|
|
||||||
LOCK_HASH=$(sha256sum pnpm-lock.yaml | cut -c1-12)
|
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/ci-base:latest"
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-$LOCK_HASH"
|
|
||||||
/kaniko/executor --context . --dockerfile Dockerfile.ci $DESTINATIONS
|
|
||||||
@@ -1,20 +1,9 @@
|
|||||||
# &node_image is the pre-baked CI base built by .woodpecker/ci-image.yml:
|
|
||||||
# node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm
|
|
||||||
# store. The install step resolves from the baked store (--prefer-offline)
|
|
||||||
# instead of paying a ~731s cold fetch + native compile every run.
|
|
||||||
variables:
|
variables:
|
||||||
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
- &node_image 'node:22-alpine'
|
||||||
- &enable_pnpm 'corepack enable'
|
- &enable_pnpm 'corepack enable'
|
||||||
|
|
||||||
when:
|
when:
|
||||||
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
|
- event: [push, pull_request, manual]
|
||||||
# push CI is restricted to protected branches (main) so a feature-branch push no
|
|
||||||
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
|
|
||||||
# CI load on the storage-constrained runner with zero loss of gating (branch
|
|
||||||
# protection requires no push/ci status context; main still gets full push CI).
|
|
||||||
- event: [pull_request, manual]
|
|
||||||
- event: push
|
|
||||||
branch: main
|
|
||||||
|
|
||||||
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||||
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||||
@@ -26,21 +15,7 @@ steps:
|
|||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
- corepack enable
|
- corepack enable
|
||||||
# python3/make/g++ are baked into ci-base; --prefer-offline resolves from
|
- pnpm install --frozen-lockfile
|
||||||
# the baked pnpm store.
|
|
||||||
- pnpm install --frozen-lockfile --prefer-offline
|
|
||||||
|
|
||||||
# Blocking gate: public framework package must contain no operator-specific
|
|
||||||
# personal data or private $HOME defaults. Runs early (no node_modules needed).
|
|
||||||
sanitization:
|
|
||||||
image: *node_image
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache bash
|
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
|
|
||||||
# Resident line-count ceiling over framework-owned resident files
|
|
||||||
# (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
|
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
@@ -49,7 +24,6 @@ steps:
|
|||||||
- pnpm typecheck
|
- pnpm typecheck
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
- sanitization
|
|
||||||
|
|
||||||
# lint, format, and test are independent — run in parallel after typecheck
|
# lint, format, and test are independent — run in parallel after typecheck
|
||||||
lint:
|
lint:
|
||||||
@@ -71,36 +45,27 @@ steps:
|
|||||||
test:
|
test:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
# Avoid the namespace-level Woodpecker DB service named "postgres".
|
DATABASE_URL: postgresql://mosaic:mosaic@postgres:5432/mosaic
|
||||||
# The Kubernetes backend exposes service containers by step name.
|
|
||||||
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
# postgresql-client (pg_isready) is baked into ci-base.
|
# Install postgresql-client for pg_isready
|
||||||
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
- apk add --no-cache postgresql-client
|
||||||
|
# Wait up to 30s for postgres to be ready
|
||||||
- |
|
- |
|
||||||
ready=0
|
for i in $(seq 1 30); do
|
||||||
for i in $(seq 1 60); do
|
pg_isready -h postgres -p 5432 -U mosaic && break
|
||||||
if pg_isready -h ci-postgres -p 5432 -U mosaic; then
|
echo "Waiting for postgres ($i/30)..."
|
||||||
ready=1
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "Waiting for ci-postgres ($i/60)..."
|
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
if [ "$ready" -ne 1 ]; then
|
|
||||||
echo "ci-postgres did not become ready" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# Run migrations (DATABASE_URL is set in environment above)
|
# Run migrations (DATABASE_URL is set in environment above)
|
||||||
- pnpm --filter @mosaicstack/db run db:migrate
|
- pnpm --filter @mosaic/db run db:migrate
|
||||||
# Run all tests
|
# Run all tests
|
||||||
- pnpm test
|
- pnpm test
|
||||||
depends_on:
|
depends_on:
|
||||||
- typecheck
|
- typecheck
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ci-postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: mosaic
|
POSTGRES_USER: mosaic
|
||||||
|
|||||||
@@ -2,27 +2,8 @@
|
|||||||
# Runs only on main branch push/tag
|
# Runs only on main branch push/tag
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
- &node_image 'node:22-alpine'
|
||||||
# toolchain + warm pnpm store. Kills the second cold install publish pays.
|
|
||||||
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
|
||||||
- &enable_pnpm 'corepack enable'
|
- &enable_pnpm 'corepack enable'
|
||||||
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
|
|
||||||
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
|
|
||||||
# images (gateway/appservice/web do not depend on @mosaicstack/mosaic). Releases
|
|
||||||
# (tags) always build everything. Exclude-list keeps the default SAFE: any
|
|
||||||
# non-excluded change still builds, so no transitive dep can silently go stale.
|
|
||||||
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
|
||||||
# the separate `event: tag` entry.)
|
|
||||||
- &image_build_when
|
|
||||||
- event: tag
|
|
||||||
- event: [push, manual]
|
|
||||||
branch: main
|
|
||||||
path:
|
|
||||||
exclude:
|
|
||||||
- 'packages/mosaic/**'
|
|
||||||
- 'docs/**'
|
|
||||||
- '**/*.md'
|
|
||||||
- '.woodpecker/**'
|
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main]
|
||||||
@@ -33,8 +14,7 @@ steps:
|
|||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
- corepack enable
|
- corepack enable
|
||||||
# Resolve from the baked pnpm store instead of a cold network fetch.
|
- pnpm install --frozen-lockfile
|
||||||
- pnpm install --frozen-lockfile --prefer-offline
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
@@ -46,15 +26,6 @@ steps:
|
|||||||
|
|
||||||
publish-npm:
|
publish-npm:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
# Publish only when a publishable package changed (or on a release tag); a
|
|
||||||
# pure-docs merge runs no publish. Cheap step, but gated for cleanliness.
|
|
||||||
when:
|
|
||||||
- event: tag
|
|
||||||
- event: [push, manual]
|
|
||||||
branch: main
|
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- 'packages/**'
|
|
||||||
environment:
|
environment:
|
||||||
NPM_TOKEN:
|
NPM_TOKEN:
|
||||||
from_secret: gitea_token
|
from_secret: gitea_token
|
||||||
@@ -62,65 +33,21 @@ steps:
|
|||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
# Configure auth for Gitea npm registry
|
# Configure auth for Gitea npm registry
|
||||||
- |
|
- |
|
||||||
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
echo "//git.mosaicstack.dev/api/packages/mosaic/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
||||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
echo "@mosaic:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/" >> ~/.npmrc
|
||||||
# Publish non-private packages to Gitea.
|
# Publish all non-private packages (--no-git-checks skips dirty/branch checks in CI)
|
||||||
#
|
# --filter excludes private apps (gateway, web) and the root
|
||||||
# The only publish failure we tolerate is "version already exists" —
|
- >
|
||||||
# that legitimately happens when only some packages were bumped in
|
pnpm --filter "@mosaic/*"
|
||||||
# the merge. Any other failure (registry 404, auth error, network
|
--filter "!@mosaic/gateway"
|
||||||
# error) MUST fail the pipeline loudly: the previous
|
--filter "!@mosaic/web"
|
||||||
# `|| echo "... continuing"` fallback silently hid a 404 from the
|
publish --no-git-checks --access public
|
||||||
# Gitea org rename and caused every @mosaicstack/* publish to fall
|
|| echo "[publish] Some packages may already exist at this version — continuing"
|
||||||
# on the floor while CI still reported green.
|
|
||||||
- |
|
|
||||||
# Portable sh (Alpine ash) — avoid bashisms like PIPESTATUS.
|
|
||||||
set +e
|
|
||||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" publish --no-git-checks --access public >/tmp/publish.log 2>&1
|
|
||||||
EXIT=$?
|
|
||||||
set -e
|
|
||||||
cat /tmp/publish.log
|
|
||||||
if [ "$EXIT" -eq 0 ]; then
|
|
||||||
echo "[publish] all packages published successfully"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
# Hard registry / auth / network errors → fatal. Match npm's own
|
|
||||||
# error lines specifically to avoid false positives on arbitrary
|
|
||||||
# log text that happens to contain "E404" etc.
|
|
||||||
if grep -qE "npm (error|ERR!) code (E404|E401|ENEEDAUTH|ECONNREFUSED|ETIMEDOUT|ENOTFOUND)" /tmp/publish.log; then
|
|
||||||
echo "[publish] FATAL: registry/auth/network error detected — failing pipeline" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# Only tolerate the explicit "version already published" case.
|
|
||||||
# npm returns this as E403 with body "You cannot publish over..."
|
|
||||||
# or EPUBLISHCONFLICT depending on version.
|
|
||||||
if grep -qE "EPUBLISHCONFLICT|You cannot publish over|previously published" /tmp/publish.log; then
|
|
||||||
echo "[publish] some packages already at this version — continuing (non-fatal)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "[publish] FATAL: publish failed with unrecognized error — failing pipeline" >&2
|
|
||||||
exit 1
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
|
|
||||||
# TODO: Uncomment when ready to publish to npmjs.org
|
|
||||||
# publish-npmjs:
|
|
||||||
# image: *node_image
|
|
||||||
# environment:
|
|
||||||
# NPM_TOKEN:
|
|
||||||
# from_secret: npmjs_token
|
|
||||||
# commands:
|
|
||||||
# - *enable_pnpm
|
|
||||||
# - apk add --no-cache jq bash
|
|
||||||
# - bash scripts/publish-npmjs.sh
|
|
||||||
# depends_on:
|
|
||||||
# - build
|
|
||||||
# when:
|
|
||||||
# - event: [tag]
|
|
||||||
|
|
||||||
build-gateway:
|
build-gateway:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -133,46 +60,19 @@ steps:
|
|||||||
- mkdir -p /kaniko/.docker
|
- mkdir -p /kaniko/.docker
|
||||||
- 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/mosaic/mosaic-stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaic/mosaic-stack/gateway:latest"
|
||||||
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/mosaic/mosaic-stack/gateway:$CI_COMMIT_TAG"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
|
|
||||||
build-appservice:
|
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
|
||||||
when: *image_build_when
|
|
||||||
environment:
|
|
||||||
REGISTRY_USER:
|
|
||||||
from_secret: gitea_username
|
|
||||||
REGISTRY_PASS:
|
|
||||||
from_secret: gitea_password
|
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
|
||||||
commands:
|
|
||||||
- mkdir -p /kaniko/.docker
|
|
||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
|
||||||
- |
|
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
|
||||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
|
||||||
fi
|
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
|
||||||
fi
|
|
||||||
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
|
|
||||||
depends_on:
|
|
||||||
- build
|
|
||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -185,12 +85,12 @@ steps:
|
|||||||
- mkdir -p /kaniko/.docker
|
- mkdir -p /kaniko/.docker
|
||||||
- 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/web:sha-${CI_COMMIT_SHA:0:7}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/mosaic-stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaic/mosaic-stack/web:latest"
|
||||||
fi
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaic/mosaic-stack/web:$CI_COMMIT_TAG"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
16
AGENTS.md
16
AGENTS.md
@@ -21,11 +21,11 @@ Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo
|
|||||||
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
||||||
| `packages/types` | Shared TypeScript contracts | class-validator |
|
| `packages/types` | Shared TypeScript contracts | class-validator |
|
||||||
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
|
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
|
||||||
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
| `packages/auth` | BetterAuth configuration | better-auth, @mosaic/db |
|
||||||
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
|
| `packages/brain` | Data layer (PG-backed) | @mosaic/db |
|
||||||
| `packages/queue` | Valkey task queue + MCP | ioredis |
|
| `packages/queue` | Valkey task queue + MCP | ioredis |
|
||||||
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
| `packages/coord` | Mission coordination | @mosaic/queue |
|
||||||
| `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander |
|
| `packages/cli` | Unified CLI + Pi TUI | Ink, Pi SDK |
|
||||||
| `plugins/discord` | Discord channel plugin | discord.js |
|
| `plugins/discord` | Discord channel plugin | discord.js |
|
||||||
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
||||||
|
|
||||||
@@ -33,9 +33,9 @@ Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo
|
|||||||
|
|
||||||
1. Gateway is the single API surface — all clients connect through it
|
1. Gateway is the single API surface — all clients connect through it
|
||||||
2. Pi SDK is ESM-only — gateway and CLI must use ESM
|
2. Pi SDK is ESM-only — gateway and CLI must use ESM
|
||||||
3. Socket.IO typed events defined in `@mosaicstack/types` enforce compile-time contracts
|
3. Socket.IO typed events defined in `@mosaic/types` enforce compile-time contracts
|
||||||
4. OTEL auto-instrumentation loads before NestJS bootstrap
|
4. OTEL auto-instrumentation loads before NestJS bootstrap
|
||||||
5. BetterAuth manages auth tables; schema defined in `@mosaicstack/db`
|
5. BetterAuth manages auth tables; schema defined in `@mosaic/db`
|
||||||
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
|
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
|
||||||
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
|
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
|
||||||
|
|
||||||
@@ -59,9 +59,9 @@ pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
|||||||
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
|
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
|
||||||
|
|
||||||
| Value | When to use | Budget |
|
| Value | When to use | Budget |
|
||||||
| --------- | ----------------------------------------------------------- | -------------------------- |
|
| -------- | ----------------------------------------------------------- | -------------------------- |
|
||||||
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
|
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
|
||||||
| `glm-5.1` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
|
| `glm-5` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
|
||||||
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
|
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
|
||||||
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
|
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
|
||||||
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
|
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
|
||||||
|
|||||||
10
CLAUDE.md
10
CLAUDE.md
@@ -10,7 +10,7 @@ Self-hosted, multi-user AI agent platform. TypeScript monorepo.
|
|||||||
- **Web**: Next.js 16 + React 19 (`apps/web`)
|
- **Web**: Next.js 16 + React 19 (`apps/web`)
|
||||||
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
|
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
|
||||||
- **Auth**: BetterAuth (`packages/auth`)
|
- **Auth**: BetterAuth (`packages/auth`)
|
||||||
- **Agent**: Pi SDK (`packages/agent`, `packages/mosaic`)
|
- **Agent**: Pi SDK (`packages/agent`, `packages/cli`)
|
||||||
- **Queue**: Valkey 8 (`packages/queue`)
|
- **Queue**: Valkey 8 (`packages/queue`)
|
||||||
- **Build**: pnpm workspaces + Turborepo
|
- **Build**: pnpm workspaces + Turborepo
|
||||||
- **CI**: Woodpecker CI
|
- **CI**: Woodpecker CI
|
||||||
@@ -26,13 +26,13 @@ pnpm test # Vitest (all packages)
|
|||||||
pnpm build # Build all packages
|
pnpm build # Build all packages
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
pnpm --filter @mosaicstack/db db:push # Push schema to PG (dev)
|
pnpm --filter @mosaic/db db:push # Push schema to PG (dev)
|
||||||
pnpm --filter @mosaicstack/db db:generate # Generate migrations
|
pnpm --filter @mosaic/db db:generate # Generate migrations
|
||||||
pnpm --filter @mosaicstack/db db:migrate # Run migrations
|
pnpm --filter @mosaic/db db:migrate # Run migrations
|
||||||
|
|
||||||
# Dev
|
# Dev
|
||||||
docker compose up -d # Start PG, Valkey, OTEL, Jaeger
|
docker compose up -d # Start PG, Valkey, OTEL, Jaeger
|
||||||
pnpm --filter @mosaicstack/gateway exec tsx src/main.ts # Start gateway
|
pnpm --filter @mosaic/gateway exec tsx src/main.ts # Start gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
# Pre-baked CI base image for Woodpecker pipelines.
|
|
||||||
#
|
|
||||||
# Purpose: eliminate the cold `pnpm install` that dominates every pipeline
|
|
||||||
# (~731s median). This image ships the native toolchain (no per-run `apk add`)
|
|
||||||
# AND a warm, content-addressable pnpm store with the dependency-tree tarballs
|
|
||||||
# already fetched at build time. `pnpm fetch` only populates the store from the
|
|
||||||
# lockfile — it does NOT run the native node-gyp builds (better-sqlite3,
|
|
||||||
# node-pty, sqlite3, canvas, sharp); those still compile at `pnpm install`,
|
|
||||||
# which is exactly why the musl toolchain stays baked into this image. A
|
|
||||||
# pipeline `pnpm install --frozen-lockfile --prefer-offline` then resolves
|
|
||||||
# tarballs from local hard-links (no network) and compiles natives against the
|
|
||||||
# already-present toolchain, in tens of seconds instead of ~731s.
|
|
||||||
#
|
|
||||||
# Rebuilt only when `pnpm-lock.yaml` or this Dockerfile change
|
|
||||||
# (see .woodpecker/ci-image.yml).
|
|
||||||
#
|
|
||||||
# Node version is pinned to 24 (Active LTS). This is the follow-up bump from
|
|
||||||
# node:22 — sequenced AFTER the CI cache work landed so the runtime change
|
|
||||||
# carries zero cache variables. node:26 stays held until it reaches LTS
|
|
||||||
# (Oct 2026); the Current line risks native-module (node-gyp) breakage on a
|
|
||||||
# runner that compiles better-sqlite3 / canvas / sharp / node-pty from source.
|
|
||||||
FROM node:24-alpine
|
|
||||||
|
|
||||||
# Native toolchain required to compile node-gyp deps on musl, plus the
|
|
||||||
# postgresql-client used by the test step's pg_isready readiness probe. `bash`
|
|
||||||
# is baked here too — the sanitization step in ci.yml otherwise does a per-run
|
|
||||||
# `apk add bash`.
|
|
||||||
RUN apk add --no-cache python3 make g++ postgresql-client bash
|
|
||||||
|
|
||||||
# Pin pnpm to the repo's packageManager version via corepack.
|
|
||||||
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Pin the store location so the pipeline can point `store-dir` at the same path.
|
|
||||||
ENV PNPM_HOME=/root/.local/share/pnpm
|
|
||||||
RUN pnpm config set store-dir /root/.local/share/pnpm/store
|
|
||||||
|
|
||||||
# Warm the store. `pnpm fetch` populates the content-addressable store with the
|
|
||||||
# dependency tarballs directly from the lockfile (no package.json / workspace
|
|
||||||
# needed), so a baked store stays valid until the lockfile changes. Note:
|
|
||||||
# `fetch` does NOT compile native modules — that happens later at `pnpm install`
|
|
||||||
# in the pipeline, against the toolchain baked above.
|
|
||||||
COPY pnpm-lock.yaml ./
|
|
||||||
RUN pnpm fetch --frozen-lockfile
|
|
||||||
21
LICENSE
21
LICENSE
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Mosaic Stack
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
152
README.md
152
README.md
@@ -7,39 +7,26 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
|
|||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
|
||||||
|
|
||||||
Or use the direct URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL …) --yes # Accept all defaults
|
|
||||||
bash <(curl -fsSL …) --yes --no-auto-launch # Install only, skip wizard
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs both components:
|
This installs both components:
|
||||||
|
|
||||||
| Component | What | Where |
|
| Component | What | Where |
|
||||||
| ----------------------- | ---------------------------------------------------------------- | -------------------- |
|
| --------------- | ----------------------------------------------------- | -------------------- |
|
||||||
| **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/` |
|
| **@mosaic/cli** | TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||||
|
|
||||||
After install, the wizard runs automatically or you can invoke it manually:
|
After install, set up your agent identity:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic wizard # Full guided setup (gateway install → verify)
|
mosaic init # Interactive wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Node.js ≥ 20
|
- Node.js ≥ 20
|
||||||
- npm (for global @mosaicstack/mosaic install)
|
- npm (for global @mosaic/cli install)
|
||||||
- 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)
|
- 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)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -58,40 +45,14 @@ mosaic yolo pi # Pi in yolo mode
|
|||||||
|
|
||||||
The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md` standards into the runtime, and forwards all arguments.
|
The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md` standards into the runtime, and forwards all arguments.
|
||||||
|
|
||||||
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
|
|
||||||
|
|
||||||
### TUI & Gateway
|
### TUI & Gateway
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic tui # Interactive TUI connected to the gateway
|
mosaic tui # Interactive TUI connected to the gateway
|
||||||
mosaic gateway login # Authenticate with a gateway instance
|
mosaic login # Authenticate with a gateway instance
|
||||||
mosaic sessions list # List active agent sessions
|
mosaic sessions list # List active agent sessions
|
||||||
```
|
```
|
||||||
|
|
||||||
### Gateway Management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic gateway install # Install and configure the gateway service
|
|
||||||
mosaic gateway verify # Post-install health check
|
|
||||||
mosaic gateway login # Authenticate and store a session token
|
|
||||||
mosaic gateway config rotate-token # Rotate your API token
|
|
||||||
mosaic gateway config recover-token # Recover a token via BetterAuth cookie
|
|
||||||
```
|
|
||||||
|
|
||||||
If you already have a gateway account but no token, use `mosaic gateway config recover-token` to retrieve one without recreating your account.
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
Mosaic supports three storage tiers: `local` (PGlite, single-host), `standalone` (PostgreSQL, single-host), and `federated` (PostgreSQL + pgvector + Valkey, multi-host). See [Federated Tier Setup](docs/federation/SETUP.md) for multi-user and production deployments, or [Migrating to Federated](docs/guides/migrate-tier.md) to upgrade from existing tiers.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic config show # Print full config as JSON
|
|
||||||
mosaic config get <key> # Read a specific key
|
|
||||||
mosaic config set <key> <val># Write a key
|
|
||||||
mosaic config edit # Open config in $EDITOR
|
|
||||||
mosaic config path # Print config file path
|
|
||||||
```
|
|
||||||
|
|
||||||
### Management
|
### Management
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -104,80 +65,6 @@ mosaic coord init # Initialize a new orchestration mission
|
|||||||
mosaic prdy init # Create a PRD via guided session
|
mosaic prdy init # Create a PRD via guided session
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sub-package Commands
|
|
||||||
|
|
||||||
Each Mosaic sub-package exposes its API surface through the unified CLI:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# User management
|
|
||||||
mosaic auth users list
|
|
||||||
mosaic auth users create
|
|
||||||
mosaic auth sso
|
|
||||||
|
|
||||||
# Agent brain (projects, missions, tasks)
|
|
||||||
mosaic brain projects
|
|
||||||
mosaic brain missions
|
|
||||||
mosaic brain tasks
|
|
||||||
mosaic brain conversations
|
|
||||||
|
|
||||||
# Agent forge pipeline
|
|
||||||
mosaic forge run
|
|
||||||
mosaic forge status
|
|
||||||
mosaic forge resume
|
|
||||||
mosaic forge personas
|
|
||||||
|
|
||||||
# Structured logging
|
|
||||||
mosaic log tail
|
|
||||||
mosaic log search
|
|
||||||
mosaic log export
|
|
||||||
mosaic log level
|
|
||||||
|
|
||||||
# MACP protocol
|
|
||||||
mosaic macp tasks
|
|
||||||
mosaic macp submit
|
|
||||||
mosaic macp gate
|
|
||||||
mosaic macp events
|
|
||||||
|
|
||||||
# Agent memory
|
|
||||||
mosaic memory search
|
|
||||||
mosaic memory stats
|
|
||||||
mosaic memory insights
|
|
||||||
mosaic memory preferences
|
|
||||||
|
|
||||||
# Task queue (Valkey)
|
|
||||||
mosaic queue list
|
|
||||||
mosaic queue stats
|
|
||||||
mosaic queue pause
|
|
||||||
mosaic queue resume
|
|
||||||
mosaic queue jobs
|
|
||||||
mosaic queue drain
|
|
||||||
|
|
||||||
# Object storage
|
|
||||||
mosaic storage status
|
|
||||||
mosaic storage tier
|
|
||||||
mosaic storage export
|
|
||||||
mosaic storage import
|
|
||||||
mosaic storage migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Telemetry
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Local observability (OTEL / Jaeger)
|
|
||||||
mosaic telemetry local status
|
|
||||||
mosaic telemetry local tail
|
|
||||||
mosaic telemetry local jaeger
|
|
||||||
|
|
||||||
# Remote telemetry (dry-run by default)
|
|
||||||
mosaic telemetry status
|
|
||||||
mosaic telemetry opt-in
|
|
||||||
mosaic telemetry opt-out
|
|
||||||
mosaic telemetry test
|
|
||||||
mosaic telemetry upload # Dry-run unless opted in
|
|
||||||
```
|
|
||||||
|
|
||||||
Consent state is persisted in config. Remote upload is a no-op until you run `mosaic telemetry opt-in`.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -189,8 +76,8 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
|
|||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@git.mosaicstack.dev:mosaicstack/stack.git
|
git clone git@git.mosaicstack.dev:mosaic/mosaic-stack.git
|
||||||
cd stack
|
cd mosaic-stack
|
||||||
|
|
||||||
# Start infrastructure (Postgres, Valkey, Jaeger)
|
# Start infrastructure (Postgres, Valkey, Jaeger)
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -199,7 +86,7 @@ docker compose up -d
|
|||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# Run migrations
|
# Run migrations
|
||||||
pnpm --filter @mosaicstack/db run db:migrate
|
pnpm --filter @mosaic/db run db:migrate
|
||||||
|
|
||||||
# Start all services in dev mode
|
# Start all services in dev mode
|
||||||
pnpm dev
|
pnpm dev
|
||||||
@@ -239,12 +126,13 @@ npm packages are published to the Gitea package registry on main merges.
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
stack/
|
mosaic-stack/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
|
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
|
||||||
│ └── web/ Next.js dashboard (React 19, Tailwind)
|
│ └── web/ Next.js dashboard (React 19, Tailwind)
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── mosaic/ Unified CLI — TUI, gateway client, wizard, sub-package commands
|
│ ├── cli/ Mosaic CLI — TUI, gateway client, wizard
|
||||||
|
│ ├── mosaic/ Framework — wizard, runtime detection, update checker
|
||||||
│ ├── types/ Shared TypeScript contracts (Socket.IO typed events)
|
│ ├── types/ Shared TypeScript contracts (Socket.IO typed events)
|
||||||
│ ├── db/ Drizzle ORM schema + migrations (pgvector)
|
│ ├── db/ Drizzle ORM schema + migrations (pgvector)
|
||||||
│ ├── auth/ BetterAuth configuration
|
│ ├── auth/ BetterAuth configuration
|
||||||
@@ -265,7 +153,7 @@ stack/
|
|||||||
│ ├── macp/ OpenClaw MACP runtime plugin
|
│ ├── macp/ OpenClaw MACP runtime plugin
|
||||||
│ └── mosaic-framework/ OpenClaw framework injection plugin
|
│ └── mosaic-framework/ OpenClaw framework injection plugin
|
||||||
├── tools/
|
├── tools/
|
||||||
│ └── install.sh Unified installer (framework + npm CLI, --yes / --no-auto-launch)
|
│ └── install.sh Unified installer (framework + npm CLI)
|
||||||
├── scripts/agent/ Agent session lifecycle scripts
|
├── scripts/agent/ Agent session lifecycle scripts
|
||||||
├── docker-compose.yml Dev infrastructure
|
├── docker-compose.yml Dev infrastructure
|
||||||
└── .woodpecker/ CI pipeline configs
|
└── .woodpecker/ CI pipeline configs
|
||||||
@@ -275,7 +163,7 @@ stack/
|
|||||||
|
|
||||||
- **Gateway is the single API surface** — all clients (TUI, web, Discord, Telegram) connect through it
|
- **Gateway is the single API surface** — all clients (TUI, web, Discord, Telegram) connect through it
|
||||||
- **ESM everywhere** — `"type": "module"`, `.js` extensions in imports, NodeNext resolution
|
- **ESM everywhere** — `"type": "module"`, `.js` extensions in imports, NodeNext resolution
|
||||||
- **Socket.IO typed events** — defined in `@mosaicstack/types`, enforced at compile time
|
- **Socket.IO typed events** — defined in `@mosaic/types`, enforced at compile time
|
||||||
- **OTEL auto-instrumentation** — loads before NestJS bootstrap
|
- **OTEL auto-instrumentation** — loads before NestJS bootstrap
|
||||||
- **Explicit `@Inject()` decorators** — required since tsx/esbuild doesn't emit decorator metadata
|
- **Explicit `@Inject()` decorators** — required since tsx/esbuild doesn't emit decorator metadata
|
||||||
|
|
||||||
@@ -312,13 +200,7 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding),
|
|||||||
Run the installer again — it handles upgrades automatically:
|
Run the installer again — it handles upgrades automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
|
||||||
|
|
||||||
Or use the direct URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the CLI:
|
Or use the CLI:
|
||||||
@@ -337,8 +219,6 @@ 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 --ref v1.0 # Install from a specific git ref
|
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
|
||||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@mosaicstack/mosaic-as",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"type": "module",
|
|
||||||
"private": true,
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
|
||||||
"directory": "apps/appservice"
|
|
||||||
},
|
|
||||||
"main": "dist/main.js",
|
|
||||||
"bin": {
|
|
||||||
"mosaic-as": "dist/main.js",
|
|
||||||
"mosaic-as-registration": "dist/registration-main.js"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"lint": "eslint src",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"test": "vitest run --passWithNoTests",
|
|
||||||
"dev": "tsx watch src/main.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@mosaicstack/appservice": "workspace:*"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"tsx": "^4.19.0",
|
|
||||||
"typescript": "^5.8.0",
|
|
||||||
"vitest": "^2.0.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { AppserviceDaemon } from '../server.js';
|
|
||||||
import type { DaemonConfig, DaemonRequest } from '../server.js';
|
|
||||||
|
|
||||||
const AGENTS_TYPE = 'org.uscllc.mosaic_as.agents';
|
|
||||||
|
|
||||||
const cfg: DaemonConfig = {
|
|
||||||
homeserverUrl: 'https://hs.example',
|
|
||||||
domain: 'hs.example',
|
|
||||||
asToken: 'as-secret',
|
|
||||||
hsToken: 'hs-secret',
|
|
||||||
bridgeTokens: ['bridge-secret'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const jsonResponse = (status: number, body: unknown): Response =>
|
|
||||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
|
||||||
|
|
||||||
const request = (overrides: Partial<DaemonRequest>): DaemonRequest => ({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/',
|
|
||||||
searchParams: new URLSearchParams(),
|
|
||||||
body: undefined,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeDaemon = () => {
|
|
||||||
const fetchMock = vi.fn(async (_input: URL | string) => jsonResponse(200, { event_id: '$sent' }));
|
|
||||||
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
|
|
||||||
return { daemon, fetchMock };
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('AppserviceDaemon routing', () => {
|
|
||||||
it('serves health unauthenticated', async () => {
|
|
||||||
const { daemon } = makeDaemon();
|
|
||||||
expect((await daemon.handle(request({ path: '/health' }))).status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('404s unknown paths', async () => {
|
|
||||||
const { daemon } = makeDaemon();
|
|
||||||
expect((await daemon.handle(request({ path: '/nope' }))).status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('transactions require the hs_token', async () => {
|
|
||||||
const { daemon } = makeDaemon();
|
|
||||||
const bad = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'PUT',
|
|
||||||
path: '/_matrix/app/v1/transactions/t1',
|
|
||||||
authorizationHeader: 'Bearer wrong',
|
|
||||||
body: { events: [] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(bad.status).toBe(403);
|
|
||||||
const ok = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'PUT',
|
|
||||||
path: '/_matrix/app/v1/transactions/t1',
|
|
||||||
authorizationHeader: 'Bearer hs-secret',
|
|
||||||
body: { events: [{ type: 'm.room.message', event_id: '$e' }] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(ok.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bridge requires a bridge token (hs/as tokens do not work)', async () => {
|
|
||||||
const { daemon } = makeDaemon();
|
|
||||||
for (const token of [undefined, 'Bearer hs-secret', 'Bearer as-secret', 'Bearer nope']) {
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/messages',
|
|
||||||
authorizationHeader: token,
|
|
||||||
body: {},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(403);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bridge message sends as the agent and returns the event id', async () => {
|
|
||||||
const { daemon, fetchMock } = makeDaemon();
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/messages',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi', thread_root: '$req' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.event_id).toBe('$sent');
|
|
||||||
const sendCall = fetchMock.mock.calls
|
|
||||||
.map((c) => new URL(String(c[0])))
|
|
||||||
.find((u) => u.pathname.includes('/send/m.room.message/'));
|
|
||||||
expect(sendCall).toBeDefined();
|
|
||||||
expect(sendCall!.searchParams.get('user_id')).toBe('@agent-pi0-web1:hs.example');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bridge rejects invalid payloads with 400', async () => {
|
|
||||||
const { daemon } = makeDaemon();
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/messages',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: { room_id: 'bad', agent: 'pi0', body: 'x' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bridge typing endpoint works', async () => {
|
|
||||||
const { daemon, fetchMock } = makeDaemon();
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/typing',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: { room_id: '!r:hs.example', agent: 'pi0-web1', typing: true },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const typingCall = fetchMock.mock.calls
|
|
||||||
.map((c) => new URL(String(c[0])))
|
|
||||||
.find((u) => u.pathname.includes('/typing/'));
|
|
||||||
expect(typingCall).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('authenticated unknown bridge sub-paths return 405, never fall through', async () => {
|
|
||||||
const { daemon } = makeDaemon();
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/bridge/v1/unknown',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(405);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('provisions a room as the AS sender with space linking', async () => {
|
|
||||||
const calls: Array<{ url: URL; body: unknown }> = [];
|
|
||||||
const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
|
|
||||||
const url = new URL(String(input));
|
|
||||||
calls.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined });
|
|
||||||
if (url.pathname.endsWith('/createRoom'))
|
|
||||||
return jsonResponse(200, { room_id: '!new:hs.example' });
|
|
||||||
return jsonResponse(200, {});
|
|
||||||
});
|
|
||||||
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/provision/rooms',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: {
|
|
||||||
name: 'proj-x',
|
|
||||||
alias: 'mosaic-proj-x',
|
|
||||||
invite: ['@jason.woltje:hs.example'],
|
|
||||||
space_id: '!space:hs.example',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.room_id).toBe('!new:hs.example');
|
|
||||||
expect(res.body.space_linked).toBe(true);
|
|
||||||
const create = calls.find((c) => c.url.pathname.endsWith('/createRoom'));
|
|
||||||
expect(create!.url.searchParams.get('user_id')).toBe('@mosaic-as:hs.example');
|
|
||||||
const body = create!.body as Record<string, unknown>;
|
|
||||||
expect(body.room_alias_name).toBe('mosaic-proj-x');
|
|
||||||
expect((body.power_level_content_override as Record<string, unknown>).users).toEqual({
|
|
||||||
'@mosaic-as:hs.example': 100,
|
|
||||||
});
|
|
||||||
expect(calls.some((c) => c.url.pathname.includes('/state/m.space.child/'))).toBe(true);
|
|
||||||
expect(calls.some((c) => c.url.pathname.includes('/state/m.space.parent/'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('space-link failure still returns the room id (no orphan)', async () => {
|
|
||||||
const fetchMock = vi.fn(async (input: URL | string) => {
|
|
||||||
const url = new URL(String(input));
|
|
||||||
if (url.pathname.endsWith('/createRoom'))
|
|
||||||
return jsonResponse(200, { room_id: '!new:hs.example' });
|
|
||||||
if (url.pathname.includes('/state/m.space.child/'))
|
|
||||||
return jsonResponse(403, { errcode: 'M_FORBIDDEN', error: 'no PL in space' });
|
|
||||||
return jsonResponse(200, {});
|
|
||||||
});
|
|
||||||
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/provision/rooms',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: { name: 'proj-x', space_id: '!space:hs.example' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.room_id).toBe('!new:hs.example');
|
|
||||||
expect(res.body.space_linked).toBe(false);
|
|
||||||
expect(String(res.body.space_error)).toContain('403');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('invite list cap enforced', async () => {
|
|
||||||
const { daemon } = makeDaemon();
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/provision/rooms',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: { name: 'x', invite: Array.from({ length: 51 }, (_, i) => `@u${i}:hs`) },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('provision rejects bad payloads and requires auth', async () => {
|
|
||||||
const { daemon } = makeDaemon();
|
|
||||||
const noAuth = await daemon.handle(
|
|
||||||
request({ method: 'POST', path: '/bridge/v1/provision/rooms', body: { name: 'x' } }),
|
|
||||||
);
|
|
||||||
expect(noAuth.status).toBe(403);
|
|
||||||
const bad = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/provision/rooms',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: { name: '', alias: 'BAD ALIAS' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(bad.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
// A daemon whose fetch mock backs account_data with a mutable in-test object,
|
|
||||||
// so register/verify/revoke round-trip through the (faked) homeserver.
|
|
||||||
const makeAgentDaemon = () => {
|
|
||||||
const accountData: { value: Record<string, unknown> | null } = { value: null };
|
|
||||||
const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
|
|
||||||
const url = new URL(String(input));
|
|
||||||
const path = url.pathname;
|
|
||||||
if (path.includes(`/account_data/${AGENTS_TYPE}`)) {
|
|
||||||
if (init?.method === 'PUT') {
|
|
||||||
accountData.value = JSON.parse(String(init.body)) as Record<string, unknown>;
|
|
||||||
return jsonResponse(200, {});
|
|
||||||
}
|
|
||||||
if (accountData.value === null) {
|
|
||||||
return jsonResponse(404, { errcode: 'M_NOT_FOUND', error: 'not found' });
|
|
||||||
}
|
|
||||||
return jsonResponse(200, accountData.value);
|
|
||||||
}
|
|
||||||
if (path.endsWith('/register')) return jsonResponse(200, { user_id: 'whatever' });
|
|
||||||
if (path.includes('/send/m.room.message/')) return jsonResponse(200, { event_id: '$sent' });
|
|
||||||
return jsonResponse(200, {});
|
|
||||||
});
|
|
||||||
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
|
|
||||||
return { daemon, fetchMock };
|
|
||||||
};
|
|
||||||
|
|
||||||
const registerAgent = async (
|
|
||||||
daemon: AppserviceDaemon,
|
|
||||||
body: Record<string, unknown> = { alias: 'pi0', host: 'web1' },
|
|
||||||
) =>
|
|
||||||
daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/agents',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
it('host token registers an agent and returns agent_user_id + bridge_token', async () => {
|
|
||||||
const { daemon, fetchMock } = makeAgentDaemon();
|
|
||||||
const res = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.agent_user_id).toBe('@agent-pi0-web1:hs.example');
|
|
||||||
expect(String(res.body.bridge_token).startsWith('magt_')).toBe(true);
|
|
||||||
const registerCall = fetchMock.mock.calls
|
|
||||||
.map((c) => new URL(String(c[0])))
|
|
||||||
.find((u) => u.pathname.endsWith('/register'));
|
|
||||||
expect(registerCall).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('register requires a HOST token (agent token and no token are 403)', async () => {
|
|
||||||
const { daemon } = makeAgentDaemon();
|
|
||||||
const minted = await registerAgent(daemon);
|
|
||||||
const agentToken = String(minted.body.bridge_token);
|
|
||||||
|
|
||||||
const asAgent = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/agents',
|
|
||||||
authorizationHeader: `Bearer ${agentToken}`,
|
|
||||||
body: { alias: 'pi1', host: 'web2' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(asAgent.status).toBe(403);
|
|
||||||
|
|
||||||
const noAuth = await daemon.handle(
|
|
||||||
request({ method: 'POST', path: '/bridge/v1/agents', body: { alias: 'pi1', host: 'web2' } }),
|
|
||||||
);
|
|
||||||
expect(noAuth.status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('agent-scoped token may send as itself but not as another agent', async () => {
|
|
||||||
const { daemon } = makeAgentDaemon();
|
|
||||||
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
|
|
||||||
const agentToken = String(minted.body.bridge_token);
|
|
||||||
|
|
||||||
const self = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/messages',
|
|
||||||
authorizationHeader: `Bearer ${agentToken}`,
|
|
||||||
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(self.status).toBe(200);
|
|
||||||
|
|
||||||
const other = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/messages',
|
|
||||||
authorizationHeader: `Bearer ${agentToken}`,
|
|
||||||
body: { room_id: '!r:hs.example', agent: 'pi9-web9', body: 'hi' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(other.status).toBe(403);
|
|
||||||
expect(other.body.error).toBe('token not scoped to this agent');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('revoked agent token is rejected on messages', async () => {
|
|
||||||
const { daemon } = makeAgentDaemon();
|
|
||||||
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
|
|
||||||
const agentToken = String(minted.body.bridge_token);
|
|
||||||
|
|
||||||
const revoke = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/agents/revoke',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: { agent_user_id: '@agent-pi0-web1:hs.example' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(revoke.status).toBe(200);
|
|
||||||
expect(revoke.body.revoked).toBe(1);
|
|
||||||
|
|
||||||
const afterRevoke = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/messages',
|
|
||||||
authorizationHeader: `Bearer ${agentToken}`,
|
|
||||||
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(afterRevoke.status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /bridge/v1/agents lists registered agents (host only)', async () => {
|
|
||||||
const { daemon } = makeAgentDaemon();
|
|
||||||
await registerAgent(daemon, { alias: 'pi0', host: 'web1', display_name: 'Pi Zero' });
|
|
||||||
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/bridge/v1/agents',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const agents = res.body.agents as Array<Record<string, unknown>>;
|
|
||||||
expect(agents).toHaveLength(1);
|
|
||||||
expect(agents[0]?.agent_user_id).toBe('@agent-pi0-web1:hs.example');
|
|
||||||
expect(agents[0]?.display_name).toBe('Pi Zero');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('empty bridge token list denies everything', async () => {
|
|
||||||
const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {});
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/typing',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: {},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(403);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { DaemonConfig } from './server.js';
|
|
||||||
|
|
||||||
const required = (name: string): string => {
|
|
||||||
const value = process.env[name];
|
|
||||||
if (!value) throw new Error(`missing required env var ${name}`);
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function configFromEnv(): DaemonConfig & { port: number } {
|
|
||||||
return {
|
|
||||||
homeserverUrl: required('MOSAIC_AS_HOMESERVER_URL'),
|
|
||||||
domain: required('MOSAIC_AS_DOMAIN'),
|
|
||||||
asToken: required('MOSAIC_AS_TOKEN'),
|
|
||||||
hsToken: required('MOSAIC_HS_TOKEN'),
|
|
||||||
userPrefix: process.env.MOSAIC_AS_USER_PREFIX ?? 'agent-',
|
|
||||||
senderLocalpart: process.env.MOSAIC_AS_SENDER_LOCALPART ?? 'mosaic-as',
|
|
||||||
bridgeTokens: (process.env.MOSAIC_AS_BRIDGE_TOKENS ?? '')
|
|
||||||
.split(',')
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
port: Number(process.env.MOSAIC_AS_PORT ?? 8008),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import http from 'node:http';
|
|
||||||
|
|
||||||
import { configFromEnv } from './config.js';
|
|
||||||
import { AppserviceDaemon } from './server.js';
|
|
||||||
|
|
||||||
const cfg = configFromEnv();
|
|
||||||
const daemon = new AppserviceDaemon(cfg);
|
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 1024 * 1024;
|
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
let received = 0;
|
|
||||||
let rejected = false;
|
|
||||||
req.on('data', (chunk: Buffer) => {
|
|
||||||
received += chunk.length;
|
|
||||||
if (received > MAX_BODY_BYTES) {
|
|
||||||
rejected = true;
|
|
||||||
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ errcode: 'M_TOO_LARGE', error: 'request body too large' }));
|
|
||||||
req.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
chunks.push(chunk);
|
|
||||||
});
|
|
||||||
req.on('end', () => {
|
|
||||||
if (rejected) return;
|
|
||||||
void (async () => {
|
|
||||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
||||||
let body: unknown;
|
|
||||||
try {
|
|
||||||
const raw = Buffer.concat(chunks).toString();
|
|
||||||
body = raw ? JSON.parse(raw) : undefined;
|
|
||||||
} catch {
|
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ errcode: 'M_NOT_JSON', error: 'invalid json' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await daemon.handle({
|
|
||||||
method: req.method ?? 'GET',
|
|
||||||
path: url.pathname,
|
|
||||||
searchParams: url.searchParams,
|
|
||||||
authorizationHeader: req.headers.authorization,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
res.writeHead(result.status, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(result.body));
|
|
||||||
})().catch((error: unknown) => {
|
|
||||||
console.error('request failed:', error);
|
|
||||||
if (res.headersSent) {
|
|
||||||
res.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ error: 'internal error' }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(cfg.port, () => {
|
|
||||||
console.log(
|
|
||||||
`mosaic-as listening on :${cfg.port} (homeserver ${cfg.homeserverUrl}, domain ${cfg.domain})`,
|
|
||||||
);
|
|
||||||
if (cfg.bridgeTokens.length === 0) {
|
|
||||||
console.warn('WARNING: MOSAIC_AS_BRIDGE_TOKENS is empty — bridge API will deny all requests');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { buildRegistration, registrationToYaml } from '@mosaicstack/appservice';
|
|
||||||
|
|
||||||
import { configFromEnv } from './config.js';
|
|
||||||
|
|
||||||
// Prints the Synapse registration YAML (mosaic-as.yaml) for the current env.
|
|
||||||
// Usage: MOSAIC_AS_URL=http://mosaic-as:8008 mosaic-as-registration > mosaic-as.yaml
|
|
||||||
const cfg = configFromEnv();
|
|
||||||
const url = process.env.MOSAIC_AS_URL;
|
|
||||||
if (!url) throw new Error('missing required env var MOSAIC_AS_URL');
|
|
||||||
process.stdout.write(registrationToYaml(buildRegistration(cfg, { url })));
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
||||||
|
|
||||||
import {
|
|
||||||
AgentTokenStore,
|
|
||||||
AppserviceIntent,
|
|
||||||
TransactionHandler,
|
|
||||||
validateBridgeMessage,
|
|
||||||
validateBridgeTyping,
|
|
||||||
validateProvisionRoom,
|
|
||||||
validateRegisterAgent,
|
|
||||||
validateRevokeAgent,
|
|
||||||
} from '@mosaicstack/appservice';
|
|
||||||
import type { AppserviceConfig, MatrixEvent } from '@mosaicstack/appservice';
|
|
||||||
|
|
||||||
export interface DaemonConfig extends AppserviceConfig {
|
|
||||||
/** Bearer tokens accepted on /bridge/v1/* (one per agent-comms host daemon). */
|
|
||||||
bridgeTokens: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DaemonRequest {
|
|
||||||
method: string;
|
|
||||||
/** URL path without query string. */
|
|
||||||
path: string;
|
|
||||||
searchParams: URLSearchParams;
|
|
||||||
authorizationHeader?: string;
|
|
||||||
body: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DaemonResponse {
|
|
||||||
status: number;
|
|
||||||
body: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare equal-length HMAC digests so neither content nor LENGTH of the
|
|
||||||
// stored secret is observable through timing.
|
|
||||||
const HMAC_KEY = randomBytes(32);
|
|
||||||
const digest = (value: string): Buffer => createHmac('sha256', HMAC_KEY).update(value).digest();
|
|
||||||
|
|
||||||
const safeEqual = (a: string, b: string): boolean => timingSafeEqual(digest(a), digest(b));
|
|
||||||
|
|
||||||
const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolved identity for an authenticated /bridge/v1/* caller. Host principals
|
|
||||||
* (the agent-comms host daemons) are unrestricted; agent principals are scoped
|
|
||||||
* to a single virtual user and may only act as themselves.
|
|
||||||
*/
|
|
||||||
export type BridgePrincipal = { kind: 'host' } | { kind: 'agent'; agentUserId: string } | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP-framework-agnostic request router for the mosaic-as daemon: the
|
|
||||||
* Application Service transactions endpoint (Synapse-facing) plus the
|
|
||||||
* internal bridge API v1 (agent-comms daemon-facing). main.ts binds this to
|
|
||||||
* node:http; tests drive it directly.
|
|
||||||
*/
|
|
||||||
export class AppserviceDaemon {
|
|
||||||
readonly intent: AppserviceIntent;
|
|
||||||
private readonly transactions: TransactionHandler;
|
|
||||||
private readonly agents: AgentTokenStore;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly cfg: DaemonConfig,
|
|
||||||
fetchImpl?: typeof fetch,
|
|
||||||
private readonly log: (line: string) => void = (line) => console.log(line),
|
|
||||||
) {
|
|
||||||
this.intent = new AppserviceIntent(cfg, fetchImpl);
|
|
||||||
this.agents = new AgentTokenStore(this.intent);
|
|
||||||
this.transactions = new TransactionHandler({
|
|
||||||
hsToken: cfg.hsToken,
|
|
||||||
onEvent: (event) => this.onEvent(event),
|
|
||||||
onError: (error, txnId) => this.log(`txn ${txnId} handler error: ${String(error)}`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** v1: the daemon only observes; room logic lives in the agent-comms daemons. */
|
|
||||||
private onEvent(event: MatrixEvent): void {
|
|
||||||
if (event.type === 'm.room.message') {
|
|
||||||
this.log(
|
|
||||||
`event ${event.event_id ?? '?'} in ${event.room_id ?? '?'} from ${event.sender ?? '?'}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve the calling principal, or null when unauthorized. Fail-closed:
|
|
||||||
* host tokens win (timing-safe compare); otherwise a magt_* bearer is looked
|
|
||||||
* up in the agent token store; anything else is rejected. */
|
|
||||||
private async bridgeAuthorized(
|
|
||||||
authorizationHeader: string | undefined,
|
|
||||||
): Promise<BridgePrincipal> {
|
|
||||||
if (!authorizationHeader?.startsWith('Bearer ')) return null;
|
|
||||||
const presented = authorizationHeader.slice('Bearer '.length);
|
|
||||||
if (this.cfg.bridgeTokens.some((token) => safeEqual(presented, token))) {
|
|
||||||
return { kind: 'host' };
|
|
||||||
}
|
|
||||||
const agentUserId = await this.agents.verifyToken(presented);
|
|
||||||
if (agentUserId) return { kind: 'agent', agentUserId };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async handle(req: DaemonRequest): Promise<DaemonResponse> {
|
|
||||||
if (req.method === 'GET' && req.path === '/health') {
|
|
||||||
return { status: 200, body: { ok: true } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const txnMatch = req.method === 'PUT' ? TXN_PATH.exec(req.path) : null;
|
|
||||||
if (txnMatch?.[1] !== undefined) {
|
|
||||||
return this.transactions.handle(txnMatch[1], req.body, {
|
|
||||||
authorizationHeader: req.authorizationHeader,
|
|
||||||
accessTokenParam: req.searchParams.get('access_token') ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.path.startsWith('/bridge/v1/')) {
|
|
||||||
const principal = await this.bridgeAuthorized(req.authorizationHeader);
|
|
||||||
if (!principal) {
|
|
||||||
return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad bridge token' } };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (req.method === 'POST' && req.path === '/bridge/v1/agents') {
|
|
||||||
if (principal.kind !== 'host') {
|
|
||||||
return {
|
|
||||||
status: 403,
|
|
||||||
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot register agents' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
validateRegisterAgent(req.body);
|
|
||||||
const { agentUserId, token } = await this.agents.register({
|
|
||||||
alias: req.body.alias,
|
|
||||||
host: req.body.host,
|
|
||||||
displayName: req.body.display_name,
|
|
||||||
});
|
|
||||||
this.log(`registered agent ${agentUserId}`);
|
|
||||||
return { status: 200, body: { agent_user_id: agentUserId, bridge_token: token } };
|
|
||||||
}
|
|
||||||
if (req.method === 'POST' && req.path === '/bridge/v1/agents/revoke') {
|
|
||||||
if (principal.kind !== 'host') {
|
|
||||||
return {
|
|
||||||
status: 403,
|
|
||||||
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot revoke agents' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
validateRevokeAgent(req.body);
|
|
||||||
const revoked = await this.agents.revoke(req.body.agent_user_id);
|
|
||||||
this.log(`revoked ${revoked} token(s) for ${req.body.agent_user_id}`);
|
|
||||||
return { status: 200, body: { revoked } };
|
|
||||||
}
|
|
||||||
if (req.method === 'GET' && req.path === '/bridge/v1/agents') {
|
|
||||||
if (principal.kind !== 'host') {
|
|
||||||
return {
|
|
||||||
status: 403,
|
|
||||||
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot list agents' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const agents = await this.agents.list();
|
|
||||||
return { status: 200, body: { agents } };
|
|
||||||
}
|
|
||||||
if (req.method === 'POST' && req.path === '/bridge/v1/messages') {
|
|
||||||
validateBridgeMessage(req.body);
|
|
||||||
if (
|
|
||||||
principal.kind === 'agent' &&
|
|
||||||
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
status: 403,
|
|
||||||
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const eventId = await this.intent.sendAsAgent({
|
|
||||||
roomId: req.body.room_id,
|
|
||||||
agent: req.body.agent,
|
|
||||||
body: req.body.body,
|
|
||||||
threadRoot: req.body.thread_root,
|
|
||||||
msgtype: req.body.msgtype,
|
|
||||||
extraContent: req.body.extra_content,
|
|
||||||
});
|
|
||||||
return { status: 200, body: { event_id: eventId ?? null } };
|
|
||||||
}
|
|
||||||
if (req.method === 'POST' && req.path === '/bridge/v1/typing') {
|
|
||||||
validateBridgeTyping(req.body);
|
|
||||||
if (
|
|
||||||
principal.kind === 'agent' &&
|
|
||||||
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
status: 403,
|
|
||||||
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
await this.intent.setTyping(req.body.room_id, req.body.agent, req.body.typing);
|
|
||||||
return { status: 200, body: {} };
|
|
||||||
}
|
|
||||||
if (req.method === 'POST' && req.path === '/bridge/v1/provision/rooms') {
|
|
||||||
validateProvisionRoom(req.body);
|
|
||||||
const result = await this.intent.createRoom({
|
|
||||||
name: req.body.name,
|
|
||||||
alias: req.body.alias,
|
|
||||||
topic: req.body.topic,
|
|
||||||
invite: req.body.invite,
|
|
||||||
spaceId: req.body.space_id,
|
|
||||||
});
|
|
||||||
this.log(
|
|
||||||
`provisioned room ${result.roomId} (${req.body.name}) space_linked=${result.spaceLinked}`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
status: 200,
|
|
||||||
body: {
|
|
||||||
room_id: result.roomId,
|
|
||||||
space_linked: result.spaceLinked,
|
|
||||||
...(result.spaceError ? { space_error: result.spaceError } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
this.log(`bridge error ${req.method} ${req.path}: ${message}`);
|
|
||||||
return { status: 400, body: { error: message } };
|
|
||||||
}
|
|
||||||
// Explicit: never fall out of the authenticated bridge block, so future
|
|
||||||
// sub-paths cannot accidentally route around the auth guard above.
|
|
||||||
return { status: 405, body: { error: 'unsupported bridge method/path' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: 404, body: { error: 'not found' } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/gateway",
|
"name": "@mosaic/gateway",
|
||||||
"version": "0.0.6",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"private": true,
|
||||||
"type": "git",
|
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
|
||||||
"directory": "apps/gateway"
|
|
||||||
},
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"bin": {
|
|
||||||
"mosaic-gateway": "dist/main.js"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
|
|
||||||
"access": "public"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "tsx watch src/main.ts",
|
"dev": "tsx watch src/main.ts",
|
||||||
@@ -28,36 +14,32 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.80.0",
|
"@anthropic-ai/sdk": "^0.80.0",
|
||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@mariozechner/pi-ai": "^0.65.0",
|
"@mariozechner/pi-ai": "~0.57.1",
|
||||||
"@mariozechner/pi-coding-agent": "^0.65.0",
|
"@mariozechner/pi-coding-agent": "~0.57.1",
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"@mosaicstack/agent": "workspace:^",
|
"@mosaic/auth": "workspace:^",
|
||||||
"@mosaicstack/auth": "workspace:^",
|
"@mosaic/brain": "workspace:^",
|
||||||
"@mosaicstack/brain": "workspace:^",
|
"@mosaic/coord": "workspace:^",
|
||||||
"@mosaicstack/config": "workspace:^",
|
"@mosaic/db": "workspace:^",
|
||||||
"@mosaicstack/coord": "workspace:^",
|
"@mosaic/discord-plugin": "workspace:^",
|
||||||
"@mosaicstack/db": "workspace:^",
|
"@mosaic/log": "workspace:^",
|
||||||
"@mosaicstack/discord-plugin": "workspace:^",
|
"@mosaic/memory": "workspace:^",
|
||||||
"@mosaicstack/log": "workspace:^",
|
"@mosaic/queue": "workspace:^",
|
||||||
"@mosaicstack/memory": "workspace:^",
|
"@mosaic/telegram-plugin": "workspace:^",
|
||||||
"@mosaicstack/queue": "workspace:^",
|
"@mosaic/types": "workspace:^",
|
||||||
"@mosaicstack/storage": "workspace:^",
|
|
||||||
"@mosaicstack/telegram-plugin": "workspace:^",
|
|
||||||
"@mosaicstack/types": "workspace:^",
|
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
"@nestjs/platform-fastify": "^11.0.0",
|
"@nestjs/platform-fastify": "^11.0.0",
|
||||||
"@nestjs/platform-socket.io": "^11.0.0",
|
"@nestjs/platform-socket.io": "^11.0.0",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/websockets": "^11.0.0",
|
"@nestjs/websockets": "^11.0.0",
|
||||||
"@opentelemetry/auto-instrumentations-node": "^0.72.0",
|
"@opentelemetry/auto-instrumentations-node": "^0.71.0",
|
||||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.213.0",
|
"@opentelemetry/exporter-metrics-otlp-http": "^0.213.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.213.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.213.0",
|
||||||
"@opentelemetry/resources": "^2.6.0",
|
"@opentelemetry/resources": "^2.6.0",
|
||||||
"@opentelemetry/sdk-metrics": "^2.6.0",
|
"@opentelemetry/sdk-metrics": "^2.6.0",
|
||||||
"@opentelemetry/sdk-node": "^0.213.0",
|
"@opentelemetry/sdk-node": "^0.213.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||||
"@peculiar/x509": "^2.0.0",
|
|
||||||
"@sinclair/typebox": "^0.34.48",
|
"@sinclair/typebox": "^0.34.48",
|
||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
"bullmq": "^5.71.0",
|
"bullmq": "^5.71.0",
|
||||||
@@ -65,30 +47,20 @@
|
|||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
"ioredis": "^5.10.0",
|
|
||||||
"jose": "^6.2.2",
|
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"openai": "^6.32.0",
|
"openai": "^6.32.0",
|
||||||
"postgres": "^3.4.8",
|
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"socket.io": "^4.8.0",
|
"socket.io": "^4.8.0",
|
||||||
"uuid": "^11.0.0",
|
"uuid": "^11.0.0",
|
||||||
"undici": "^7.24.6",
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/testing": "^11.1.18",
|
|
||||||
"@swc/core": "^1.15.24",
|
|
||||||
"@swc/helpers": "^0.5.21",
|
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/supertest": "^7.2.0",
|
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"supertest": "^7.2.2",
|
|
||||||
"tsx": "^4.0.0",
|
"tsx": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"unplugin-swc": "^1.5.9",
|
|
||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
import type { ConversationHistoryMessage } from '../agent/agent.service.js';
|
import type { ConversationHistoryMessage } from '../agent/agent.service.js';
|
||||||
import { ConversationsController } from '../conversations/conversations.controller.js';
|
import { ConversationsController } from '../conversations/conversations.controller.js';
|
||||||
import type { Message } from '@mosaicstack/brain';
|
import type { Message } from '@mosaic/brain';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared test data
|
// Shared test data
|
||||||
|
|||||||
@@ -18,13 +18,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { createDb } from '@mosaicstack/db';
|
import { createDb } from '@mosaic/db';
|
||||||
import { createConversationsRepo } from '@mosaicstack/brain';
|
import { createConversationsRepo } from '@mosaic/brain';
|
||||||
import { createAgentsRepo } from '@mosaicstack/brain';
|
import { createAgentsRepo } from '@mosaic/brain';
|
||||||
import { createPreferencesRepo, createInsightsRepo } from '@mosaicstack/memory';
|
import { createPreferencesRepo, createInsightsRepo } from '@mosaic/memory';
|
||||||
import { users, conversations, messages, agents, preferences, insights } from '@mosaicstack/db';
|
import { users, conversations, messages, agents, preferences, insights } from '@mosaic/db';
|
||||||
import { eq } from '@mosaicstack/db';
|
import { eq } from '@mosaic/db';
|
||||||
import type { DbHandle } from '@mosaicstack/db';
|
import type { DbHandle } from '@mosaic/db';
|
||||||
|
|
||||||
// ─── Fixed IDs so the afterAll cleanup is deterministic ──────────────────────
|
// ─── Fixed IDs so the afterAll cleanup is deterministic ──────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
/**
|
|
||||||
* Test B — Gateway boot refuses (fail-fast) when PG is unreachable.
|
|
||||||
*
|
|
||||||
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
|
|
||||||
* (Valkey must be running; only PG is intentionally misconfigured.)
|
|
||||||
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-boot.pg-unreachable.integration.test.ts
|
|
||||||
*
|
|
||||||
* Skipped when FEDERATED_INTEGRATION !== '1'.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import net from 'node:net';
|
|
||||||
import { beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import { TierDetectionError, detectAndAssertTier } from '@mosaicstack/storage';
|
|
||||||
|
|
||||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
|
||||||
|
|
||||||
const VALKEY_URL = 'redis://localhost:6380';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reserves a guaranteed-closed port at runtime by binding to an ephemeral OS
|
|
||||||
* port (port 0) and immediately releasing it. The OS will not reassign the
|
|
||||||
* port during the TIME_WAIT window, so it remains closed for the duration of
|
|
||||||
* this test.
|
|
||||||
*/
|
|
||||||
async function reserveClosedPort(): Promise<number> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const server = net.createServer();
|
|
||||||
server.listen(0, '127.0.0.1', () => {
|
|
||||||
const addr = server.address();
|
|
||||||
if (typeof addr !== 'object' || !addr) return reject(new Error('no addr'));
|
|
||||||
const port = addr.port;
|
|
||||||
server.close(() => resolve(port));
|
|
||||||
});
|
|
||||||
server.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe.skipIf(!run)('federated boot — PG unreachable', () => {
|
|
||||||
let badPgUrl: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const closedPort = await reserveClosedPort();
|
|
||||||
badPgUrl = `postgresql://mosaic:mosaic@localhost:${closedPort}/mosaic`;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('detectAndAssertTier throws TierDetectionError with service: postgres when PG is down', async () => {
|
|
||||||
const brokenConfig = {
|
|
||||||
tier: 'federated' as const,
|
|
||||||
storage: {
|
|
||||||
type: 'postgres' as const,
|
|
||||||
url: badPgUrl,
|
|
||||||
enableVector: true,
|
|
||||||
},
|
|
||||||
queue: {
|
|
||||||
type: 'bullmq',
|
|
||||||
url: VALKEY_URL,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(detectAndAssertTier(brokenConfig)).rejects.toSatisfy(
|
|
||||||
(err: unknown) => err instanceof TierDetectionError && err.service === 'postgres',
|
|
||||||
);
|
|
||||||
}, 10_000);
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
/**
|
|
||||||
* Test A — Gateway boot succeeds when federated services are up.
|
|
||||||
*
|
|
||||||
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
|
|
||||||
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-boot.success.integration.test.ts
|
|
||||||
*
|
|
||||||
* Skipped when FEDERATED_INTEGRATION !== '1'.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import postgres from 'postgres';
|
|
||||||
import { afterAll, describe, expect, it } from 'vitest';
|
|
||||||
import { detectAndAssertTier } from '@mosaicstack/storage';
|
|
||||||
|
|
||||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
|
||||||
|
|
||||||
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
|
||||||
const VALKEY_URL = 'redis://localhost:6380';
|
|
||||||
|
|
||||||
const federatedConfig = {
|
|
||||||
tier: 'federated' as const,
|
|
||||||
storage: {
|
|
||||||
type: 'postgres' as const,
|
|
||||||
url: PG_URL,
|
|
||||||
enableVector: true,
|
|
||||||
},
|
|
||||||
queue: {
|
|
||||||
type: 'bullmq',
|
|
||||||
url: VALKEY_URL,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe.skipIf(!run)('federated boot — success path', () => {
|
|
||||||
let sql: ReturnType<typeof postgres> | undefined;
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
if (sql) {
|
|
||||||
await sql.end({ timeout: 2 }).catch(() => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('detectAndAssertTier resolves without throwing when federated services are up', async () => {
|
|
||||||
await expect(detectAndAssertTier(federatedConfig)).resolves.toBeUndefined();
|
|
||||||
}, 10_000);
|
|
||||||
|
|
||||||
it('pgvector extension is registered (pg_extension row exists)', async () => {
|
|
||||||
sql = postgres(PG_URL, { max: 1, connect_timeout: 5, idle_timeout: 5 });
|
|
||||||
const rows = await sql`SELECT * FROM pg_extension WHERE extname = 'vector'`;
|
|
||||||
expect(rows).toHaveLength(1);
|
|
||||||
}, 10_000);
|
|
||||||
});
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/**
|
|
||||||
* Test C — pgvector extension is functional end-to-end.
|
|
||||||
*
|
|
||||||
* Creates a temp table with a vector(3) column, inserts a row, and queries it
|
|
||||||
* back — confirming the extension is not just registered but operational.
|
|
||||||
*
|
|
||||||
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
|
|
||||||
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-pgvector.integration.test.ts
|
|
||||||
*
|
|
||||||
* Skipped when FEDERATED_INTEGRATION !== '1'.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import postgres from 'postgres';
|
|
||||||
import { afterAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
|
||||||
|
|
||||||
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
|
||||||
|
|
||||||
let sql: ReturnType<typeof postgres> | undefined;
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
if (sql) {
|
|
||||||
await sql.end({ timeout: 2 }).catch(() => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.skipIf(!run)('federated pgvector — functional end-to-end', () => {
|
|
||||||
it('vector ops round-trip: INSERT [1,2,3] and SELECT returns [1,2,3]', async () => {
|
|
||||||
sql = postgres(PG_URL, { max: 1, connect_timeout: 5, idle_timeout: 5 });
|
|
||||||
|
|
||||||
await sql`CREATE TEMP TABLE t (id int, embedding vector(3))`;
|
|
||||||
await sql`INSERT INTO t VALUES (1, '[1,2,3]')`;
|
|
||||||
const rows = await sql`SELECT embedding FROM t`;
|
|
||||||
|
|
||||||
expect(rows).toHaveLength(1);
|
|
||||||
// The postgres driver returns vector columns as strings like '[1,2,3]'.
|
|
||||||
// Normalise by parsing the string representation.
|
|
||||||
const raw = rows[0]?.['embedding'] as string;
|
|
||||||
const parsed = JSON.parse(raw) as number[];
|
|
||||||
expect(parsed).toEqual([1, 2, 3]);
|
|
||||||
}, 10_000);
|
|
||||||
});
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation M2 E2E test — peer-add enrollment flow (FED-M2-10).
|
|
||||||
*
|
|
||||||
* Covers MILESTONES.md acceptance test #6:
|
|
||||||
* "`peer add <url>` on Server A yields an `active` peer record with a valid cert + key"
|
|
||||||
*
|
|
||||||
* This test simulates two gateways using a single bootstrapped NestJS app:
|
|
||||||
* - "Server A": the admin API that generates a keypair and stores the cert
|
|
||||||
* - "Server B": the enrollment endpoint that signs the CSR
|
|
||||||
* Both share the same DB + Step-CA in the test environment.
|
|
||||||
*
|
|
||||||
* Prerequisites:
|
|
||||||
* docker compose -f docker-compose.federated.yml --profile federated up -d
|
|
||||||
*
|
|
||||||
* Run:
|
|
||||||
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
|
|
||||||
* STEP_CA_URL=https://localhost:9000 \
|
|
||||||
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
|
|
||||||
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
|
|
||||||
* pnpm --filter @mosaicstack/gateway test \
|
|
||||||
* src/__tests__/integration/federation-m2-e2e.integration.test.ts
|
|
||||||
*
|
|
||||||
* Obtaining Step-CA credentials:
|
|
||||||
* # Extract provisioner key from running container:
|
|
||||||
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
|
|
||||||
* # Copy root cert from container:
|
|
||||||
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
|
|
||||||
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
|
|
||||||
*
|
|
||||||
* Skipped unless both FEDERATED_INTEGRATION=1 and STEP_CA_AVAILABLE=1 are set.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as crypto from 'node:crypto';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import supertest from 'supertest';
|
|
||||||
import {
|
|
||||||
createDb,
|
|
||||||
type Db,
|
|
||||||
type DbHandle,
|
|
||||||
federationPeers,
|
|
||||||
federationGrants,
|
|
||||||
federationEnrollmentTokens,
|
|
||||||
inArray,
|
|
||||||
eq,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import * as schema from '@mosaicstack/db';
|
|
||||||
import { DB } from '../../database/database.module.js';
|
|
||||||
import { AdminGuard } from '../../admin/admin.guard.js';
|
|
||||||
import { FederationModule } from '../../federation/federation.module.js';
|
|
||||||
import { GrantsService } from '../../federation/grants.service.js';
|
|
||||||
import { EnrollmentService } from '../../federation/enrollment.service.js';
|
|
||||||
|
|
||||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
|
||||||
const stepCaRun =
|
|
||||||
run &&
|
|
||||||
process.env['STEP_CA_AVAILABLE'] === '1' &&
|
|
||||||
!!process.env['STEP_CA_URL'] &&
|
|
||||||
!!process.env['STEP_CA_PROVISIONER_KEY_JSON'] &&
|
|
||||||
!!process.env['STEP_CA_ROOT_CERT_PATH'];
|
|
||||||
|
|
||||||
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
|
||||||
|
|
||||||
const RUN_ID = crypto.randomUUID();
|
|
||||||
|
|
||||||
describe.skipIf(!stepCaRun)('federation M2 E2E — peer add enrollment flow', () => {
|
|
||||||
let handle: DbHandle;
|
|
||||||
let db: Db;
|
|
||||||
let app: NestFastifyApplication;
|
|
||||||
let agent: ReturnType<typeof supertest>;
|
|
||||||
let grantsService: GrantsService;
|
|
||||||
let enrollmentService: EnrollmentService;
|
|
||||||
|
|
||||||
const createdTokenGrantIds: string[] = [];
|
|
||||||
const createdGrantIds: string[] = [];
|
|
||||||
const createdPeerIds: string[] = [];
|
|
||||||
const createdUserIds: string[] = [];
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
process.env['BETTER_AUTH_SECRET'] ??= 'test-e2e-sealing-key';
|
|
||||||
|
|
||||||
handle = createDb(PG_URL);
|
|
||||||
db = handle.db;
|
|
||||||
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
imports: [FederationModule],
|
|
||||||
providers: [{ provide: DB, useValue: db }],
|
|
||||||
})
|
|
||||||
.overrideGuard(AdminGuard)
|
|
||||||
.useValue({ canActivate: () => true })
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
||||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
|
||||||
await app.init();
|
|
||||||
await app.getHttpAdapter().getInstance().ready();
|
|
||||||
|
|
||||||
agent = supertest(app.getHttpServer());
|
|
||||||
|
|
||||||
grantsService = moduleRef.get(GrantsService);
|
|
||||||
enrollmentService = moduleRef.get(EnrollmentService);
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
if (db && createdTokenGrantIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(federationEnrollmentTokens)
|
|
||||||
.where(inArray(federationEnrollmentTokens.grantId, createdTokenGrantIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
|
||||||
}
|
|
||||||
if (db && createdGrantIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(federationGrants)
|
|
||||||
.where(inArray(federationGrants.id, createdGrantIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
|
||||||
}
|
|
||||||
if (db && createdPeerIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(federationPeers)
|
|
||||||
.where(inArray(federationPeers.id, createdPeerIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
|
||||||
}
|
|
||||||
if (db && createdUserIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(schema.users)
|
|
||||||
.where(inArray(schema.users.id, createdUserIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
|
||||||
}
|
|
||||||
if (app)
|
|
||||||
await app.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
|
||||||
if (handle)
|
|
||||||
await handle.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// #6 — peer add: keypair → enrollment → cert storage → active peer record
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
it('#6 — peer add flow: keypair → enrollment → cert storage → active peer record', async () => {
|
|
||||||
// Create a subject user to satisfy FK on federation_grants.subject_user_id
|
|
||||||
const userId = crypto.randomUUID();
|
|
||||||
await db
|
|
||||||
.insert(schema.users)
|
|
||||||
.values({
|
|
||||||
id: userId,
|
|
||||||
name: `e2e-user-${RUN_ID}`,
|
|
||||||
email: `e2e-${RUN_ID}@federation-test.invalid`,
|
|
||||||
emailVerified: false,
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
createdUserIds.push(userId);
|
|
||||||
|
|
||||||
// ── Step A: "Server B" setup ─────────────────────────────────────────
|
|
||||||
// Server B admin creates a grant and generates an enrollment token to
|
|
||||||
// share out-of-band with Server A's operator.
|
|
||||||
|
|
||||||
// Insert a placeholder peer on "Server B" to satisfy the grant FK
|
|
||||||
const serverBPeerId = crypto.randomUUID();
|
|
||||||
await db
|
|
||||||
.insert(federationPeers)
|
|
||||||
.values({
|
|
||||||
id: serverBPeerId,
|
|
||||||
commonName: `server-b-peer-${RUN_ID}`,
|
|
||||||
displayName: 'Server B Placeholder',
|
|
||||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
|
||||||
certSerial: `serial-b-${serverBPeerId}`,
|
|
||||||
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
|
||||||
state: 'pending',
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
createdPeerIds.push(serverBPeerId);
|
|
||||||
|
|
||||||
const grant = await grantsService.createGrant({
|
|
||||||
subjectUserId: userId,
|
|
||||||
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100 },
|
|
||||||
peerId: serverBPeerId,
|
|
||||||
});
|
|
||||||
createdGrantIds.push(grant.id);
|
|
||||||
createdTokenGrantIds.push(grant.id);
|
|
||||||
|
|
||||||
const { token } = await enrollmentService.createToken({
|
|
||||||
grantId: grant.id,
|
|
||||||
peerId: serverBPeerId,
|
|
||||||
ttlSeconds: 900,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Step B: "Server A" generates keypair ─────────────────────────────
|
|
||||||
const keypairRes = await agent
|
|
||||||
.post('/api/admin/federation/peers/keypair')
|
|
||||||
.send({
|
|
||||||
commonName: `e2e-peer-${RUN_ID.slice(0, 8)}`,
|
|
||||||
displayName: 'E2E Test Peer',
|
|
||||||
endpointUrl: 'https://test.invalid',
|
|
||||||
})
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(keypairRes.status).toBe(201);
|
|
||||||
const { peerId, csrPem } = keypairRes.body as { peerId: string; csrPem: string };
|
|
||||||
expect(typeof peerId).toBe('string');
|
|
||||||
expect(csrPem).toContain('-----BEGIN CERTIFICATE REQUEST-----');
|
|
||||||
createdPeerIds.push(peerId);
|
|
||||||
|
|
||||||
// ── Step C: Enrollment (simulates Server A sending CSR to Server B) ──
|
|
||||||
const enrollRes = await agent
|
|
||||||
.post(`/api/federation/enrollment/${token}`)
|
|
||||||
.send({ csrPem })
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(enrollRes.status).toBe(200);
|
|
||||||
const { certPem, certChainPem } = enrollRes.body as {
|
|
||||||
certPem: string;
|
|
||||||
certChainPem: string;
|
|
||||||
};
|
|
||||||
expect(certPem).toContain('-----BEGIN CERTIFICATE-----');
|
|
||||||
expect(certChainPem).toContain('-----BEGIN CERTIFICATE-----');
|
|
||||||
|
|
||||||
// ── Step D: "Server A" stores the cert ───────────────────────────────
|
|
||||||
const storeRes = await agent
|
|
||||||
.patch(`/api/admin/federation/peers/${peerId}/cert`)
|
|
||||||
.send({ certPem })
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(storeRes.status).toBe(200);
|
|
||||||
|
|
||||||
// ── Step E: Verify peer record in DB ─────────────────────────────────
|
|
||||||
const [peer] = await db
|
|
||||||
.select()
|
|
||||||
.from(federationPeers)
|
|
||||||
.where(eq(federationPeers.id, peerId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
expect(peer).toBeDefined();
|
|
||||||
expect(peer?.state).toBe('active');
|
|
||||||
expect(peer?.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
|
||||||
expect(typeof peer?.certSerial).toBe('string');
|
|
||||||
expect((peer?.certSerial ?? '').length).toBeGreaterThan(0);
|
|
||||||
// clientKeyPem is a sealed ciphertext — must not be a raw PEM
|
|
||||||
expect(peer?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
|
|
||||||
// certNotAfter must be in the future
|
|
||||||
expect(peer?.certNotAfter?.getTime()).toBeGreaterThan(Date.now());
|
|
||||||
}, 60_000);
|
|
||||||
});
|
|
||||||
@@ -1,483 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation M2 integration tests (FED-M2-09).
|
|
||||||
*
|
|
||||||
* Covers MILESTONES.md acceptance tests #1, #2, #3, #5, #7, #8.
|
|
||||||
*
|
|
||||||
* Prerequisites:
|
|
||||||
* docker compose -f docker-compose.federated.yml --profile federated up -d
|
|
||||||
*
|
|
||||||
* Run DB-only tests (no Step-CA):
|
|
||||||
* FEDERATED_INTEGRATION=1 BETTER_AUTH_SECRET=test-secret pnpm --filter @mosaicstack/gateway test \
|
|
||||||
* src/__tests__/integration/federation-m2.integration.test.ts
|
|
||||||
*
|
|
||||||
* Run all tests including Step-CA-dependent ones:
|
|
||||||
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
|
|
||||||
* STEP_CA_URL=https://localhost:9000 \
|
|
||||||
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
|
|
||||||
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
|
|
||||||
* pnpm --filter @mosaicstack/gateway test \
|
|
||||||
* src/__tests__/integration/federation-m2.integration.test.ts
|
|
||||||
*
|
|
||||||
* Obtaining Step-CA credentials:
|
|
||||||
* # Extract provisioner key from running container:
|
|
||||||
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
|
|
||||||
* # Copy root cert from container:
|
|
||||||
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
|
|
||||||
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as crypto from 'node:crypto';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { GoneException } from '@nestjs/common';
|
|
||||||
import { Pkcs10CertificateRequestGenerator, X509Certificate as PeculiarX509 } from '@peculiar/x509';
|
|
||||||
import {
|
|
||||||
createDb,
|
|
||||||
type Db,
|
|
||||||
type DbHandle,
|
|
||||||
federationPeers,
|
|
||||||
federationGrants,
|
|
||||||
federationEnrollmentTokens,
|
|
||||||
inArray,
|
|
||||||
eq,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import * as schema from '@mosaicstack/db';
|
|
||||||
import { seal } from '@mosaicstack/auth';
|
|
||||||
import { DB } from '../../database/database.module.js';
|
|
||||||
import { GrantsService } from '../../federation/grants.service.js';
|
|
||||||
import { EnrollmentService } from '../../federation/enrollment.service.js';
|
|
||||||
import { CaService } from '../../federation/ca.service.js';
|
|
||||||
import { FederationScopeError } from '../../federation/scope-schema.js';
|
|
||||||
|
|
||||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
|
||||||
const stepCaRun = run && process.env['STEP_CA_AVAILABLE'] === '1';
|
|
||||||
|
|
||||||
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers for test data isolation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Unique run prefix to identify rows created by this test run. */
|
|
||||||
const RUN_ID = crypto.randomUUID();
|
|
||||||
|
|
||||||
/** Insert a minimal user row to satisfy the FK on federation_grants.subject_user_id. */
|
|
||||||
async function insertTestUser(db: Db, id: string): Promise<void> {
|
|
||||||
await db
|
|
||||||
.insert(schema.users)
|
|
||||||
.values({
|
|
||||||
id,
|
|
||||||
name: `test-user-${id}`,
|
|
||||||
email: `test-${id}@federation-test.invalid`,
|
|
||||||
emailVerified: false,
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Insert a minimal peer row to satisfy the FK on federation_grants.peer_id. */
|
|
||||||
async function insertTestPeer(db: Db, id: string, suffix: string = ''): Promise<void> {
|
|
||||||
await db
|
|
||||||
.insert(federationPeers)
|
|
||||||
.values({
|
|
||||||
id,
|
|
||||||
commonName: `test-peer-${RUN_ID}-${suffix}`,
|
|
||||||
displayName: `Test Peer ${suffix}`,
|
|
||||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
|
||||||
certSerial: `test-serial-${id}`,
|
|
||||||
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
|
||||||
state: 'pending',
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DB-only test module (CaService mocked so env vars not required)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function buildDbModule(db: Db) {
|
|
||||||
return Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
{ provide: DB, useValue: db },
|
|
||||||
GrantsService,
|
|
||||||
{
|
|
||||||
provide: CaService,
|
|
||||||
useValue: {
|
|
||||||
issueCert: async () => {
|
|
||||||
throw new Error('CaService.issueCert should not be called in DB-only tests');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
EnrollmentService,
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test suite — DB-only (no Step-CA)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe.skipIf(!run)('federation M2 — DB-only tests', () => {
|
|
||||||
let handle: DbHandle;
|
|
||||||
let db: Db;
|
|
||||||
let grantsService: GrantsService;
|
|
||||||
|
|
||||||
/** IDs created during this run — cleaned up in afterAll. */
|
|
||||||
const createdGrantIds: string[] = [];
|
|
||||||
const createdPeerIds: string[] = [];
|
|
||||||
const createdUserIds: string[] = [];
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
process.env['BETTER_AUTH_SECRET'] ??= 'test-integration-sealing-key-not-for-prod';
|
|
||||||
|
|
||||||
handle = createDb(PG_URL);
|
|
||||||
db = handle.db;
|
|
||||||
|
|
||||||
const moduleRef = await buildDbModule(db);
|
|
||||||
grantsService = moduleRef.get(GrantsService);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
// Clean up in FK-safe order: tokens → grants → peers → users
|
|
||||||
if (db && createdGrantIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(federationEnrollmentTokens)
|
|
||||||
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
await db
|
|
||||||
.delete(federationGrants)
|
|
||||||
.where(inArray(federationGrants.id, createdGrantIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
}
|
|
||||||
if (db && createdPeerIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(federationPeers)
|
|
||||||
.where(inArray(federationPeers.id, createdPeerIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
}
|
|
||||||
if (db && createdUserIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(schema.users)
|
|
||||||
.where(inArray(schema.users.id, createdUserIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
}
|
|
||||||
if (handle)
|
|
||||||
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// #1 — grant create writes a pending row
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
it('#1 — createGrant writes a pending row to DB', async () => {
|
|
||||||
const userId = crypto.randomUUID();
|
|
||||||
const peerId = crypto.randomUUID();
|
|
||||||
const validScope = {
|
|
||||||
resources: ['tasks'],
|
|
||||||
excluded_resources: [],
|
|
||||||
max_rows_per_query: 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
await insertTestUser(db, userId);
|
|
||||||
await insertTestPeer(db, peerId, 'test1');
|
|
||||||
createdUserIds.push(userId);
|
|
||||||
createdPeerIds.push(peerId);
|
|
||||||
|
|
||||||
const grant = await grantsService.createGrant({
|
|
||||||
subjectUserId: userId,
|
|
||||||
scope: validScope,
|
|
||||||
peerId,
|
|
||||||
});
|
|
||||||
|
|
||||||
createdGrantIds.push(grant.id);
|
|
||||||
|
|
||||||
// Verify the row exists in DB with correct shape
|
|
||||||
const [row] = await db
|
|
||||||
.select()
|
|
||||||
.from(federationGrants)
|
|
||||||
.where(eq(federationGrants.id, grant.id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
expect(row).toBeDefined();
|
|
||||||
expect(row?.status).toBe('pending');
|
|
||||||
expect(row?.peerId).toBe(peerId);
|
|
||||||
expect(row?.subjectUserId).toBe(userId);
|
|
||||||
const storedScope = row?.scope as Record<string, unknown>;
|
|
||||||
expect(storedScope['resources']).toEqual(['tasks']);
|
|
||||||
expect(storedScope['max_rows_per_query']).toBe(100);
|
|
||||||
}, 15_000);
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// #7 — scope with unknown resource type rejected
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
it('#7 — createGrant rejects scope with unknown resource type', async () => {
|
|
||||||
const userId = crypto.randomUUID();
|
|
||||||
const peerId = crypto.randomUUID();
|
|
||||||
const invalidScope = {
|
|
||||||
resources: ['totally_unknown_resource'],
|
|
||||||
excluded_resources: [],
|
|
||||||
max_rows_per_query: 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
await insertTestUser(db, userId);
|
|
||||||
await insertTestPeer(db, peerId, 'test7');
|
|
||||||
createdUserIds.push(userId);
|
|
||||||
createdPeerIds.push(peerId);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
grantsService.createGrant({
|
|
||||||
subjectUserId: userId,
|
|
||||||
scope: invalidScope,
|
|
||||||
peerId,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(FederationScopeError);
|
|
||||||
}, 15_000);
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// #8 — listGrants returns accurate status for grants in various states
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
it('#8 — listGrants returns accurate status for grants in various states', async () => {
|
|
||||||
const userId = crypto.randomUUID();
|
|
||||||
const peerId = crypto.randomUUID();
|
|
||||||
const validScope = {
|
|
||||||
resources: ['notes'],
|
|
||||||
excluded_resources: [],
|
|
||||||
max_rows_per_query: 50,
|
|
||||||
};
|
|
||||||
|
|
||||||
await insertTestUser(db, userId);
|
|
||||||
await insertTestPeer(db, peerId, 'test8');
|
|
||||||
createdUserIds.push(userId);
|
|
||||||
createdPeerIds.push(peerId);
|
|
||||||
|
|
||||||
// Create two pending grants via GrantsService
|
|
||||||
const grantA = await grantsService.createGrant({
|
|
||||||
subjectUserId: userId,
|
|
||||||
scope: validScope,
|
|
||||||
peerId,
|
|
||||||
});
|
|
||||||
const grantB = await grantsService.createGrant({
|
|
||||||
subjectUserId: userId,
|
|
||||||
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 50 },
|
|
||||||
peerId,
|
|
||||||
});
|
|
||||||
createdGrantIds.push(grantA.id, grantB.id);
|
|
||||||
|
|
||||||
// Insert a third grant directly in 'revoked' state to test status variety
|
|
||||||
const [grantC] = await db
|
|
||||||
.insert(federationGrants)
|
|
||||||
.values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
subjectUserId: userId,
|
|
||||||
peerId,
|
|
||||||
scope: validScope,
|
|
||||||
status: 'revoked',
|
|
||||||
revokedAt: new Date(),
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
createdGrantIds.push(grantC!.id);
|
|
||||||
|
|
||||||
// List all grants for this peer
|
|
||||||
const allForPeer = await grantsService.listGrants({ peerId });
|
|
||||||
|
|
||||||
const ourGrantIds = new Set([grantA.id, grantB.id, grantC!.id]);
|
|
||||||
const ourGrants = allForPeer.filter((g) => ourGrantIds.has(g.id));
|
|
||||||
expect(ourGrants).toHaveLength(3);
|
|
||||||
|
|
||||||
const pendingGrants = ourGrants.filter((g) => g.status === 'pending');
|
|
||||||
const revokedGrants = ourGrants.filter((g) => g.status === 'revoked');
|
|
||||||
expect(pendingGrants).toHaveLength(2);
|
|
||||||
expect(revokedGrants).toHaveLength(1);
|
|
||||||
|
|
||||||
// Status-filtered query
|
|
||||||
const pendingOnly = await grantsService.listGrants({ peerId, status: 'pending' });
|
|
||||||
const ourPending = pendingOnly.filter((g) => ourGrantIds.has(g.id));
|
|
||||||
expect(ourPending.every((g) => g.status === 'pending')).toBe(true);
|
|
||||||
|
|
||||||
// Verify peer list from DB also shows the peer rows with correct state
|
|
||||||
const peers = await db.select().from(federationPeers).where(eq(federationPeers.id, peerId));
|
|
||||||
expect(peers).toHaveLength(1);
|
|
||||||
expect(peers[0]?.state).toBe('pending');
|
|
||||||
}, 15_000);
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// #5 — client_key_pem encrypted at rest
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
it('#5 — clientKeyPem stored in DB is a sealed ciphertext (not a valid PEM)', async () => {
|
|
||||||
const peerId = crypto.randomUUID();
|
|
||||||
const rawPem = '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n';
|
|
||||||
const sealed = seal(rawPem);
|
|
||||||
|
|
||||||
await db.insert(federationPeers).values({
|
|
||||||
id: peerId,
|
|
||||||
commonName: `test-peer-${RUN_ID}-sealed`,
|
|
||||||
displayName: 'Sealed Key Test Peer',
|
|
||||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
|
||||||
certSerial: `test-serial-sealed-${peerId}`,
|
|
||||||
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
|
||||||
state: 'pending',
|
|
||||||
clientKeyPem: sealed,
|
|
||||||
});
|
|
||||||
createdPeerIds.push(peerId);
|
|
||||||
|
|
||||||
const [row] = await db
|
|
||||||
.select()
|
|
||||||
.from(federationPeers)
|
|
||||||
.where(eq(federationPeers.id, peerId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
expect(row).toBeDefined();
|
|
||||||
// The stored value must NOT be a valid PEM — it's a sealed ciphertext blob
|
|
||||||
expect(row?.clientKeyPem).toBeDefined();
|
|
||||||
expect(row?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
|
|
||||||
// The sealed value should be non-trivial (at least 20 chars)
|
|
||||||
expect((row?.clientKeyPem ?? '').length).toBeGreaterThan(20);
|
|
||||||
}, 15_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test suite — Step-CA gated
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe.skipIf(!stepCaRun)('federation M2 — Step-CA tests', () => {
|
|
||||||
let handle: DbHandle;
|
|
||||||
let db: Db;
|
|
||||||
let grantsService: GrantsService;
|
|
||||||
let enrollmentService: EnrollmentService;
|
|
||||||
|
|
||||||
const createdGrantIds: string[] = [];
|
|
||||||
const createdPeerIds: string[] = [];
|
|
||||||
const createdUserIds: string[] = [];
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
handle = createDb(PG_URL);
|
|
||||||
db = handle.db;
|
|
||||||
|
|
||||||
// Use real CaService — env vars (STEP_CA_URL, STEP_CA_PROVISIONER_KEY_JSON,
|
|
||||||
// STEP_CA_ROOT_CERT_PATH) must be set when STEP_CA_AVAILABLE=1
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
providers: [{ provide: DB, useValue: db }, CaService, GrantsService, EnrollmentService],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
grantsService = moduleRef.get(GrantsService);
|
|
||||||
enrollmentService = moduleRef.get(EnrollmentService);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
if (db && createdGrantIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(federationEnrollmentTokens)
|
|
||||||
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
await db
|
|
||||||
.delete(federationGrants)
|
|
||||||
.where(inArray(federationGrants.id, createdGrantIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
}
|
|
||||||
if (db && createdPeerIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(federationPeers)
|
|
||||||
.where(inArray(federationPeers.id, createdPeerIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
}
|
|
||||||
if (db && createdUserIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(schema.users)
|
|
||||||
.where(inArray(schema.users.id, createdUserIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
}
|
|
||||||
if (handle)
|
|
||||||
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Generate a P-256 key pair and PKCS#10 CSR, returning the CSR as PEM. */
|
|
||||||
async function generateCsrPem(cn: string): Promise<string> {
|
|
||||||
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' };
|
|
||||||
const keyPair = await crypto.subtle.generateKey(alg, true, ['sign', 'verify']);
|
|
||||||
const csr = await Pkcs10CertificateRequestGenerator.create({
|
|
||||||
name: `CN=${cn}`,
|
|
||||||
keys: keyPair,
|
|
||||||
signingAlgorithm: alg,
|
|
||||||
});
|
|
||||||
return csr.toString('pem');
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// #2 — enrollment signs CSR and returns cert
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
it('#2 — redeem returns a certPem containing a valid PEM certificate', async () => {
|
|
||||||
const userId = crypto.randomUUID();
|
|
||||||
const peerId = crypto.randomUUID();
|
|
||||||
const validScope = {
|
|
||||||
resources: ['tasks'],
|
|
||||||
excluded_resources: [],
|
|
||||||
max_rows_per_query: 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
await insertTestUser(db, userId);
|
|
||||||
await insertTestPeer(db, peerId, 'ca-test2');
|
|
||||||
createdUserIds.push(userId);
|
|
||||||
createdPeerIds.push(peerId);
|
|
||||||
|
|
||||||
const grant = await grantsService.createGrant({
|
|
||||||
subjectUserId: userId,
|
|
||||||
scope: validScope,
|
|
||||||
peerId,
|
|
||||||
});
|
|
||||||
createdGrantIds.push(grant.id);
|
|
||||||
|
|
||||||
const { token } = await enrollmentService.createToken({
|
|
||||||
grantId: grant.id,
|
|
||||||
peerId,
|
|
||||||
ttlSeconds: 900,
|
|
||||||
});
|
|
||||||
|
|
||||||
const csrPem = await generateCsrPem(`gateway-test-${RUN_ID.slice(0, 8)}`);
|
|
||||||
const result = await enrollmentService.redeem(token, csrPem);
|
|
||||||
|
|
||||||
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
|
||||||
expect(result.certChainPem).toContain('-----BEGIN CERTIFICATE-----');
|
|
||||||
|
|
||||||
// Verify the issued cert parses cleanly
|
|
||||||
const cert = new PeculiarX509(result.certPem);
|
|
||||||
expect(cert.serialNumber).toBeTruthy();
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// #3 — token single-use; second attempt returns GoneException
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
it('#3 — second redeem of the same token throws GoneException', async () => {
|
|
||||||
const userId = crypto.randomUUID();
|
|
||||||
const peerId = crypto.randomUUID();
|
|
||||||
const validScope = {
|
|
||||||
resources: ['notes'],
|
|
||||||
excluded_resources: [],
|
|
||||||
max_rows_per_query: 50,
|
|
||||||
};
|
|
||||||
|
|
||||||
await insertTestUser(db, userId);
|
|
||||||
await insertTestPeer(db, peerId, 'ca-test3');
|
|
||||||
createdUserIds.push(userId);
|
|
||||||
createdPeerIds.push(peerId);
|
|
||||||
|
|
||||||
const grant = await grantsService.createGrant({
|
|
||||||
subjectUserId: userId,
|
|
||||||
scope: validScope,
|
|
||||||
peerId,
|
|
||||||
});
|
|
||||||
createdGrantIds.push(grant.id);
|
|
||||||
|
|
||||||
const { token } = await enrollmentService.createToken({
|
|
||||||
grantId: grant.id,
|
|
||||||
peerId,
|
|
||||||
ttlSeconds: 900,
|
|
||||||
});
|
|
||||||
|
|
||||||
const csrPem = await generateCsrPem(`gateway-test-replay-${RUN_ID.slice(0, 8)}`);
|
|
||||||
|
|
||||||
// First redeem must succeed
|
|
||||||
const result = await enrollmentService.redeem(token, csrPem);
|
|
||||||
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
|
||||||
|
|
||||||
// Second redeem with the same token must be rejected
|
|
||||||
await expect(enrollmentService.redeem(token, csrPem)).rejects.toThrow(GoneException);
|
|
||||||
}, 30_000);
|
|
||||||
});
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { InMemoryDurableSessionStore } from '@mosaicstack/agent';
|
|
||||||
import {
|
|
||||||
createDiscordIngressEnvelope,
|
|
||||||
DiscordPlugin,
|
|
||||||
type DiscordIngressPayload,
|
|
||||||
} from '@mosaicstack/discord-plugin';
|
|
||||||
import { InteractionController } from '../../agent/interaction.controller.js';
|
|
||||||
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
|
|
||||||
import { DurableSessionService } from '../../agent/durable-session.service.js';
|
|
||||||
import { ChatGateway } from '../../chat/chat.gateway.js';
|
|
||||||
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
|
|
||||||
|
|
||||||
const SERVICE_TOKEN = 'test-discord-service-token';
|
|
||||||
const envKeys = [
|
|
||||||
'DISCORD_SERVICE_TOKEN',
|
|
||||||
'DISCORD_SERVICE_TENANT_ID',
|
|
||||||
'DISCORD_INTERACTION_BINDINGS',
|
|
||||||
'DISCORD_ALLOWED_GUILD_IDS',
|
|
||||||
'DISCORD_ALLOWED_CHANNEL_IDS',
|
|
||||||
'DISCORD_ALLOWED_USER_IDS',
|
|
||||||
'MOSAIC_AGENT_NAME',
|
|
||||||
] as const;
|
|
||||||
const priorEnv = new Map<string, string | undefined>();
|
|
||||||
|
|
||||||
function payload(content: string, messageId: string, correlationId: string): DiscordIngressPayload {
|
|
||||||
return {
|
|
||||||
content,
|
|
||||||
messageId,
|
|
||||||
correlationId,
|
|
||||||
guildId: 'guild-1',
|
|
||||||
channelId: 'channel-1',
|
|
||||||
userId: 'discord-admin-1',
|
|
||||||
conversationId: 'Nova:discord:channel-1',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function authorization(): CommandAuthorizationService {
|
|
||||||
const entries = new Map<string, string>();
|
|
||||||
return new CommandAuthorizationService(
|
|
||||||
{
|
|
||||||
select: () => ({
|
|
||||||
from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }),
|
|
||||||
}),
|
|
||||||
} as never,
|
|
||||||
{
|
|
||||||
get: async (key: string) => entries.get(key) ?? null,
|
|
||||||
set: async (key: string, value: string) => entries.set(key, value),
|
|
||||||
del: async (key: string) => Number(entries.delete(key)),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('interaction Discord/CLI durable-session integration', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
for (const key of envKeys) {
|
|
||||||
const value = priorEnv.get(key);
|
|
||||||
if (value === undefined) delete process.env[key];
|
|
||||||
else process.env[key] = value;
|
|
||||||
}
|
|
||||||
priorEnv.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enrolls through the CLI surface then resolves the same durable session from Discord', async () => {
|
|
||||||
for (const key of envKeys) priorEnv.set(key, process.env[key]);
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
||||||
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
|
|
||||||
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-1';
|
|
||||||
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-1';
|
|
||||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-1';
|
|
||||||
process.env['DISCORD_ALLOWED_USER_IDS'] = 'discord-admin-1';
|
|
||||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
|
||||||
{
|
|
||||||
instanceId: 'Nova',
|
|
||||||
guildId: 'guild-1',
|
|
||||||
channelId: 'channel-1',
|
|
||||||
pairedUsers: {
|
|
||||||
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const durable = new DurableSessionService(
|
|
||||||
new InMemoryDurableSessionStore() as never,
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
const enrollmentRuntime = {
|
|
||||||
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
|
|
||||||
};
|
|
||||||
const controller = new InteractionController(enrollmentRuntime as never, durable);
|
|
||||||
await controller.enroll(
|
|
||||||
'Nova',
|
|
||||||
'Nova:discord:channel-1',
|
|
||||||
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
||||||
{ id: 'mosaic-admin-1', tenantId: 'tenant-1' },
|
|
||||||
'cli-enrollment-correlation',
|
|
||||||
);
|
|
||||||
|
|
||||||
const authz = authorization();
|
|
||||||
const terminated = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const runtime = new RuntimeProviderService(
|
|
||||||
{
|
|
||||||
require: () => ({
|
|
||||||
capabilities: async () => ({ supported: ['session.terminate'] }),
|
|
||||||
terminate: terminated,
|
|
||||||
}),
|
|
||||||
} as never,
|
|
||||||
{ record: async () => undefined } as never,
|
|
||||||
{
|
|
||||||
consume: (approvalId, action) =>
|
|
||||||
authz.consumeRuntimeTerminationApproval(approvalId, action),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const gateway = new ChatGateway(
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
authz,
|
|
||||||
runtime,
|
|
||||||
durable,
|
|
||||||
);
|
|
||||||
const client = { data: { discordService: true }, emit: vi.fn() };
|
|
||||||
const plugin = new DiscordPlugin({
|
|
||||||
token: 'unused',
|
|
||||||
gatewayUrl: 'http://unused',
|
|
||||||
serviceToken: SERVICE_TOKEN,
|
|
||||||
allowedGuildIds: ['guild-1'],
|
|
||||||
allowedChannelIds: ['channel-1'],
|
|
||||||
allowedUserIds: ['discord-admin-1'],
|
|
||||||
interactionBindings: [
|
|
||||||
{
|
|
||||||
instanceId: 'Nova',
|
|
||||||
guildId: 'guild-1',
|
|
||||||
channelId: 'channel-1',
|
|
||||||
pairedUsers: {
|
|
||||||
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const pluginInternals = plugin as unknown as {
|
|
||||||
client: { user: { id: string } };
|
|
||||||
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
|
|
||||||
handleDiscordMessage(message: unknown): void;
|
|
||||||
};
|
|
||||||
const pluginSocket = { connected: true, emit: vi.fn() };
|
|
||||||
pluginInternals.client = { user: { id: 'bot-1' } };
|
|
||||||
pluginInternals.socket = pluginSocket;
|
|
||||||
pluginInternals.handleDiscordMessage({
|
|
||||||
id: 'approve-1',
|
|
||||||
guildId: 'guild-1',
|
|
||||||
channelId: 'channel-1',
|
|
||||||
author: { id: 'discord-admin-1', bot: false },
|
|
||||||
mentions: { has: () => true },
|
|
||||||
content: '<@bot-1> /approve',
|
|
||||||
channel: { parentId: null },
|
|
||||||
attachments: new Map(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(pluginSocket.emit).toHaveBeenCalledWith('discord:approve', expect.any(Object));
|
|
||||||
const approvalEnvelope = pluginSocket.emit.mock.calls[0]?.[1];
|
|
||||||
await gateway.handleDiscordApproval(client as never, approvalEnvelope);
|
|
||||||
const approval = client.emit.mock.calls.find(
|
|
||||||
([event]) => event === 'discord:approval',
|
|
||||||
)?.[1] as {
|
|
||||||
approvalId: string;
|
|
||||||
success: boolean;
|
|
||||||
};
|
|
||||||
expect(approval.success).toBe(true);
|
|
||||||
|
|
||||||
await gateway.handleDiscordStop(
|
|
||||||
client as never,
|
|
||||||
createDiscordIngressEnvelope(
|
|
||||||
payload(`/stop ${approval.approvalId}`, 'stop-1', 'discord-stop-correlation'),
|
|
||||||
SERVICE_TOKEN,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(terminated).toHaveBeenCalledWith(
|
|
||||||
'runtime-1',
|
|
||||||
approval.approvalId,
|
|
||||||
expect.objectContaining({ actorId: 'mosaic-admin-1' }),
|
|
||||||
);
|
|
||||||
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
|
|
||||||
correlationId: 'discord-stop-correlation',
|
|
||||||
success: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||||
import { sql, type Db } from '@mosaicstack/db';
|
import { sql, type Db } from '@mosaic/db';
|
||||||
import { createQueue } from '@mosaicstack/queue';
|
import { createQueue } from '@mosaic/queue';
|
||||||
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';
|
||||||
@@ -20,7 +20,7 @@ export class AdminHealthController {
|
|||||||
async check(): Promise<HealthStatusDto> {
|
async check(): Promise<HealthStatusDto> {
|
||||||
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
|
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
|
||||||
|
|
||||||
const sessions = this.agentService.listAllSessionsForSystem();
|
const sessions = this.agentService.listSessions();
|
||||||
const providers = this.providerService.listProviders();
|
const providers = this.providerService.listProviders();
|
||||||
|
|
||||||
const allOk = database.status === 'ok' && cache.status === 'ok';
|
const allOk = database.status === 'ok' && cache.status === 'ok';
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { randomBytes, createHash } from 'node:crypto';
|
|
||||||
import { eq, type Db, adminTokens } from '@mosaicstack/db';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
import { DB } from '../database/database.module.js';
|
|
||||||
import { AdminGuard } from './admin.guard.js';
|
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
|
||||||
import type {
|
|
||||||
CreateTokenDto,
|
|
||||||
TokenCreatedDto,
|
|
||||||
TokenDto,
|
|
||||||
TokenListDto,
|
|
||||||
} from './admin-tokens.dto.js';
|
|
||||||
|
|
||||||
function hashToken(plaintext: string): string {
|
|
||||||
return createHash('sha256').update(plaintext).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
function toTokenDto(row: typeof adminTokens.$inferSelect): TokenDto {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
label: row.label,
|
|
||||||
scope: row.scope,
|
|
||||||
expiresAt: row.expiresAt?.toISOString() ?? null,
|
|
||||||
lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('api/admin/tokens')
|
|
||||||
@UseGuards(AdminGuard)
|
|
||||||
export class AdminTokensController {
|
|
||||||
constructor(@Inject(DB) private readonly db: Db) {}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
async create(
|
|
||||||
@Body() dto: CreateTokenDto,
|
|
||||||
@CurrentUser() user: { id: string },
|
|
||||||
): Promise<TokenCreatedDto> {
|
|
||||||
const plaintext = randomBytes(32).toString('hex');
|
|
||||||
const tokenHash = hashToken(plaintext);
|
|
||||||
const id = uuid();
|
|
||||||
|
|
||||||
const expiresAt = dto.expiresInDays
|
|
||||||
? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const [row] = await this.db
|
|
||||||
.insert(adminTokens)
|
|
||||||
.values({
|
|
||||||
id,
|
|
||||||
userId: user.id,
|
|
||||||
tokenHash,
|
|
||||||
label: dto.label ?? 'CLI token',
|
|
||||||
scope: dto.scope ?? 'admin',
|
|
||||||
expiresAt,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return { ...toTokenDto(row!), plaintext };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
async list(@CurrentUser() user: { id: string }): Promise<TokenListDto> {
|
|
||||||
const rows = await this.db
|
|
||||||
.select()
|
|
||||||
.from(adminTokens)
|
|
||||||
.where(eq(adminTokens.userId, user.id))
|
|
||||||
.orderBy(adminTokens.createdAt);
|
|
||||||
|
|
||||||
return { tokens: rows.map(toTokenDto), total: rows.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id')
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async revoke(@Param('id') id: string, @CurrentUser() _user: { id: string }): Promise<void> {
|
|
||||||
await this.db.delete(adminTokens).where(eq(adminTokens.id, id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { IsString, IsOptional, IsInt, Min } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateTokenDto {
|
|
||||||
@IsString()
|
|
||||||
label!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
scope?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
expiresInDays?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenDto {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
scope: string;
|
|
||||||
expiresAt: string | null;
|
|
||||||
lastUsedAt: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenCreatedDto extends TokenDto {
|
|
||||||
plaintext: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenListDto {
|
|
||||||
tokens: TokenDto[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { eq, type Db, users as usersTable } from '@mosaicstack/db';
|
import { eq, type Db, users as usersTable } from '@mosaic/db';
|
||||||
import type { Auth } from '@mosaicstack/auth';
|
import type { Auth } from '@mosaic/auth';
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
import { AdminGuard } from './admin.guard.js';
|
import { AdminGuard } from './admin.guard.js';
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { fromNodeHeaders } from 'better-auth/node';
|
import { fromNodeHeaders } from 'better-auth/node';
|
||||||
import type { Auth } from '@mosaicstack/auth';
|
import type { Auth } from '@mosaic/auth';
|
||||||
import type { Db } from '@mosaicstack/db';
|
import type { Db } from '@mosaic/db';
|
||||||
import { eq, adminTokens, users as usersTable } from '@mosaicstack/db';
|
import { eq, users as usersTable } from '@mosaic/db';
|
||||||
import type { FastifyRequest } from 'fastify';
|
import type { FastifyRequest } from 'fastify';
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
@@ -20,8 +19,6 @@ interface UserWithRole {
|
|||||||
role?: string;
|
role?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthenticatedRequest = FastifyRequest & { user: unknown; session: unknown };
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminGuard implements CanActivate {
|
export class AdminGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -31,64 +28,8 @@ export class AdminGuard implements CanActivate {
|
|||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||||
|
|
||||||
// Try bearer token auth first
|
|
||||||
const authHeader = request.raw.headers['authorization'];
|
|
||||||
if (authHeader?.startsWith('Bearer ')) {
|
|
||||||
return this.validateBearerToken(request, authHeader.slice(7));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to BetterAuth session
|
|
||||||
return this.validateSession(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async validateBearerToken(request: FastifyRequest, plaintext: string): Promise<boolean> {
|
|
||||||
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
|
|
||||||
|
|
||||||
const [row] = await this.db
|
|
||||||
.select({
|
|
||||||
tokenId: adminTokens.id,
|
|
||||||
userId: adminTokens.userId,
|
|
||||||
scope: adminTokens.scope,
|
|
||||||
expiresAt: adminTokens.expiresAt,
|
|
||||||
userName: usersTable.name,
|
|
||||||
userEmail: usersTable.email,
|
|
||||||
userRole: usersTable.role,
|
|
||||||
})
|
|
||||||
.from(adminTokens)
|
|
||||||
.innerJoin(usersTable, eq(adminTokens.userId, usersTable.id))
|
|
||||||
.where(eq(adminTokens.tokenHash, tokenHash))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
throw new UnauthorizedException('Invalid API token');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.expiresAt && row.expiresAt < new Date()) {
|
|
||||||
throw new UnauthorizedException('API token expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.userRole !== 'admin') {
|
|
||||||
throw new ForbiddenException('Admin access required');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last-used timestamp (fire-and-forget)
|
|
||||||
this.db
|
|
||||||
.update(adminTokens)
|
|
||||||
.set({ lastUsedAt: new Date() })
|
|
||||||
.where(eq(adminTokens.id, row.tokenId))
|
|
||||||
.then(() => {})
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
const req = request as AuthenticatedRequest;
|
|
||||||
req.user = { id: row.userId, name: row.userName, email: row.userEmail, role: row.userRole };
|
|
||||||
req.session = { id: `token:${row.tokenId}`, userId: row.userId };
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async validateSession(request: FastifyRequest): Promise<boolean> {
|
|
||||||
const headers = fromNodeHeaders(request.raw.headers);
|
const headers = fromNodeHeaders(request.raw.headers);
|
||||||
|
|
||||||
const result = await this.auth.api.getSession({ headers });
|
const result = await this.auth.api.getSession({ headers });
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
@@ -97,6 +38,8 @@ export class AdminGuard implements CanActivate {
|
|||||||
|
|
||||||
const user = result.user as UserWithRole;
|
const user = result.user as UserWithRole;
|
||||||
|
|
||||||
|
// Ensure the role field is populated. better-auth should include additionalFields
|
||||||
|
// in the session, but as a fallback, fetch the role from the database if needed.
|
||||||
let userRole = user.role;
|
let userRole = user.role;
|
||||||
if (!userRole) {
|
if (!userRole) {
|
||||||
const [dbUser] = await this.db
|
const [dbUser] = await this.db
|
||||||
@@ -105,6 +48,7 @@ export class AdminGuard implements CanActivate {
|
|||||||
.where(eq(usersTable.id, user.id))
|
.where(eq(usersTable.id, user.id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
userRole = dbUser?.role ?? 'member';
|
userRole = dbUser?.role ?? 'member';
|
||||||
|
// Update the session user object with the fetched role
|
||||||
(user as UserWithRole).role = userRole;
|
(user as UserWithRole).role = userRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,9 +56,8 @@ export class AdminGuard implements CanActivate {
|
|||||||
throw new ForbiddenException('Admin access required');
|
throw new ForbiddenException('Admin access required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const req = request as AuthenticatedRequest;
|
(request as FastifyRequest & { user: unknown; session: unknown }).user = result.user;
|
||||||
req.user = result.user;
|
(request as FastifyRequest & { user: unknown; session: unknown }).session = result.session;
|
||||||
req.session = result.session;
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,10 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AdminController } from './admin.controller.js';
|
import { AdminController } from './admin.controller.js';
|
||||||
import { AdminHealthController } from './admin-health.controller.js';
|
import { AdminHealthController } from './admin-health.controller.js';
|
||||||
import { AdminJobsController } from './admin-jobs.controller.js';
|
import { AdminJobsController } from './admin-jobs.controller.js';
|
||||||
import { AdminTokensController } from './admin-tokens.controller.js';
|
|
||||||
import { BootstrapController } from './bootstrap.controller.js';
|
|
||||||
import { AdminGuard } from './admin.guard.js';
|
import { AdminGuard } from './admin.guard.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [
|
controllers: [AdminController, AdminHealthController, AdminJobsController],
|
||||||
AdminController,
|
|
||||||
AdminHealthController,
|
|
||||||
AdminJobsController,
|
|
||||||
AdminTokensController,
|
|
||||||
BootstrapController,
|
|
||||||
],
|
|
||||||
providers: [AdminGuard],
|
providers: [AdminGuard],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
ForbiddenException,
|
|
||||||
Get,
|
|
||||||
Inject,
|
|
||||||
InternalServerErrorException,
|
|
||||||
Post,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { randomBytes, createHash } from 'node:crypto';
|
|
||||||
import { count, eq, type Db, users as usersTable, adminTokens } from '@mosaicstack/db';
|
|
||||||
import type { Auth } from '@mosaicstack/auth';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
|
||||||
import { DB } from '../database/database.module.js';
|
|
||||||
import { BootstrapSetupDto } from './bootstrap.dto.js';
|
|
||||||
import type { BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
|
|
||||||
|
|
||||||
@Controller('api/bootstrap')
|
|
||||||
export class BootstrapController {
|
|
||||||
constructor(
|
|
||||||
@Inject(AUTH) private readonly auth: Auth,
|
|
||||||
@Inject(DB) private readonly db: Db,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get('status')
|
|
||||||
async status(): Promise<BootstrapStatusDto> {
|
|
||||||
const [result] = await this.db.select({ total: count() }).from(usersTable);
|
|
||||||
return { needsSetup: (result?.total ?? 0) === 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('setup')
|
|
||||||
async setup(@Body() dto: BootstrapSetupDto): Promise<BootstrapResultDto> {
|
|
||||||
// Only allow setup when zero users exist
|
|
||||||
const [result] = await this.db.select({ total: count() }).from(usersTable);
|
|
||||||
if ((result?.total ?? 0) > 0) {
|
|
||||||
throw new ForbiddenException('Setup already completed — users exist');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create admin user via BetterAuth API
|
|
||||||
const authApi = this.auth.api as unknown as {
|
|
||||||
createUser: (opts: {
|
|
||||||
body: { name: string; email: string; password: string; role?: string };
|
|
||||||
}) => Promise<{
|
|
||||||
user: { id: string; name: string; email: string };
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const created = await authApi.createUser({
|
|
||||||
body: {
|
|
||||||
name: dto.name,
|
|
||||||
email: dto.email,
|
|
||||||
password: dto.password,
|
|
||||||
role: 'admin',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify user was created
|
|
||||||
const [user] = await this.db
|
|
||||||
.select()
|
|
||||||
.from(usersTable)
|
|
||||||
.where(eq(usersTable.id, created.user.id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!user) throw new InternalServerErrorException('User created but not found');
|
|
||||||
|
|
||||||
// Ensure role is admin (createUser may not set it via BetterAuth)
|
|
||||||
if (user.role !== 'admin') {
|
|
||||||
await this.db.update(usersTable).set({ role: 'admin' }).where(eq(usersTable.id, user.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate admin API token
|
|
||||||
const plaintext = randomBytes(32).toString('hex');
|
|
||||||
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
|
|
||||||
const tokenId = uuid();
|
|
||||||
|
|
||||||
const [token] = await this.db
|
|
||||||
.insert(adminTokens)
|
|
||||||
.values({
|
|
||||||
id: tokenId,
|
|
||||||
userId: user.id,
|
|
||||||
tokenHash,
|
|
||||||
label: 'Initial setup token',
|
|
||||||
scope: 'admin',
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return {
|
|
||||||
user: {
|
|
||||||
id: user.id,
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
role: 'admin',
|
|
||||||
},
|
|
||||||
token: {
|
|
||||||
id: token!.id,
|
|
||||||
plaintext,
|
|
||||||
label: token!.label,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { IsString, IsEmail, MinLength } from 'class-validator';
|
|
||||||
|
|
||||||
export class BootstrapSetupDto {
|
|
||||||
@IsString()
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@IsEmail()
|
|
||||||
email!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MinLength(8)
|
|
||||||
password!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BootstrapStatusDto {
|
|
||||||
needsSetup: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BootstrapResultDto {
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
};
|
|
||||||
token: {
|
|
||||||
id: string;
|
|
||||||
plaintext: string;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/**
|
|
||||||
* E2E integration test — POST /api/bootstrap/setup
|
|
||||||
*
|
|
||||||
* Regression guard for the `import type { BootstrapSetupDto }` class-erasure
|
|
||||||
* bug (IUV-M01, issue #436).
|
|
||||||
*
|
|
||||||
* When `BootstrapSetupDto` is imported with `import type`, TypeScript erases
|
|
||||||
* the class at compile time. NestJS then sees `Object` as the `@Body()`
|
|
||||||
* metatype, and ValidationPipe with `whitelist:true + forbidNonWhitelisted:true`
|
|
||||||
* treats every property as non-whitelisted, returning:
|
|
||||||
*
|
|
||||||
* 400 { message: ["property email should not exist", "property password should not exist"] }
|
|
||||||
*
|
|
||||||
* The fix is a plain value import (`import { BootstrapSetupDto }`), which
|
|
||||||
* preserves the class reference so Nest can read the class-validator decorators.
|
|
||||||
*
|
|
||||||
* This test MUST fail if `import type` is re-introduced on `BootstrapSetupDto`.
|
|
||||||
* A controller unit test that constructs ValidationPipe manually won't catch
|
|
||||||
* this — only the real DI binding path exercises the metatype lookup.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import 'reflect-metadata';
|
|
||||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { ValidationPipe, type INestApplication } from '@nestjs/common';
|
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { BootstrapController } from './bootstrap.controller.js';
|
|
||||||
import type { BootstrapResultDto } from './bootstrap.dto.js';
|
|
||||||
|
|
||||||
// ─── Minimal mock dependencies ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* We use explicit `@Inject(AUTH)` / `@Inject(DB)` in the controller so we
|
|
||||||
* can provide mock values by token without spinning up the real DB or Auth.
|
|
||||||
*/
|
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
|
||||||
import { DB } from '../database/database.module.js';
|
|
||||||
|
|
||||||
const MOCK_USER_ID = 'mock-user-id-001';
|
|
||||||
|
|
||||||
const mockAuth = {
|
|
||||||
api: {
|
|
||||||
createUser: () =>
|
|
||||||
Promise.resolve({
|
|
||||||
user: {
|
|
||||||
id: MOCK_USER_ID,
|
|
||||||
name: 'Admin',
|
|
||||||
email: 'admin@example.com',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Override db.select() so the second query (verify user exists) returns a user.
|
|
||||||
// The bootstrap controller calls select().from() twice:
|
|
||||||
// 1. count() to check zero users → returns [{total: 0}]
|
|
||||||
// 2. select().where().limit() → returns [the created user]
|
|
||||||
let selectCallCount = 0;
|
|
||||||
const mockDbWithUser = {
|
|
||||||
select: () => {
|
|
||||||
selectCallCount++;
|
|
||||||
return {
|
|
||||||
from: () => {
|
|
||||||
if (selectCallCount === 1) {
|
|
||||||
// First call: count — zero users
|
|
||||||
return Promise.resolve([{ total: 0 }]);
|
|
||||||
}
|
|
||||||
// Subsequent calls: return a mock user row
|
|
||||||
return {
|
|
||||||
where: () => ({
|
|
||||||
limit: () =>
|
|
||||||
Promise.resolve([
|
|
||||||
{
|
|
||||||
id: MOCK_USER_ID,
|
|
||||||
name: 'Admin',
|
|
||||||
email: 'admin@example.com',
|
|
||||||
role: 'admin',
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
update: () => ({
|
|
||||||
set: () => ({
|
|
||||||
where: () => Promise.resolve([]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
insert: () => ({
|
|
||||||
values: () => ({
|
|
||||||
returning: () =>
|
|
||||||
Promise.resolve([
|
|
||||||
{
|
|
||||||
id: 'token-id-001',
|
|
||||||
label: 'Initial setup token',
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Test suite ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
selectCallCount = 0;
|
|
||||||
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
controllers: [BootstrapController],
|
|
||||||
providers: [
|
|
||||||
{ provide: AUTH, useValue: mockAuth },
|
|
||||||
{ provide: DB, useValue: mockDbWithUser },
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
||||||
|
|
||||||
// Mirror main.ts configuration exactly — this is what reproduced the 400.
|
|
||||||
app.useGlobalPipes(
|
|
||||||
new ValidationPipe({
|
|
||||||
whitelist: true,
|
|
||||||
forbidNonWhitelisted: true,
|
|
||||||
transform: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await app.init();
|
|
||||||
// Fastify requires waiting for the adapter to be ready
|
|
||||||
await app.getHttpAdapter().getInstance().ready();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 201 (not 400) when a valid {name, email, password} body is sent', async () => {
|
|
||||||
const res = await request(app.getHttpServer())
|
|
||||||
.post('/api/bootstrap/setup')
|
|
||||||
.send({ name: 'Admin', email: 'admin@example.com', password: 'password123' })
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
// Before the fix (import type), Nest ValidationPipe returned 400 with
|
|
||||||
// "property email should not exist" / "property password should not exist"
|
|
||||||
// because the DTO class was erased and every field looked non-whitelisted.
|
|
||||||
expect(res.status).not.toBe(400);
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
const body = res.body as BootstrapResultDto;
|
|
||||||
expect(body.user).toBeDefined();
|
|
||||||
expect(body.user.email).toBe('admin@example.com');
|
|
||||||
expect(body.token).toBeDefined();
|
|
||||||
expect(body.token.plaintext).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when extra forbidden properties are sent', async () => {
|
|
||||||
// This proves ValidationPipe IS active and working (forbidNonWhitelisted).
|
|
||||||
const res = await request(app.getHttpServer())
|
|
||||||
.post('/api/bootstrap/setup')
|
|
||||||
.send({
|
|
||||||
name: 'Admin',
|
|
||||||
email: 'admin@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
extraField: 'should-be-rejected',
|
|
||||||
})
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when email is invalid', async () => {
|
|
||||||
const res = await request(app.getHttpServer())
|
|
||||||
.post('/api/bootstrap/setup')
|
|
||||||
.send({ name: 'Admin', email: 'not-an-email', password: 'password123' })
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when password is too short', async () => {
|
|
||||||
const res = await request(app.getHttpServer())
|
|
||||||
.post('/api/bootstrap/setup')
|
|
||||||
.send({ name: 'Admin', email: 'admin@example.com', password: 'short' })
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import { ForbiddenException } from '@nestjs/common';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { AgentService, type AgentSession } from '../agent.service.js';
|
|
||||||
import type { ActorTenantScope } from '../../auth/session-scope.js';
|
|
||||||
|
|
||||||
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
|
|
||||||
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-user', tenantId: 'owner-tenant' };
|
|
||||||
const FOREIGN_SCOPE: ActorTenantScope = { userId: 'foreign-user', tenantId: 'foreign-tenant' };
|
|
||||||
|
|
||||||
type AgentServiceInternals = {
|
|
||||||
sessions: Map<string, AgentSession>;
|
|
||||||
creating: Map<string, Promise<AgentSession>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeService(operatorMemory: unknown = null): AgentService {
|
|
||||||
return new AgentService(
|
|
||||||
{
|
|
||||||
getDefaultModel: vi.fn(() => null),
|
|
||||||
getRegistry: vi.fn(() => ({})),
|
|
||||||
findModel: vi.fn(),
|
|
||||||
listAvailableModels: vi.fn(() => []),
|
|
||||||
} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{ available: false } as never,
|
|
||||||
{} as never,
|
|
||||||
{ getToolDefinitions: vi.fn(() => []) } as never,
|
|
||||||
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
|
||||||
operatorMemory as never,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function internals(service: AgentService): AgentServiceInternals {
|
|
||||||
return service as unknown as AgentServiceInternals;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeSession(scope: ActorTenantScope = OWNER_SCOPE): AgentSession {
|
|
||||||
return {
|
|
||||||
id: CONVERSATION_ID,
|
|
||||||
provider: 'test-provider',
|
|
||||||
modelId: 'test-model',
|
|
||||||
piSession: {
|
|
||||||
thinkingLevel: 'off',
|
|
||||||
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
|
|
||||||
setThinkingLevel: vi.fn(),
|
|
||||||
abort: vi.fn().mockResolvedValue(undefined),
|
|
||||||
prompt: vi.fn().mockResolvedValue(undefined),
|
|
||||||
dispose: vi.fn(),
|
|
||||||
getSessionStats: vi.fn(),
|
|
||||||
getContextUsage: vi.fn(),
|
|
||||||
} as unknown as AgentSession['piSession'],
|
|
||||||
listeners: new Set(),
|
|
||||||
unsubscribe: vi.fn(),
|
|
||||||
createdAt: Date.now(),
|
|
||||||
promptCount: 0,
|
|
||||||
channels: new Set(),
|
|
||||||
skillPromptAdditions: [],
|
|
||||||
sandboxDir: '/tmp/tess-session-ownership-test',
|
|
||||||
allowedTools: null,
|
|
||||||
userId: scope.userId,
|
|
||||||
tenantId: scope.tenantId,
|
|
||||||
metrics: {
|
|
||||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
modelSwitches: 0,
|
|
||||||
messageCount: 0,
|
|
||||||
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AgentService owner/tenant scope enforcement', () => {
|
|
||||||
it('allows owner-scoped operations and rejects foreign scopes for seeded sessions', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
const session = makeSession();
|
|
||||||
internals(service).sessions.set(CONVERSATION_ID, session);
|
|
||||||
|
|
||||||
expect(service.getSession(CONVERSATION_ID, OWNER_SCOPE)).toBe(session);
|
|
||||||
expect(service.getSession(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
|
|
||||||
expect(service.getSessionInfo(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
|
|
||||||
expect(service.listSessions(OWNER_SCOPE)).toHaveLength(1);
|
|
||||||
expect(service.listSessions(FOREIGN_SCOPE)).toEqual([]);
|
|
||||||
|
|
||||||
service.addChannel(CONVERSATION_ID, 'websocket:owner', OWNER_SCOPE);
|
|
||||||
expect(session.channels.has('websocket:owner')).toBe(true);
|
|
||||||
expect(() => service.addChannel(CONVERSATION_ID, 'websocket:foreign', FOREIGN_SCOPE)).toThrow(
|
|
||||||
ForbiddenException,
|
|
||||||
);
|
|
||||||
expect(() => service.removeChannel(CONVERSATION_ID, 'websocket:owner', FOREIGN_SCOPE)).toThrow(
|
|
||||||
ForbiddenException,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
service.updateSessionModel(CONVERSATION_ID, 'foreign-model', FOREIGN_SCOPE),
|
|
||||||
).toThrow(ForbiddenException);
|
|
||||||
service.updateSessionModel(CONVERSATION_ID, 'owner-model', OWNER_SCOPE);
|
|
||||||
expect(session.modelId).toBe('owner-model');
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
service.applyAgentConfig(CONVERSATION_ID, 'agent-foreign', 'Foreign Agent', FOREIGN_SCOPE),
|
|
||||||
).toThrow(ForbiddenException);
|
|
||||||
service.applyAgentConfig(CONVERSATION_ID, 'agent-owner', 'Owner Agent', OWNER_SCOPE);
|
|
||||||
expect(session.agentConfigId).toBe('agent-owner');
|
|
||||||
|
|
||||||
expect(() => service.onEvent(CONVERSATION_ID, vi.fn(), FOREIGN_SCOPE)).toThrow(
|
|
||||||
ForbiddenException,
|
|
||||||
);
|
|
||||||
const cleanup = service.onEvent(CONVERSATION_ID, vi.fn(), OWNER_SCOPE);
|
|
||||||
cleanup();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.prompt(CONVERSATION_ID, 'foreign prompt', FOREIGN_SCOPE),
|
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
|
||||||
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
|
|
||||||
expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt');
|
|
||||||
|
|
||||||
await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
|
|
||||||
ForbiddenException,
|
|
||||||
);
|
|
||||||
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(true);
|
|
||||||
|
|
||||||
await service.destroySession(CONVERSATION_ID, OWNER_SCOPE);
|
|
||||||
expect(session.piSession.dispose).toHaveBeenCalled();
|
|
||||||
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('derives the operator-memory scope on the createSession production path', async () => {
|
|
||||||
const plugin = { capture: vi.fn(), search: vi.fn() };
|
|
||||||
const service = makeService(plugin);
|
|
||||||
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox').mockReturnValue([]);
|
|
||||||
|
|
||||||
// Session construction reaches the real scope derivation before the intentionally incomplete
|
|
||||||
// Pi test double rejects later in createAgentSession.
|
|
||||||
await service.createSession(CONVERSATION_ID, OWNER_SCOPE).catch(() => undefined);
|
|
||||||
|
|
||||||
expect(buildTools).toHaveBeenCalledWith(expect.any(String), OWNER_SCOPE.userId, {
|
|
||||||
tenantId: OWNER_SCOPE.tenantId,
|
|
||||||
ownerId: OWNER_SCOPE.userId,
|
|
||||||
sessionId: CONVERSATION_ID,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies a foreign actor before it can obtain another session operator-memory scope', async () => {
|
|
||||||
const plugin = { capture: vi.fn(), search: vi.fn() };
|
|
||||||
const service = makeService(plugin);
|
|
||||||
internals(service).sessions.set(CONVERSATION_ID, makeSession());
|
|
||||||
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox');
|
|
||||||
|
|
||||||
await expect(service.createSession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
|
|
||||||
ForbiddenException,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(buildTools).not.toHaveBeenCalled();
|
|
||||||
expect(plugin.capture).not.toHaveBeenCalled();
|
|
||||||
expect(plugin.search).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('checks owner/tenant scope before returning an in-flight session creation', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
const session = makeSession();
|
|
||||||
internals(service).creating.set(CONVERSATION_ID, Promise.resolve(session));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.createSession(CONVERSATION_ID, {
|
|
||||||
userId: FOREIGN_SCOPE.userId,
|
|
||||||
tenantId: FOREIGN_SCOPE.tenantId,
|
|
||||||
}),
|
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.createSession(CONVERSATION_ID, {
|
|
||||||
userId: OWNER_SCOPE.userId,
|
|
||||||
tenantId: OWNER_SCOPE.tenantId,
|
|
||||||
}),
|
|
||||||
).resolves.toBe(session);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -62,7 +62,7 @@ function restoreEnv(saved: Map<EnvKey, string | undefined>): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeRegistry(): ModelRegistry {
|
function makeRegistry(): ModelRegistry {
|
||||||
return ModelRegistry.inMemory(AuthStorage.inMemory());
|
return new ModelRegistry(AuthStorage.inMemory());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { RoutingService } from '../routing.service.js';
|
import { RoutingService } from '../routing.service.js';
|
||||||
import type { ModelInfo } from '@mosaicstack/types';
|
import type { ModelInfo } from '@mosaic/types';
|
||||||
|
|
||||||
const mockModels: ModelInfo[] = [
|
const mockModels: ModelInfo[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,370 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type {
|
|
||||||
AgentRuntimeProvider,
|
|
||||||
RuntimeAttachHandle,
|
|
||||||
RuntimeAttachMode,
|
|
||||||
RuntimeCapability,
|
|
||||||
RuntimeCapabilitySet,
|
|
||||||
RuntimeHealth,
|
|
||||||
RuntimeMessage,
|
|
||||||
RuntimeScope,
|
|
||||||
RuntimeSession,
|
|
||||||
RuntimeSessionTree,
|
|
||||||
RuntimeStreamEvent,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
|
||||||
import type { ActorTenantScope } from '../../auth/session-scope.js';
|
|
||||||
import {
|
|
||||||
RuntimeProviderAuditService,
|
|
||||||
RuntimeProviderService,
|
|
||||||
type RuntimeAuditEvent,
|
|
||||||
type RuntimeAuditSink,
|
|
||||||
type RuntimeApprovalVerifier,
|
|
||||||
} from '../runtime-provider-registry.service.js';
|
|
||||||
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] ??= 'test-runtime-agent';
|
|
||||||
|
|
||||||
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' };
|
|
||||||
const CONTEXT = {
|
|
||||||
actorScope: OWNER_SCOPE,
|
|
||||||
channelId: 'cli',
|
|
||||||
correlationId: 'correlation-1',
|
|
||||||
};
|
|
||||||
|
|
||||||
class RecordingRuntimeProvider implements AgentRuntimeProvider {
|
|
||||||
readonly id = 'fleet';
|
|
||||||
readonly receivedScopes: RuntimeScope[] = [];
|
|
||||||
readonly sentMessages: RuntimeMessage[] = [];
|
|
||||||
terminateCalls = 0;
|
|
||||||
throwAfterSend = false;
|
|
||||||
throwAuthorization = false;
|
|
||||||
|
|
||||||
constructor(private readonly supported: RuntimeCapability[]) {}
|
|
||||||
|
|
||||||
async capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
|
|
||||||
this.receivedScopes.push(scope);
|
|
||||||
return { supported: this.supported };
|
|
||||||
}
|
|
||||||
|
|
||||||
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
|
|
||||||
this.receivedScopes.push(scope);
|
|
||||||
return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' };
|
|
||||||
}
|
|
||||||
|
|
||||||
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
|
|
||||||
this.receivedScopes.push(scope);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
|
|
||||||
this.receivedScopes.push(scope);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async *streamSession(
|
|
||||||
_sessionId: string,
|
|
||||||
_cursor: string | undefined,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
): AsyncIterable<RuntimeStreamEvent> {
|
|
||||||
this.receivedScopes.push(scope);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendMessage(
|
|
||||||
_sessionId: string,
|
|
||||||
message: RuntimeMessage,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
): Promise<void> {
|
|
||||||
this.receivedScopes.push(scope);
|
|
||||||
this.sentMessages.push(message);
|
|
||||||
if (this.throwAuthorization) {
|
|
||||||
throw Object.assign(new Error('provider authorization denied'), { code: 'forbidden' });
|
|
||||||
}
|
|
||||||
if (this.throwAfterSend) {
|
|
||||||
throw new Error('provider acknowledgement failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async attach(
|
|
||||||
sessionId: string,
|
|
||||||
mode: RuntimeAttachMode,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
): Promise<RuntimeAttachHandle> {
|
|
||||||
this.receivedScopes.push(scope);
|
|
||||||
return {
|
|
||||||
attachmentId: 'attachment-1',
|
|
||||||
sessionId,
|
|
||||||
mode,
|
|
||||||
expiresAt: '2026-07-12T00:00:00.000Z',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async detach(_attachmentId: string, scope: RuntimeScope): Promise<void> {
|
|
||||||
this.receivedScopes.push(scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
async terminate(_sessionId: string, _approvalRef: string, scope: RuntimeScope): Promise<void> {
|
|
||||||
this.receivedScopes.push(scope);
|
|
||||||
this.terminateCalls += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RecordingAuditSink implements RuntimeAuditSink {
|
|
||||||
readonly events: RuntimeAuditEvent[] = [];
|
|
||||||
|
|
||||||
async record(event: RuntimeAuditEvent): Promise<void> {
|
|
||||||
this.events.push(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class DenyingApprovalVerifier implements RuntimeApprovalVerifier {
|
|
||||||
async consume(): Promise<boolean> {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AcceptingApprovalVerifier implements RuntimeApprovalVerifier {
|
|
||||||
consumedAction: Parameters<RuntimeApprovalVerifier['consume']>[1] | undefined;
|
|
||||||
|
|
||||||
async consume(
|
|
||||||
_approvalRef: string,
|
|
||||||
action: Parameters<RuntimeApprovalVerifier['consume']>[1],
|
|
||||||
): Promise<boolean> {
|
|
||||||
this.consumedAction = action;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeService(
|
|
||||||
provider: RecordingRuntimeProvider,
|
|
||||||
audit: RuntimeAuditSink = new RecordingAuditSink(),
|
|
||||||
approval: RuntimeApprovalVerifier = new DenyingApprovalVerifier(),
|
|
||||||
): RuntimeProviderService {
|
|
||||||
const registry = new AgentRuntimeProviderRegistry();
|
|
||||||
registry.register(provider);
|
|
||||||
return new RuntimeProviderService(registry, audit, approval);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('RuntimeProviderService security boundary', (): void => {
|
|
||||||
it('derives and freezes only the authenticated actor scope while preserving correlation metadata', async (): Promise<void> => {
|
|
||||||
const provider = new RecordingRuntimeProvider(['session.send']);
|
|
||||||
const audit = new RecordingAuditSink();
|
|
||||||
const service = makeService(provider, audit);
|
|
||||||
|
|
||||||
await service.sendMessage(
|
|
||||||
'fleet',
|
|
||||||
'session-1',
|
|
||||||
{ content: 'hello', idempotencyKey: 'key-1' },
|
|
||||||
CONTEXT,
|
|
||||||
);
|
|
||||||
|
|
||||||
const providerScope = provider.receivedScopes[0];
|
|
||||||
expect(providerScope).toEqual({
|
|
||||||
actorId: OWNER_SCOPE.userId,
|
|
||||||
tenantId: OWNER_SCOPE.tenantId,
|
|
||||||
channelId: CONTEXT.channelId,
|
|
||||||
correlationId: CONTEXT.correlationId,
|
|
||||||
});
|
|
||||||
expect(Object.isFrozen(providerScope)).toBe(true);
|
|
||||||
expect(audit.events).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
providerId: 'fleet',
|
|
||||||
operation: 'session.send',
|
|
||||||
outcome: 'succeeded',
|
|
||||||
actorId: OWNER_SCOPE.userId,
|
|
||||||
tenantId: OWNER_SCOPE.tenantId,
|
|
||||||
channelId: CONTEXT.channelId,
|
|
||||||
correlationId: CONTEXT.correlationId,
|
|
||||||
resourceId: 'session-1',
|
|
||||||
durationMs: expect.any(Number),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(JSON.stringify(audit.events)).not.toContain('hello');
|
|
||||||
expect(JSON.stringify(audit.events)).not.toContain('key-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not block a provider operation when an unsafe resource ID is redacted in durable audit', async (): Promise<void> => {
|
|
||||||
const provider = new RecordingRuntimeProvider(['session.send']);
|
|
||||||
let persisted: unknown;
|
|
||||||
const durableAudit = new RuntimeProviderAuditService({
|
|
||||||
logs: {
|
|
||||||
ingest: async (entry: unknown): Promise<unknown> => {
|
|
||||||
persisted = entry;
|
|
||||||
return entry;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as never);
|
|
||||||
const service = makeService(provider, durableAudit);
|
|
||||||
|
|
||||||
await service.sendMessage(
|
|
||||||
'fleet',
|
|
||||||
'session/credential-canary=secret-value',
|
|
||||||
{ content: 'safe message', idempotencyKey: 'key-1' },
|
|
||||||
CONTEXT,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(provider.sentMessages).toHaveLength(1);
|
|
||||||
expect(JSON.stringify(persisted)).not.toContain('secret-value');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed before a provider side effect when a capability is missing', async (): Promise<void> => {
|
|
||||||
const provider = new RecordingRuntimeProvider([]);
|
|
||||||
const service = makeService(provider);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.sendMessage(
|
|
||||||
'fleet',
|
|
||||||
'session-1',
|
|
||||||
{ content: 'hello', idempotencyKey: 'key-1' },
|
|
||||||
CONTEXT,
|
|
||||||
),
|
|
||||||
).rejects.toThrow(/capability denied/);
|
|
||||||
expect(provider.sentMessages).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requires a consumed exact-action approval before termination', async (): Promise<void> => {
|
|
||||||
const provider = new RecordingRuntimeProvider(['session.terminate']);
|
|
||||||
const approval = new DenyingApprovalVerifier();
|
|
||||||
const audit = new RecordingAuditSink();
|
|
||||||
const service = makeService(provider, audit, approval);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT),
|
|
||||||
).rejects.toThrow(/approval denied/);
|
|
||||||
expect(provider.terminateCalls).toBe(0);
|
|
||||||
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('binds an accepted termination approval to provider, session, immutable scope, and correlation', async (): Promise<void> => {
|
|
||||||
const provider = new RecordingRuntimeProvider(['session.terminate']);
|
|
||||||
const approval = new AcceptingApprovalVerifier();
|
|
||||||
const service = makeService(provider, new RecordingAuditSink(), approval);
|
|
||||||
|
|
||||||
await service.terminate('fleet', 'session-1', 'approval-1', CONTEXT);
|
|
||||||
|
|
||||||
expect(approval.consumedAction).toEqual({
|
|
||||||
providerId: 'fleet',
|
|
||||||
sessionId: 'session-1',
|
|
||||||
actorId: OWNER_SCOPE.userId,
|
|
||||||
tenantId: OWNER_SCOPE.tenantId,
|
|
||||||
channelId: CONTEXT.channelId,
|
|
||||||
correlationId: CONTEXT.correlationId,
|
|
||||||
agentName: process.env['MOSAIC_AGENT_NAME'],
|
|
||||||
});
|
|
||||||
expect(provider.terminateCalls).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed before invoking a provider when audit persistence rejects the request', async (): Promise<void> => {
|
|
||||||
const provider = new RecordingRuntimeProvider(['session.send']);
|
|
||||||
const unavailableAudit: RuntimeAuditSink = {
|
|
||||||
async record(): Promise<void> {
|
|
||||||
throw new Error('audit unavailable');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const service = makeService(provider, unavailableAudit);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.sendMessage(
|
|
||||||
'fleet',
|
|
||||||
'session-1',
|
|
||||||
{ content: 'hello', idempotencyKey: 'key-1' },
|
|
||||||
CONTEXT,
|
|
||||||
),
|
|
||||||
).rejects.toThrow(/audit unavailable/);
|
|
||||||
expect(provider.sentMessages).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('records a provider error after invocation as failed rather than denied', async (): Promise<void> => {
|
|
||||||
const provider = new RecordingRuntimeProvider(['session.send']);
|
|
||||||
provider.throwAfterSend = true;
|
|
||||||
const audit = new RecordingAuditSink();
|
|
||||||
const service = makeService(provider, audit);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.sendMessage(
|
|
||||||
'fleet',
|
|
||||||
'session-1',
|
|
||||||
{ content: 'hello', idempotencyKey: 'key-1' },
|
|
||||||
CONTEXT,
|
|
||||||
),
|
|
||||||
).rejects.toThrow(/provider acknowledgement failed/);
|
|
||||||
expect(provider.sentMessages).toHaveLength(1);
|
|
||||||
expect(audit.events.map((event: RuntimeAuditEvent): string => event.outcome)).toEqual([
|
|
||||||
'requested',
|
|
||||||
'failed',
|
|
||||||
]);
|
|
||||||
expect(audit.events.at(-1)).toMatchObject({
|
|
||||||
errorCode: 'provider_error',
|
|
||||||
durationMs: expect.any(Number),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('records a provider authorization rejection as denied rather than provider failure', async (): Promise<void> => {
|
|
||||||
const provider = new RecordingRuntimeProvider(['session.send']);
|
|
||||||
provider.throwAuthorization = true;
|
|
||||||
const audit = new RecordingAuditSink();
|
|
||||||
const service = makeService(provider, audit);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.sendMessage(
|
|
||||||
'fleet',
|
|
||||||
'session-1',
|
|
||||||
{ content: 'hello', idempotencyKey: 'key-1' },
|
|
||||||
CONTEXT,
|
|
||||||
),
|
|
||||||
).rejects.toThrow(/provider authorization denied/);
|
|
||||||
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('persists only metadata-only runtime audit fields', async (): Promise<void> => {
|
|
||||||
let persisted: unknown;
|
|
||||||
const ingest = async (entry: unknown): Promise<unknown> => {
|
|
||||||
persisted = entry;
|
|
||||||
return entry;
|
|
||||||
};
|
|
||||||
const service = new RuntimeProviderAuditService({ logs: { ingest } } as never);
|
|
||||||
|
|
||||||
await service.record({
|
|
||||||
providerId: 'fleet',
|
|
||||||
operation: 'session.send',
|
|
||||||
outcome: 'succeeded',
|
|
||||||
actorId: 'owner-1',
|
|
||||||
tenantId: 'tenant-1',
|
|
||||||
channelId: 'cli',
|
|
||||||
correlationId: 'correlation-1',
|
|
||||||
resourceId: 'session-1',
|
|
||||||
durationMs: 12,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(persisted).toMatchObject({
|
|
||||||
content: 'runtime.provider.audit',
|
|
||||||
metadata: expect.objectContaining({ correlationId: 'correlation-1', durationMs: 12 }),
|
|
||||||
});
|
|
||||||
expect(JSON.stringify(persisted)).not.toContain('approval');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not misreport a completed provider side effect when completion auditing fails', async (): Promise<void> => {
|
|
||||||
const provider = new RecordingRuntimeProvider(['session.send']);
|
|
||||||
let auditCalls = 0;
|
|
||||||
const audit: RuntimeAuditSink = {
|
|
||||||
async record(): Promise<void> {
|
|
||||||
auditCalls += 1;
|
|
||||||
if (auditCalls === 2) {
|
|
||||||
throw new Error('completion audit unavailable');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const service = makeService(provider, audit);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.sendMessage(
|
|
||||||
'fleet',
|
|
||||||
'session-1',
|
|
||||||
{ content: 'hello', idempotencyKey: 'key-1' },
|
|
||||||
CONTEXT,
|
|
||||||
),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
expect(provider.sentMessages).toHaveLength(1);
|
|
||||||
expect(auditCalls).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
|
|
||||||
vi.mock('../../commands/command-executor.service.js', () => ({
|
|
||||||
CommandExecutorService: class CommandExecutorService {},
|
|
||||||
}));
|
|
||||||
vi.mock('../routing/routing-engine.service.js', () => ({
|
|
||||||
RoutingEngineService: class RoutingEngineService {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { SessionsController } from '../sessions.controller.js';
|
|
||||||
import { ChatController } from '../../chat/chat.controller.js';
|
|
||||||
import { ChatGateway } from '../../chat/chat.gateway.js';
|
|
||||||
import type { AgentSession } from '../agent.service.js';
|
|
||||||
import type { SessionInfoDto } from '../session.dto.js';
|
|
||||||
|
|
||||||
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
|
|
||||||
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
|
|
||||||
const CONVERSATION_ID = '11111111-1111-4111-8111-111111111111';
|
|
||||||
|
|
||||||
function makeSessionInfo(overrides?: Partial<SessionInfoDto>): SessionInfoDto {
|
|
||||||
return {
|
|
||||||
id: CONVERSATION_ID,
|
|
||||||
provider: 'test-provider',
|
|
||||||
modelId: 'test-model',
|
|
||||||
createdAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
|
||||||
promptCount: 0,
|
|
||||||
channels: [],
|
|
||||||
durationMs: 0,
|
|
||||||
metrics: {
|
|
||||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
modelSwitches: 0,
|
|
||||||
messageCount: 0,
|
|
||||||
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
|
||||||
},
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeAgentSession(owner = USER_A): AgentSession {
|
|
||||||
return {
|
|
||||||
id: CONVERSATION_ID,
|
|
||||||
provider: 'test-provider',
|
|
||||||
modelId: 'test-model',
|
|
||||||
piSession: {
|
|
||||||
thinkingLevel: 'off',
|
|
||||||
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
|
|
||||||
setThinkingLevel: vi.fn(),
|
|
||||||
abort: vi.fn().mockResolvedValue(undefined),
|
|
||||||
prompt: vi.fn().mockResolvedValue(undefined),
|
|
||||||
dispose: vi.fn(),
|
|
||||||
getSessionStats: vi.fn(),
|
|
||||||
getContextUsage: vi.fn(),
|
|
||||||
} as unknown as AgentSession['piSession'],
|
|
||||||
listeners: new Set(),
|
|
||||||
unsubscribe: vi.fn(),
|
|
||||||
createdAt: Date.now(),
|
|
||||||
promptCount: 0,
|
|
||||||
channels: new Set(),
|
|
||||||
skillPromptAdditions: [],
|
|
||||||
sandboxDir: '/tmp',
|
|
||||||
allowedTools: null,
|
|
||||||
userId: owner.id,
|
|
||||||
tenantId: owner.tenantId,
|
|
||||||
metrics: {
|
|
||||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
modelSwitches: 0,
|
|
||||||
messageCount: 0,
|
|
||||||
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeScopedAgentService() {
|
|
||||||
const foreign = makeAgentSession(USER_A);
|
|
||||||
return {
|
|
||||||
listSessions: vi.fn((scope?: { userId: string; tenantId?: string }) =>
|
|
||||||
scope?.userId === USER_B.id ? [] : [makeSessionInfo({ id: foreign.id })],
|
|
||||||
),
|
|
||||||
getSessionInfo: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
|
|
||||||
scope?.userId === USER_B.id ? undefined : makeSessionInfo({ id: foreign.id }),
|
|
||||||
),
|
|
||||||
destroySession: vi.fn(),
|
|
||||||
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
|
|
||||||
scope?.userId === USER_B.id ? undefined : foreign,
|
|
||||||
),
|
|
||||||
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
|
|
||||||
onEvent: vi.fn(() => vi.fn()),
|
|
||||||
addChannel: vi.fn(),
|
|
||||||
removeChannel: vi.fn(),
|
|
||||||
recordMessage: vi.fn(),
|
|
||||||
prompt: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
|
|
||||||
it('requires explicit owner+tenant scope on protected session operations', () => {
|
|
||||||
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
|
|
||||||
|
|
||||||
expect(source).toContain('getSession(sessionId: string, scope: ActorTenantScope)');
|
|
||||||
expect(source).toContain('listSessions(scope: ActorTenantScope)');
|
|
||||||
expect(source).toContain('getSessionInfo(sessionId: string, scope: ActorTenantScope)');
|
|
||||||
expect(source).toContain(
|
|
||||||
'addChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
|
|
||||||
);
|
|
||||||
expect(source).toContain(
|
|
||||||
'removeChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
|
|
||||||
);
|
|
||||||
expect(source).toContain(
|
|
||||||
'async prompt(sessionId: string, message: string, scope: ActorTenantScope)',
|
|
||||||
);
|
|
||||||
expect(source).toContain('scope: ActorTenantScope,');
|
|
||||||
expect(source).toContain('async destroySession(sessionId: string, scope: ActorTenantScope)');
|
|
||||||
expect(source).not.toContain('scope?: ActorTenantScope');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
|
|
||||||
it('lists only sessions owned by the authenticated owner+tenant scope', () => {
|
|
||||||
const agentService = makeScopedAgentService();
|
|
||||||
const controller = new SessionsController(agentService as never);
|
|
||||||
|
|
||||||
expect(controller.list(USER_B)).toEqual({ sessions: [], total: 0 });
|
|
||||||
expect(agentService.listSessions).toHaveBeenCalledWith({
|
|
||||||
userId: USER_B.id,
|
|
||||||
tenantId: USER_B.tenantId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not reveal another owner/tenant session by guessed id', () => {
|
|
||||||
const agentService = makeScopedAgentService();
|
|
||||||
const controller = new SessionsController(agentService as never);
|
|
||||||
|
|
||||||
expect(() => controller.findOne(CONVERSATION_ID, USER_B)).toThrow(NotFoundException);
|
|
||||||
expect(agentService.getSessionInfo).toHaveBeenCalledWith(CONVERSATION_ID, {
|
|
||||||
userId: USER_B.id,
|
|
||||||
tenantId: USER_B.tenantId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not terminate another owner/tenant session by guessed id', async () => {
|
|
||||||
const agentService = makeScopedAgentService();
|
|
||||||
const controller = new SessionsController(agentService as never);
|
|
||||||
|
|
||||||
await expect(controller.destroy(CONVERSATION_ID, USER_B)).rejects.toBeInstanceOf(
|
|
||||||
NotFoundException,
|
|
||||||
);
|
|
||||||
expect(agentService.destroySession).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
|
|
||||||
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
|
|
||||||
const agentService = makeScopedAgentService();
|
|
||||||
const controller = new ChatController(agentService as never);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
|
|
||||||
).rejects.toMatchObject({ status: 404 });
|
|
||||||
|
|
||||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
|
||||||
userId: USER_B.id,
|
|
||||||
tenantId: USER_B.tenantId,
|
|
||||||
});
|
|
||||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
|
|
||||||
function makeGateway(agentService = makeScopedAgentService()) {
|
|
||||||
const brain = {
|
|
||||||
conversations: {
|
|
||||||
findById: vi.fn().mockResolvedValue(undefined),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
findMessages: vi.fn().mockResolvedValue([]),
|
|
||||||
addMessage: vi.fn().mockResolvedValue(undefined),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) };
|
|
||||||
const commandExecutor = { execute: vi.fn() };
|
|
||||||
const routingEngine = {
|
|
||||||
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
|
|
||||||
};
|
|
||||||
const gateway = new ChatGateway(
|
|
||||||
agentService as never,
|
|
||||||
{} as never,
|
|
||||||
brain as never,
|
|
||||||
commandRegistry as never,
|
|
||||||
commandExecutor as never,
|
|
||||||
routingEngine as never,
|
|
||||||
);
|
|
||||||
return { gateway, agentService };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeSocket() {
|
|
||||||
return {
|
|
||||||
id: 'socket-b',
|
|
||||||
connected: true,
|
|
||||||
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
|
|
||||||
emit: vi.fn(),
|
|
||||||
disconnect: vi.fn(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
|
|
||||||
const { gateway, agentService } = makeGateway();
|
|
||||||
const socket = makeSocket();
|
|
||||||
|
|
||||||
await gateway.handleMessage(socket as never, {
|
|
||||||
conversationId: CONVERSATION_ID,
|
|
||||||
content: 'attach to foreign session',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
|
||||||
userId: USER_B.id,
|
|
||||||
tenantId: USER_B.tenantId,
|
|
||||||
});
|
|
||||||
expect(agentService.onEvent).not.toHaveBeenCalled();
|
|
||||||
expect(agentService.addChannel).not.toHaveBeenCalled();
|
|
||||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
|
||||||
expect(socket.emit).toHaveBeenCalledWith(
|
|
||||||
'error',
|
|
||||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not mutate thinking level on another owner/tenant session', () => {
|
|
||||||
const { gateway, agentService } = makeGateway();
|
|
||||||
const socket = makeSocket();
|
|
||||||
|
|
||||||
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
|
|
||||||
|
|
||||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
|
||||||
userId: USER_B.id,
|
|
||||||
tenantId: USER_B.tenantId,
|
|
||||||
});
|
|
||||||
expect(socket.emit).toHaveBeenCalledWith(
|
|
||||||
'error',
|
|
||||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
|
|
||||||
const { gateway, agentService } = makeGateway();
|
|
||||||
const socket = makeSocket();
|
|
||||||
|
|
||||||
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
|
|
||||||
|
|
||||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
|
||||||
userId: USER_B.id,
|
|
||||||
tenantId: USER_B.tenantId,
|
|
||||||
});
|
|
||||||
expect(socket.emit).toHaveBeenCalledWith(
|
|
||||||
'error',
|
|
||||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
IProviderAdapter,
|
IProviderAdapter,
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
ProviderHealth,
|
ProviderHealth,
|
||||||
} from '@mosaicstack/types';
|
} from '@mosaic/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anthropic provider adapter.
|
* Anthropic provider adapter.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
IProviderAdapter,
|
IProviderAdapter,
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
ProviderHealth,
|
ProviderHealth,
|
||||||
} from '@mosaicstack/types';
|
} from '@mosaic/types';
|
||||||
|
|
||||||
/** Embedding models that Ollama ships with out of the box */
|
/** Embedding models that Ollama ships with out of the box */
|
||||||
const OLLAMA_EMBEDDING_MODELS: ReadonlyArray<{
|
const OLLAMA_EMBEDDING_MODELS: ReadonlyArray<{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
IProviderAdapter,
|
IProviderAdapter,
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
ProviderHealth,
|
ProviderHealth,
|
||||||
} from '@mosaicstack/types';
|
} from '@mosaic/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenAI provider adapter.
|
* OpenAI provider adapter.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
IProviderAdapter,
|
IProviderAdapter,
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
ProviderHealth,
|
ProviderHealth,
|
||||||
} from '@mosaicstack/types';
|
} from '@mosaic/types';
|
||||||
|
|
||||||
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
IProviderAdapter,
|
IProviderAdapter,
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
ProviderHealth,
|
ProviderHealth,
|
||||||
} from '@mosaicstack/types';
|
} from '@mosaic/types';
|
||||||
import { getModelCapability } from '../model-capabilities.js';
|
import { getModelCapability } from '../model-capabilities.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Brain } from '@mosaicstack/brain';
|
import type { Brain } from '@mosaic/brain';
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
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';
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { AgentRuntimeProviderRegistry, HermesRuntimeProvider } from '@mosaicstack/agent';
|
|
||||||
import { AgentService } from './agent.service.js';
|
import { AgentService } from './agent.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
||||||
@@ -9,66 +8,24 @@ import { SkillLoaderService } from './skill-loader.service.js';
|
|||||||
import { ProvidersController } from './providers.controller.js';
|
import { ProvidersController } from './providers.controller.js';
|
||||||
import { SessionsController } from './sessions.controller.js';
|
import { SessionsController } from './sessions.controller.js';
|
||||||
import { AgentConfigsController } from './agent-configs.controller.js';
|
import { AgentConfigsController } from './agent-configs.controller.js';
|
||||||
import { InteractionController } from './interaction.controller.js';
|
|
||||||
import { RoutingController } from './routing/routing.controller.js';
|
import { RoutingController } from './routing/routing.controller.js';
|
||||||
import { DurableSessionRepository } from './durable-session.repository.js';
|
|
||||||
import { DurableSessionService } from './durable-session.service.js';
|
|
||||||
import { CoordModule } from '../coord/coord.module.js';
|
import { CoordModule } from '../coord/coord.module.js';
|
||||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
import { SkillsModule } from '../skills/skills.module.js';
|
import { SkillsModule } from '../skills/skills.module.js';
|
||||||
import { GCModule } from '../gc/gc.module.js';
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
import { LogModule } from '../log/log.module.js';
|
|
||||||
import { CommandsModule } from '../commands/commands.module.js';
|
|
||||||
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
|
|
||||||
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
|
|
||||||
import {
|
|
||||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
|
||||||
RUNTIME_APPROVAL_VERIFIER,
|
|
||||||
RUNTIME_PROVIDER_AUDIT_SINK,
|
|
||||||
RuntimeProviderAuditService,
|
|
||||||
RuntimeProviderService,
|
|
||||||
} from './runtime-provider-registry.service.js';
|
|
||||||
|
|
||||||
export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegistry {
|
|
||||||
const registry = new AgentRuntimeProviderRegistry();
|
|
||||||
registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport()));
|
|
||||||
return registry;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule],
|
imports: [CoordModule, McpClientModule, SkillsModule, GCModule],
|
||||||
providers: [
|
providers: [
|
||||||
ProviderService,
|
ProviderService,
|
||||||
ProviderCredentialsService,
|
ProviderCredentialsService,
|
||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
DurableSessionRepository,
|
|
||||||
DurableSessionService,
|
|
||||||
{
|
|
||||||
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
|
||||||
useFactory: createGatewayRuntimeProviderRegistry,
|
|
||||||
},
|
|
||||||
RuntimeProviderAuditService,
|
|
||||||
{
|
|
||||||
provide: RUNTIME_PROVIDER_AUDIT_SINK,
|
|
||||||
useExisting: RuntimeProviderAuditService,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: RUNTIME_APPROVAL_VERIFIER,
|
|
||||||
useExisting: CommandRuntimeApprovalVerifier,
|
|
||||||
},
|
|
||||||
RuntimeProviderService,
|
|
||||||
AgentService,
|
AgentService,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
|
||||||
ProvidersController,
|
|
||||||
SessionsController,
|
|
||||||
AgentConfigsController,
|
|
||||||
InteractionController,
|
|
||||||
RoutingController,
|
|
||||||
],
|
|
||||||
exports: [
|
exports: [
|
||||||
AgentService,
|
AgentService,
|
||||||
ProviderService,
|
ProviderService,
|
||||||
@@ -76,9 +33,6 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
DurableSessionService,
|
|
||||||
RuntimeProviderService,
|
|
||||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AgentModule {}
|
export class AgentModule {}
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common';
|
||||||
ForbiddenException,
|
|
||||||
Inject,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
Optional,
|
|
||||||
type OnModuleDestroy,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
DefaultResourceLoader,
|
DefaultResourceLoader,
|
||||||
@@ -14,11 +7,10 @@ import {
|
|||||||
type AgentSessionEvent,
|
type AgentSessionEvent,
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
} from '@mariozechner/pi-coding-agent';
|
} from '@mariozechner/pi-coding-agent';
|
||||||
import type { Brain } from '@mosaicstack/brain';
|
import type { Brain } from '@mosaic/brain';
|
||||||
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
|
import type { Memory } from '@mosaic/memory';
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { MEMORY } from '../memory/memory.tokens.js';
|
import { MEMORY } from '../memory/memory.tokens.js';
|
||||||
import { OPERATOR_MEMORY_PLUGIN } from '../memory/memory.module.js';
|
|
||||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
import { EmbeddingService } from '../memory/embedding.service.js';
|
||||||
import { CoordService } from '../coord/coord.service.js';
|
import { CoordService } from '../coord/coord.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
@@ -36,7 +28,6 @@ import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
|
|||||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||||
import { PreferencesService } from '../preferences/preferences.service.js';
|
import { PreferencesService } from '../preferences/preferences.service.js';
|
||||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
|
||||||
|
|
||||||
/** A single message from DB conversation history, used for context injection. */
|
/** A single message from DB conversation history, used for context injection. */
|
||||||
export interface ConversationHistoryMessage {
|
export interface ConversationHistoryMessage {
|
||||||
@@ -77,8 +68,6 @@ export interface AgentSessionOptions {
|
|||||||
agentConfigId?: string;
|
agentConfigId?: string;
|
||||||
/** ID of the user who owns this session. Used for preferences and system override lookups. */
|
/** ID of the user who owns this session. Used for preferences and system override lookups. */
|
||||||
userId?: string;
|
userId?: string;
|
||||||
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
|
|
||||||
tenantId?: string;
|
|
||||||
/**
|
/**
|
||||||
* Prior conversation messages to inject as context when resuming a session.
|
* Prior conversation messages to inject as context when resuming a session.
|
||||||
* These messages are formatted and prepended to the system prompt so the
|
* These messages are formatted and prepended to the system prompt so the
|
||||||
@@ -105,8 +94,6 @@ export interface AgentSession {
|
|||||||
allowedTools: string[] | null;
|
allowedTools: string[] | null;
|
||||||
/** User ID that owns this session, used for preference lookups. */
|
/** User ID that owns this session, used for preference lookups. */
|
||||||
userId?: string;
|
userId?: string;
|
||||||
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
|
|
||||||
tenantId?: string;
|
|
||||||
/** Agent config ID applied to this session, if any (M5-001). */
|
/** Agent config ID applied to this session, if any (M5-001). */
|
||||||
agentConfigId?: string;
|
agentConfigId?: string;
|
||||||
/** Human-readable agent name applied to this session, if any (M5-001). */
|
/** Human-readable agent name applied to this session, if any (M5-001). */
|
||||||
@@ -136,9 +123,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
@Inject(PreferencesService)
|
@Inject(PreferencesService)
|
||||||
private readonly preferencesService: PreferencesService | null,
|
private readonly preferencesService: PreferencesService | null,
|
||||||
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
||||||
@Optional()
|
|
||||||
@Inject(OPERATOR_MEMORY_PLUGIN)
|
|
||||||
private readonly operatorMemory: OperatorMemoryPlugin | null = null,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,7 +134,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
private buildToolsForSandbox(
|
private buildToolsForSandbox(
|
||||||
sandboxDir: string,
|
sandboxDir: string,
|
||||||
sessionUserId: string | undefined,
|
sessionUserId: string | undefined,
|
||||||
sessionScope?: { tenantId: string; ownerId: string; sessionId: string },
|
|
||||||
): ToolDefinition[] {
|
): ToolDefinition[] {
|
||||||
return [
|
return [
|
||||||
...createBrainTools(this.brain),
|
...createBrainTools(this.brain),
|
||||||
@@ -159,9 +142,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
this.memory,
|
this.memory,
|
||||||
this.embeddingService.available ? this.embeddingService : null,
|
this.embeddingService.available ? this.embeddingService : null,
|
||||||
sessionUserId,
|
sessionUserId,
|
||||||
this.operatorMemory && sessionScope
|
|
||||||
? { plugin: this.operatorMemory, scope: sessionScope }
|
|
||||||
: undefined,
|
|
||||||
),
|
),
|
||||||
...createFileTools(sandboxDir),
|
...createFileTools(sandboxDir),
|
||||||
...createGitTools(sandboxDir),
|
...createGitTools(sandboxDir),
|
||||||
@@ -194,20 +174,12 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
.filter((t) => t.length > 0);
|
.filter((t) => t.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(sessionId: string, options: AgentSessionOptions): Promise<AgentSession> {
|
async createSession(sessionId: string, options?: AgentSessionOptions): Promise<AgentSession> {
|
||||||
const scope = this.scopeFromOptions(options);
|
|
||||||
const existing = this.sessions.get(sessionId);
|
const existing = this.sessions.get(sessionId);
|
||||||
if (existing) {
|
if (existing) return existing;
|
||||||
this.assertSessionScope(existing, scope);
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inflight = this.creating.get(sessionId);
|
const inflight = this.creating.get(sessionId);
|
||||||
if (inflight) {
|
if (inflight) return inflight;
|
||||||
const session = await inflight;
|
|
||||||
this.assertSessionScope(session, scope);
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
const promise = this.doCreateSession(sessionId, options).finally(() => {
|
const promise = this.doCreateSession(sessionId, options).finally(() => {
|
||||||
this.creating.delete(sessionId);
|
this.creating.delete(sessionId);
|
||||||
@@ -236,7 +208,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
isAdmin: options.isAdmin,
|
isAdmin: options.isAdmin,
|
||||||
agentConfigId: options.agentConfigId,
|
agentConfigId: options.agentConfigId,
|
||||||
userId: options.userId,
|
userId: options.userId,
|
||||||
tenantId: options.tenantId,
|
|
||||||
conversationHistory: options.conversationHistory,
|
conversationHistory: options.conversationHistory,
|
||||||
};
|
};
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -276,15 +247,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build per-session tools scoped to the sandbox directory and authenticated user
|
// Build per-session tools scoped to the sandbox directory and authenticated user
|
||||||
const sessionUserId = mergedOptions?.userId;
|
const sandboxTools = this.buildToolsForSandbox(sandboxDir, mergedOptions?.userId);
|
||||||
const sessionTenantId = this.tenantIdFor(sessionUserId, mergedOptions?.tenantId);
|
|
||||||
const sandboxTools = this.buildToolsForSandbox(
|
|
||||||
sandboxDir,
|
|
||||||
sessionUserId,
|
|
||||||
sessionUserId && sessionTenantId
|
|
||||||
? { tenantId: sessionTenantId, ownerId: sessionUserId, sessionId }
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Combine static tools with dynamically discovered MCP client tools and skill tools
|
// Combine static tools with dynamically discovered MCP client tools and skill tools
|
||||||
const mcpTools = this.mcpClientService.getToolDefinitions();
|
const mcpTools = this.mcpClientService.getToolDefinitions();
|
||||||
@@ -379,7 +342,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
sandboxDir,
|
sandboxDir,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
userId: mergedOptions?.userId,
|
userId: mergedOptions?.userId,
|
||||||
tenantId: sessionTenantId,
|
|
||||||
agentConfigId: mergedOptions?.agentConfigId,
|
agentConfigId: mergedOptions?.agentConfigId,
|
||||||
agentName: resolvedAgentName,
|
agentName: resolvedAgentName,
|
||||||
metrics: {
|
metrics: {
|
||||||
@@ -511,70 +473,38 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
return this.providerService.getDefaultModel() ?? null;
|
return this.providerService.getDefaultModel() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSession(sessionId: string, scope: ActorTenantScope): AgentSession | undefined {
|
getSession(sessionId: string): AgentSession | undefined {
|
||||||
const session = this.sessions.get(sessionId);
|
return this.sessions.get(sessionId);
|
||||||
if (!session || !this.sessionMatchesScope(session, scope)) return undefined;
|
|
||||||
return session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
listSessions(scope: ActorTenantScope): SessionInfoDto[] {
|
listSessions(): SessionInfoDto[] {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return Array.from(this.sessions.values())
|
return Array.from(this.sessions.values()).map((s) => ({
|
||||||
.filter((s) => this.sessionMatchesScope(s, scope))
|
id: s.id,
|
||||||
.map((s) => this.toSessionInfo(s, now));
|
provider: s.provider,
|
||||||
|
modelId: s.modelId,
|
||||||
|
...(s.agentName ? { agentName: s.agentName } : {}),
|
||||||
|
createdAt: new Date(s.createdAt).toISOString(),
|
||||||
|
promptCount: s.promptCount,
|
||||||
|
channels: Array.from(s.channels),
|
||||||
|
durationMs: now - s.createdAt,
|
||||||
|
metrics: { ...s.metrics },
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
listAllSessionsForSystem(): SessionInfoDto[] {
|
getSessionInfo(sessionId: string): SessionInfoDto | undefined {
|
||||||
const now = Date.now();
|
|
||||||
return Array.from(this.sessions.values()).map((s) => this.toSessionInfo(s, now));
|
|
||||||
}
|
|
||||||
|
|
||||||
getSessionInfo(sessionId: string, scope: ActorTenantScope): SessionInfoDto | undefined {
|
|
||||||
const s = this.sessions.get(sessionId);
|
const s = this.sessions.get(sessionId);
|
||||||
if (!s || !this.sessionMatchesScope(s, scope)) return undefined;
|
if (!s) return undefined;
|
||||||
return this.toSessionInfo(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
private scopeFromOptions(options: AgentSessionOptions): ActorTenantScope {
|
|
||||||
if (!options.userId) {
|
|
||||||
throw new ForbiddenException('Session owner scope is required');
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
userId: options.userId,
|
id: s.id,
|
||||||
tenantId: this.tenantIdFor(options.userId, options.tenantId) ?? options.userId,
|
provider: s.provider,
|
||||||
};
|
modelId: s.modelId,
|
||||||
}
|
...(s.agentName ? { agentName: s.agentName } : {}),
|
||||||
|
createdAt: new Date(s.createdAt).toISOString(),
|
||||||
private tenantIdFor(
|
promptCount: s.promptCount,
|
||||||
userId: string | undefined,
|
channels: Array.from(s.channels),
|
||||||
tenantId: string | undefined,
|
durationMs: Date.now() - s.createdAt,
|
||||||
): string | undefined {
|
metrics: { ...s.metrics },
|
||||||
return tenantId ?? userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sessionMatchesScope(session: AgentSession, scope: ActorTenantScope): boolean {
|
|
||||||
return (
|
|
||||||
session.userId === scope.userId && (session.tenantId ?? session.userId) === scope.tenantId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertSessionScope(session: AgentSession, scope: ActorTenantScope): void {
|
|
||||||
if (!this.sessionMatchesScope(session, scope)) {
|
|
||||||
throw new ForbiddenException('Session does not belong to the current owner/tenant scope');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private toSessionInfo(session: AgentSession, now = Date.now()): SessionInfoDto {
|
|
||||||
return {
|
|
||||||
id: session.id,
|
|
||||||
provider: session.provider,
|
|
||||||
modelId: session.modelId,
|
|
||||||
...(session.agentName ? { agentName: session.agentName } : {}),
|
|
||||||
createdAt: new Date(session.createdAt).toISOString(),
|
|
||||||
promptCount: session.promptCount,
|
|
||||||
channels: Array.from(session.channels),
|
|
||||||
durationMs: now - session.createdAt,
|
|
||||||
metrics: { ...session.metrics },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,10 +553,9 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
* not reconstructed — the model is used on the next createSession call for
|
* not reconstructed — the model is used on the next createSession call for
|
||||||
* the same conversationId when the session is torn down or a new one is created.
|
* the same conversationId when the session is torn down or a new one is created.
|
||||||
*/
|
*/
|
||||||
updateSessionModel(sessionId: string, modelId: string, scope: ActorTenantScope): void {
|
updateSessionModel(sessionId: string, modelId: string): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
this.assertSessionScope(session, scope);
|
|
||||||
const prev = session.modelId;
|
const prev = session.modelId;
|
||||||
session.modelId = modelId;
|
session.modelId = modelId;
|
||||||
this.recordModelSwitch(sessionId);
|
this.recordModelSwitch(sessionId);
|
||||||
@@ -643,51 +572,48 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
agentConfigId: string,
|
agentConfigId: string,
|
||||||
agentName: string,
|
agentName: string,
|
||||||
scope: ActorTenantScope,
|
|
||||||
modelId?: string,
|
modelId?: string,
|
||||||
): void {
|
): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
this.assertSessionScope(session, scope);
|
|
||||||
session.agentConfigId = agentConfigId;
|
session.agentConfigId = agentConfigId;
|
||||||
session.agentName = agentName;
|
session.agentName = agentName;
|
||||||
if (modelId) {
|
if (modelId) {
|
||||||
this.updateSessionModel(sessionId, modelId, scope);
|
this.updateSessionModel(sessionId, modelId);
|
||||||
}
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
|
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
addChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
|
addChannel(sessionId: string, channel: string): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (session) {
|
||||||
this.assertSessionScope(session, scope);
|
|
||||||
session.channels.add(channel);
|
session.channels.add(channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
|
|
||||||
const session = this.sessions.get(sessionId);
|
|
||||||
if (!session) return;
|
|
||||||
this.assertSessionScope(session, scope);
|
|
||||||
session.channels.delete(channel);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void> {
|
removeChannel(sessionId: string, channel: string): void {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
session.channels.delete(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async prompt(sessionId: string, message: string): Promise<void> {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(`No agent session found: ${sessionId}`);
|
throw new Error(`No agent session found: ${sessionId}`);
|
||||||
}
|
}
|
||||||
this.assertSessionScope(session, scope);
|
|
||||||
session.promptCount += 1;
|
session.promptCount += 1;
|
||||||
|
|
||||||
// Prepend session-scoped system override if present (renew TTL on each turn)
|
// Prepend session-scoped system override if present (renew TTL on each turn)
|
||||||
let effectiveMessage = message;
|
let effectiveMessage = message;
|
||||||
if (this.systemOverride) {
|
if (this.systemOverride) {
|
||||||
const override = await this.systemOverride.get(sessionId, scope);
|
const override = await this.systemOverride.get(sessionId);
|
||||||
if (override) {
|
if (override) {
|
||||||
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
|
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
|
||||||
await this.systemOverride.renew(sessionId, scope);
|
await this.systemOverride.renew(sessionId);
|
||||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -703,28 +629,16 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onEvent(
|
onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void {
|
||||||
sessionId: string,
|
|
||||||
listener: (event: AgentSessionEvent) => void,
|
|
||||||
scope: ActorTenantScope,
|
|
||||||
): () => void {
|
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(`No agent session found: ${sessionId}`);
|
throw new Error(`No agent session found: ${sessionId}`);
|
||||||
}
|
}
|
||||||
this.assertSessionScope(session, scope);
|
|
||||||
session.listeners.add(listener);
|
session.listeners.add(listener);
|
||||||
return () => session.listeners.delete(listener);
|
return () => session.listeners.delete(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
async destroySession(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async destroySession(sessionId: string): Promise<void> {
|
||||||
const session = this.sessions.get(sessionId);
|
|
||||||
if (!session) return;
|
|
||||||
this.assertSessionScope(session, scope);
|
|
||||||
await this.destroySessionForSystem(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async destroySessionForSystem(sessionId: string): Promise<void> {
|
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
this.logger.log(`Destroying agent session ${sessionId}`);
|
this.logger.log(`Destroying agent session ${sessionId}`);
|
||||||
@@ -753,7 +667,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
this.logger.log('Shutting down all agent sessions');
|
this.logger.log('Shutting down all agent sessions');
|
||||||
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySessionForSystem(id));
|
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id));
|
||||||
const results = await Promise.allSettled(stops);
|
const results = await Promise.allSettled(stops);
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.status === 'rejected') {
|
if (result.status === 'rejected') {
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
|
|
||||||
|
|
||||||
/** Server-side request for a replay-safe provider message. */
|
|
||||||
export interface ProviderOutboxDto {
|
|
||||||
sessionId: string;
|
|
||||||
idempotencyKey: string;
|
|
||||||
correlationId: string;
|
|
||||||
content: string;
|
|
||||||
context: RuntimeProviderRequestContext;
|
|
||||||
}
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/db';
|
|
||||||
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
|
|
||||||
import { DurableSessionRepository } from './durable-session.repository.js';
|
|
||||||
import { DurableSessionService } from './durable-session.service.js';
|
|
||||||
|
|
||||||
const IDENTITY: DurableSessionIdentity = {
|
|
||||||
agentName: 'Nova',
|
|
||||||
sessionId: 'tess-pglite-session',
|
|
||||||
tenantId: 'tenant-pglite',
|
|
||||||
ownerId: 'tess-owner',
|
|
||||||
providerId: 'fleet',
|
|
||||||
runtimeSessionId: 'nova',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('DurableSessionRepository', () => {
|
|
||||||
let dataDir: string | undefined;
|
|
||||||
let handle: DbHandle;
|
|
||||||
let previousAuthSecret: string | undefined;
|
|
||||||
|
|
||||||
beforeAll(async (): Promise<void> => {
|
|
||||||
previousAuthSecret = process.env['BETTER_AUTH_SECRET'];
|
|
||||||
process.env['BETTER_AUTH_SECRET'] = 'tess-durable-state-test-sealing-key';
|
|
||||||
dataDir = mkdtempSync(join(tmpdir(), 'tess-durable-state-'));
|
|
||||||
handle = createPgliteDb(dataDir);
|
|
||||||
await runPgliteMigrations(handle);
|
|
||||||
await seedOwner(handle);
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
beforeEach(async (): Promise<void> => {
|
|
||||||
await handle.db.execute(sql`DELETE FROM interaction_handoffs`);
|
|
||||||
await handle.db.execute(sql`DELETE FROM interaction_checkpoints`);
|
|
||||||
await handle.db.execute(sql`DELETE FROM interaction_inbox`);
|
|
||||||
await handle.db.execute(sql`DELETE FROM interaction_outbox`);
|
|
||||||
await handle.db.execute(sql`DELETE FROM interaction_sessions`);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async (): Promise<void> => {
|
|
||||||
await handle.close();
|
|
||||||
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
|
|
||||||
if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET'];
|
|
||||||
else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
|
|
||||||
const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
|
||||||
await beforeRestart.create(IDENTITY);
|
|
||||||
await beforeRestart.receive({
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
idempotencyKey: 'inbox-before-kill',
|
|
||||||
correlationId: 'correlation-before-kill',
|
|
||||||
content: 'resume after a kill',
|
|
||||||
});
|
|
||||||
await beforeRestart.enqueueOutbox({
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
idempotencyKey: 'outbox-before-kill',
|
|
||||||
correlationId: 'correlation-before-kill',
|
|
||||||
channelId: 'cli',
|
|
||||||
kind: 'provider.send',
|
|
||||||
content: 'one response only',
|
|
||||||
});
|
|
||||||
await beforeRestart.checkpoint({
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
checkpointId: 'checkpoint-before-kill',
|
|
||||||
cursor: 'cursor-before-kill',
|
|
||||||
summary: 'restart-safe state',
|
|
||||||
compactionEpoch: 1,
|
|
||||||
});
|
|
||||||
await beforeRestart.handoff({
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
handoffId: 'handoff-before-kill',
|
|
||||||
destination: 'mos',
|
|
||||||
correlationId: 'correlation-before-kill',
|
|
||||||
checkpointId: 'checkpoint-before-kill',
|
|
||||||
status: 'pending',
|
|
||||||
});
|
|
||||||
await beforeRestart.checkpoint({
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
checkpointId: 'checkpoint-after-handoff',
|
|
||||||
cursor: 'cursor-after-handoff',
|
|
||||||
summary: 'newer state cannot strand the portable handoff',
|
|
||||||
compactionEpoch: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
await handle.close();
|
|
||||||
handle = createPgliteDb(dataDir!);
|
|
||||||
|
|
||||||
const afterRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
|
||||||
const recovered = await afterRestart.recover(IDENTITY.sessionId);
|
|
||||||
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
|
|
||||||
const handled: string[] = [];
|
|
||||||
const effects: string[] = [];
|
|
||||||
|
|
||||||
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
|
||||||
handled.push(entry.idempotencyKey);
|
|
||||||
});
|
|
||||||
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
|
||||||
effects.push(entry.idempotencyKey);
|
|
||||||
});
|
|
||||||
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
|
||||||
handled.push(entry.idempotencyKey);
|
|
||||||
});
|
|
||||||
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
|
||||||
effects.push(entry.idempotencyKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(recovered.identity).toEqual(IDENTITY);
|
|
||||||
expect(recovered.checkpoint).toMatchObject({ checkpointId: 'checkpoint-after-handoff' });
|
|
||||||
expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-before-kill' }]);
|
|
||||||
expect(resumedHandoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-before-kill' });
|
|
||||||
expect(handled).toEqual(['inbox-before-kill']);
|
|
||||||
expect(effects).toEqual(['outbox-before-kill']);
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
it('redacts sensitive durable payloads before persistence', async () => {
|
|
||||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
|
||||||
await coordinator.create(IDENTITY);
|
|
||||||
await coordinator.receive({
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
idempotencyKey: 'redacted-inbox',
|
|
||||||
correlationId: 'correlation-redaction',
|
|
||||||
content: 'api_key=super-secret-canary',
|
|
||||||
});
|
|
||||||
await coordinator.enqueueOutbox({
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
idempotencyKey: 'redacted-outbox',
|
|
||||||
correlationId: 'correlation-redaction',
|
|
||||||
channelId: 'cli',
|
|
||||||
kind: 'provider.send',
|
|
||||||
content: 'email operator@example.test api_key=super-secret-canary',
|
|
||||||
});
|
|
||||||
await coordinator.checkpoint({
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
checkpointId: 'redacted-checkpoint',
|
|
||||||
cursor: 'bearer super-secret-canary',
|
|
||||||
summary: 'email operator@example.test',
|
|
||||||
compactionEpoch: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const snapshot = await coordinator.snapshot(IDENTITY.sessionId);
|
|
||||||
const [persisted] = await handle.db
|
|
||||||
.select({ content: interactionInbox.content })
|
|
||||||
.from(interactionInbox)
|
|
||||||
.where(eq(interactionInbox.idempotencyKey, 'redacted-inbox'));
|
|
||||||
|
|
||||||
expect(JSON.stringify(snapshot)).not.toContain('super-secret-canary');
|
|
||||||
expect(JSON.stringify(snapshot)).not.toContain('operator@example.test');
|
|
||||||
expect(persisted?.content).not.toContain('super-secret-canary');
|
|
||||||
expect(persisted?.content).not.toContain('[REDACTED]');
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
it('fails closed when the configured idempotency secret is unavailable', async () => {
|
|
||||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
|
||||||
await coordinator.create(IDENTITY);
|
|
||||||
const secret = process.env['BETTER_AUTH_SECRET'];
|
|
||||||
delete process.env['BETTER_AUTH_SECRET'];
|
|
||||||
try {
|
|
||||||
await expect(
|
|
||||||
coordinator.receive({
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
idempotencyKey: 'requires-idempotency-secret',
|
|
||||||
correlationId: 'correlation-secret',
|
|
||||||
content: 'sensitive payload',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/required for durable idempotency digests/);
|
|
||||||
} finally {
|
|
||||||
if (secret === undefined) delete process.env['BETTER_AUTH_SECRET'];
|
|
||||||
else process.env['BETTER_AUTH_SECRET'] = secret;
|
|
||||||
}
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
|
|
||||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
|
||||||
await coordinator.create(IDENTITY);
|
|
||||||
const input = {
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
checkpointId: 'checkpoint-secret-conflict',
|
|
||||||
cursor: 'api_key=secret-one',
|
|
||||||
summary: 'bearer secret-one',
|
|
||||||
compactionEpoch: 1,
|
|
||||||
};
|
|
||||||
await coordinator.checkpoint(input);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
coordinator.checkpoint({
|
|
||||||
...input,
|
|
||||||
cursor: 'api_key=secret-two',
|
|
||||||
summary: 'bearer secret-two',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/checkpoint identity conflict/);
|
|
||||||
|
|
||||||
const [persisted] = await handle.db
|
|
||||||
.select({
|
|
||||||
digest: interactionCheckpoints.contentDigest,
|
|
||||||
cursor: interactionCheckpoints.cursor,
|
|
||||||
})
|
|
||||||
.from(interactionCheckpoints)
|
|
||||||
.where(eq(interactionCheckpoints.checkpointId, input.checkpointId));
|
|
||||||
expect(persisted?.cursor).not.toContain('secret-one');
|
|
||||||
expect(persisted?.digest).not.toBe(
|
|
||||||
createHash('sha256')
|
|
||||||
.update(JSON.stringify([input.cursor, input.summary]))
|
|
||||||
.digest('hex'),
|
|
||||||
);
|
|
||||||
|
|
||||||
await coordinator.checkpoint({
|
|
||||||
...input,
|
|
||||||
checkpointId: 'checkpoint-delimiter-conflict',
|
|
||||||
cursor: 'a\u0000b',
|
|
||||||
summary: 'c',
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
coordinator.checkpoint({
|
|
||||||
...input,
|
|
||||||
checkpointId: 'checkpoint-delimiter-conflict',
|
|
||||||
cursor: 'a',
|
|
||||||
summary: 'b\u0000c',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/checkpoint identity conflict/);
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
|
|
||||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
|
||||||
await coordinator.create(IDENTITY);
|
|
||||||
const inbox = {
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
idempotencyKey: 'inbox-secret-conflict',
|
|
||||||
correlationId: 'correlation-inbox-secret',
|
|
||||||
content: 'api_key=secret-one',
|
|
||||||
};
|
|
||||||
const outbox = {
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
idempotencyKey: 'outbox-secret-conflict',
|
|
||||||
correlationId: 'correlation-outbox-secret',
|
|
||||||
channelId: 'cli',
|
|
||||||
kind: 'provider.send',
|
|
||||||
content: 'api_key=secret-one',
|
|
||||||
};
|
|
||||||
await coordinator.receive(inbox);
|
|
||||||
await coordinator.enqueueOutbox(outbox);
|
|
||||||
|
|
||||||
await expect(coordinator.receive({ ...inbox, content: 'api_key=secret-two' })).rejects.toThrow(
|
|
||||||
/idempotency conflict/,
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
coordinator.enqueueOutbox({ ...outbox, content: 'api_key=secret-two' }),
|
|
||||||
).rejects.toThrow(/idempotency conflict/);
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
|
|
||||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
|
||||||
await coordinator.create(IDENTITY);
|
|
||||||
await coordinator.receive({
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
idempotencyKey: 'inbox-conflict',
|
|
||||||
correlationId: 'correlation-inbox',
|
|
||||||
content: 'original inbox',
|
|
||||||
});
|
|
||||||
await coordinator.enqueueOutbox({
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
idempotencyKey: 'outbox-conflict',
|
|
||||||
correlationId: 'correlation-outbox',
|
|
||||||
channelId: 'cli',
|
|
||||||
kind: 'provider.send',
|
|
||||||
content: 'original outbox',
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
coordinator.receive({
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
idempotencyKey: 'inbox-conflict',
|
|
||||||
correlationId: 'forged-correlation',
|
|
||||||
content: 'original inbox',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/idempotency conflict/);
|
|
||||||
await expect(
|
|
||||||
coordinator.enqueueOutbox({
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
idempotencyKey: 'outbox-conflict',
|
|
||||||
correlationId: 'correlation-outbox',
|
|
||||||
channelId: 'forged-channel',
|
|
||||||
kind: 'provider.send',
|
|
||||||
content: 'original outbox',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/idempotency conflict/);
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
|
|
||||||
const repository = new DurableSessionRepository(handle.db);
|
|
||||||
const coordinator = new DurableSessionCoordinator(repository);
|
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
|
||||||
const service = new DurableSessionService(repository, runtimeProviders as never);
|
|
||||||
const input = {
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
idempotencyKey: 'live-effect',
|
|
||||||
correlationId: 'correlation-live',
|
|
||||||
content: 'must not duplicate',
|
|
||||||
context: {
|
|
||||||
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
|
|
||||||
channelId: 'cli',
|
|
||||||
correlationId: 'correlation-live',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await coordinator.create(IDENTITY);
|
|
||||||
await service.queueProviderSend(input);
|
|
||||||
expect(await repository.claimOutbox(IDENTITY.sessionId)).toMatchObject({
|
|
||||||
status: 'processing',
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.dispatchProviderOutbox(IDENTITY.sessionId, input);
|
|
||||||
await expect(
|
|
||||||
service.recoverProviderSession(IDENTITY.sessionId, {
|
|
||||||
...input,
|
|
||||||
context: {
|
|
||||||
...input.context,
|
|
||||||
actorScope: { userId: 'intruder', tenantId: 'tenant-pglite' },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/scope or correlation mismatch/);
|
|
||||||
|
|
||||||
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
|
|
||||||
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
|
|
||||||
outbox: [{ idempotencyKey: 'live-effect', status: 'processing' }],
|
|
||||||
});
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
|
|
||||||
const repository = new DurableSessionRepository(handle.db);
|
|
||||||
const coordinator = new DurableSessionCoordinator(repository);
|
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
|
||||||
const service = new DurableSessionService(repository, runtimeProviders as never);
|
|
||||||
const input = {
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
idempotencyKey: 'mismatch-effect',
|
|
||||||
correlationId: 'correlation-expected',
|
|
||||||
content: 'must remain pending',
|
|
||||||
context: {
|
|
||||||
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
|
|
||||||
channelId: 'cli',
|
|
||||||
correlationId: 'correlation-expected',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await coordinator.create(IDENTITY);
|
|
||||||
await service.queueProviderSend(input);
|
|
||||||
await expect(
|
|
||||||
service.dispatchProviderOutbox(IDENTITY.sessionId, {
|
|
||||||
...input,
|
|
||||||
correlationId: 'correlation-forged',
|
|
||||||
context: { ...input.context, correlationId: 'correlation-forged' },
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/scope or correlation mismatch/);
|
|
||||||
|
|
||||||
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
|
|
||||||
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
|
|
||||||
outbox: [{ idempotencyKey: 'mismatch-effect', status: 'pending' }],
|
|
||||||
});
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
|
|
||||||
const repository = new DurableSessionRepository(handle.db);
|
|
||||||
const coordinator = new DurableSessionCoordinator(repository);
|
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
|
||||||
const service = new DurableSessionService(repository, runtimeProviders as never);
|
|
||||||
const first = {
|
|
||||||
sessionId: IDENTITY.sessionId,
|
|
||||||
idempotencyKey: 'scoped-effect-one',
|
|
||||||
correlationId: 'correlation-one',
|
|
||||||
content: 'first result',
|
|
||||||
context: {
|
|
||||||
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
|
|
||||||
channelId: 'cli',
|
|
||||||
correlationId: 'correlation-one',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const second = {
|
|
||||||
...first,
|
|
||||||
idempotencyKey: 'scoped-effect-two',
|
|
||||||
correlationId: 'correlation-two',
|
|
||||||
content: 'second result',
|
|
||||||
context: { ...first.context, correlationId: 'correlation-two' },
|
|
||||||
};
|
|
||||||
|
|
||||||
await coordinator.create(IDENTITY);
|
|
||||||
await service.queueProviderSend(first);
|
|
||||||
await service.queueProviderSend(second);
|
|
||||||
await service.dispatchProviderOutbox(IDENTITY.sessionId, first);
|
|
||||||
|
|
||||||
expect(runtimeProviders.sendMessage).toHaveBeenCalledTimes(1);
|
|
||||||
expect(runtimeProviders.sendMessage).toHaveBeenCalledWith(
|
|
||||||
IDENTITY.providerId,
|
|
||||||
IDENTITY.runtimeSessionId,
|
|
||||||
{ content: 'first result', idempotencyKey: 'scoped-effect-one' },
|
|
||||||
first.context,
|
|
||||||
);
|
|
||||||
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
|
|
||||||
outbox: [
|
|
||||||
{ idempotencyKey: 'scoped-effect-one', status: 'delivered' },
|
|
||||||
{ idempotencyKey: 'scoped-effect-two', status: 'pending' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}, 30_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function seedOwner(handle: DbHandle): Promise<void> {
|
|
||||||
await handle.db.execute(sql`
|
|
||||||
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
|
||||||
VALUES ('tess-owner', 'Tess Owner', 'tess-owner@example.test', false, now(), now())
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
@@ -1,529 +0,0 @@
|
|||||||
import { createHash, createHmac } from 'node:crypto';
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
and,
|
|
||||||
asc,
|
|
||||||
desc,
|
|
||||||
eq,
|
|
||||||
interactionCheckpoints,
|
|
||||||
interactionHandoffs,
|
|
||||||
interactionInbox,
|
|
||||||
interactionOutbox,
|
|
||||||
interactionSessions,
|
|
||||||
type Db,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import { seal, unseal } from '@mosaicstack/auth';
|
|
||||||
import { redactSensitiveContent } from '@mosaicstack/log';
|
|
||||||
import type {
|
|
||||||
DurableCheckpoint,
|
|
||||||
DurableCheckpointInput,
|
|
||||||
DurableEnqueueResult,
|
|
||||||
DurableHandoff,
|
|
||||||
DurableHandoffInput,
|
|
||||||
DurableInboxEntry,
|
|
||||||
DurableInboxInput,
|
|
||||||
DurableInboxStatus,
|
|
||||||
DurableOutboxEntry,
|
|
||||||
DurableOutboxInput,
|
|
||||||
DurableOutboxStatus,
|
|
||||||
DurableSessionIdentity,
|
|
||||||
DurableSessionSnapshot,
|
|
||||||
DurableSessionStore,
|
|
||||||
} from '@mosaicstack/agent';
|
|
||||||
import { DB } from '../database/database.module.js';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class DurableSessionRepository implements DurableSessionStore {
|
|
||||||
constructor(@Inject(DB) private readonly db: Db) {}
|
|
||||||
|
|
||||||
async create(identity: DurableSessionIdentity): Promise<void> {
|
|
||||||
await this.db
|
|
||||||
.insert(interactionSessions)
|
|
||||||
.values({
|
|
||||||
id: identity.sessionId,
|
|
||||||
agentName: identity.agentName,
|
|
||||||
tenantId: identity.tenantId,
|
|
||||||
ownerId: identity.ownerId,
|
|
||||||
providerId: identity.providerId,
|
|
||||||
runtimeSessionId: identity.runtimeSessionId,
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
|
|
||||||
const existing = await this.session(identity.sessionId);
|
|
||||||
if (!existing || !sameEnrollmentScope(existing, identity)) {
|
|
||||||
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
|
|
||||||
}
|
|
||||||
// A recovered/re-enrolled runtime can receive a new provider session ID;
|
|
||||||
// the conversation handle and owner scope remain immutable.
|
|
||||||
if (
|
|
||||||
existing.providerId !== identity.providerId ||
|
|
||||||
existing.runtimeSessionId !== identity.runtimeSessionId
|
|
||||||
) {
|
|
||||||
await this.db
|
|
||||||
.update(interactionSessions)
|
|
||||||
.set({ providerId: identity.providerId, runtimeSessionId: identity.runtimeSessionId })
|
|
||||||
.where(eq(interactionSessions.id, identity.sessionId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async snapshot(sessionId: string): Promise<DurableSessionSnapshot | null> {
|
|
||||||
const identity = await this.session(sessionId);
|
|
||||||
if (!identity) return null;
|
|
||||||
|
|
||||||
const [inbox, outbox, checkpoints, handoffs] = await Promise.all([
|
|
||||||
this.db
|
|
||||||
.select()
|
|
||||||
.from(interactionInbox)
|
|
||||||
.where(eq(interactionInbox.sessionId, sessionId))
|
|
||||||
.orderBy(asc(interactionInbox.createdAt)),
|
|
||||||
this.db
|
|
||||||
.select()
|
|
||||||
.from(interactionOutbox)
|
|
||||||
.where(eq(interactionOutbox.sessionId, sessionId))
|
|
||||||
.orderBy(asc(interactionOutbox.createdAt)),
|
|
||||||
this.db
|
|
||||||
.select()
|
|
||||||
.from(interactionCheckpoints)
|
|
||||||
.where(eq(interactionCheckpoints.sessionId, sessionId))
|
|
||||||
.orderBy(
|
|
||||||
desc(interactionCheckpoints.compactionEpoch),
|
|
||||||
desc(interactionCheckpoints.createdAt),
|
|
||||||
)
|
|
||||||
.limit(1),
|
|
||||||
this.db
|
|
||||||
.select()
|
|
||||||
.from(interactionHandoffs)
|
|
||||||
.where(eq(interactionHandoffs.sessionId, sessionId))
|
|
||||||
.orderBy(asc(interactionHandoffs.createdAt)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const checkpoint = checkpoints[0];
|
|
||||||
return {
|
|
||||||
identity,
|
|
||||||
inbox: inbox.map(toInbox),
|
|
||||||
outbox: outbox.map(toOutbox),
|
|
||||||
...(checkpoint ? { checkpoint: toCheckpoint(checkpoint) } : {}),
|
|
||||||
handoffs: handoffs.map(toHandoff),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
|
|
||||||
const digest = contentDigest(input.content);
|
|
||||||
const record: DurableInboxInput = {
|
|
||||||
...input,
|
|
||||||
content: redactSensitiveContent(input.content).content,
|
|
||||||
};
|
|
||||||
const inserted = await this.db
|
|
||||||
.insert(interactionInbox)
|
|
||||||
.values({
|
|
||||||
...record,
|
|
||||||
content: seal(record.content),
|
|
||||||
contentDigest: digest,
|
|
||||||
status: 'pending',
|
|
||||||
})
|
|
||||||
.onConflictDoNothing()
|
|
||||||
.returning({ status: interactionInbox.status });
|
|
||||||
if (inserted[0]) return { accepted: true, status: inserted[0].status };
|
|
||||||
|
|
||||||
const existing = await this.db
|
|
||||||
.select()
|
|
||||||
.from(interactionInbox)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(interactionInbox.sessionId, input.sessionId),
|
|
||||||
eq(interactionInbox.idempotencyKey, input.idempotencyKey),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (!existing[0]) throw new Error(`Durable inbox enqueue failed: ${input.idempotencyKey}`);
|
|
||||||
const entry = toInbox(existing[0]);
|
|
||||||
if (
|
|
||||||
!sameInbox(entry, record) ||
|
|
||||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
|
||||||
) {
|
|
||||||
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
|
|
||||||
}
|
|
||||||
return { accepted: false, status: entry.status };
|
|
||||||
}
|
|
||||||
|
|
||||||
async claimInbox(sessionId: string): Promise<DurableInboxEntry | null> {
|
|
||||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
||||||
const candidate = await this.db
|
|
||||||
.select()
|
|
||||||
.from(interactionInbox)
|
|
||||||
.where(
|
|
||||||
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'pending')),
|
|
||||||
)
|
|
||||||
.orderBy(asc(interactionInbox.createdAt))
|
|
||||||
.limit(1);
|
|
||||||
const entry = candidate[0];
|
|
||||||
if (!entry) return null;
|
|
||||||
const claimed = await this.db
|
|
||||||
.update(interactionInbox)
|
|
||||||
.set({ status: 'processing', updatedAt: new Date() })
|
|
||||||
.where(and(eq(interactionInbox.id, entry.id), eq(interactionInbox.status, 'pending')))
|
|
||||||
.returning();
|
|
||||||
if (claimed[0]) return toInbox(claimed[0]);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async completeInbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
|
||||||
await this.db
|
|
||||||
.update(interactionInbox)
|
|
||||||
.set({ status: 'processed', updatedAt: new Date() })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(interactionInbox.sessionId, sessionId),
|
|
||||||
eq(interactionInbox.idempotencyKey, idempotencyKey),
|
|
||||||
eq(interactionInbox.status, 'processing'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async releaseInbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
|
||||||
await this.db
|
|
||||||
.update(interactionInbox)
|
|
||||||
.set({ status: 'pending', updatedAt: new Date() })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(interactionInbox.sessionId, sessionId),
|
|
||||||
eq(interactionInbox.idempotencyKey, idempotencyKey),
|
|
||||||
eq(interactionInbox.status, 'processing'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async enqueueOutbox(
|
|
||||||
input: DurableOutboxInput,
|
|
||||||
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
|
|
||||||
const digest = contentDigest(input.content);
|
|
||||||
const record: DurableOutboxInput = {
|
|
||||||
...input,
|
|
||||||
content: redactSensitiveContent(input.content).content,
|
|
||||||
};
|
|
||||||
const inserted = await this.db
|
|
||||||
.insert(interactionOutbox)
|
|
||||||
.values({
|
|
||||||
...record,
|
|
||||||
content: seal(record.content),
|
|
||||||
contentDigest: digest,
|
|
||||||
status: 'pending',
|
|
||||||
})
|
|
||||||
.onConflictDoNothing()
|
|
||||||
.returning({ status: interactionOutbox.status });
|
|
||||||
if (inserted[0]) return { accepted: true, status: inserted[0].status };
|
|
||||||
|
|
||||||
const existing = await this.db
|
|
||||||
.select()
|
|
||||||
.from(interactionOutbox)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(interactionOutbox.sessionId, input.sessionId),
|
|
||||||
eq(interactionOutbox.idempotencyKey, input.idempotencyKey),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`);
|
|
||||||
const entry = toOutbox(existing[0]);
|
|
||||||
if (
|
|
||||||
!sameOutbox(entry, record) ||
|
|
||||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
|
||||||
) {
|
|
||||||
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
|
|
||||||
}
|
|
||||||
return { accepted: false, status: entry.status };
|
|
||||||
}
|
|
||||||
|
|
||||||
async claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null> {
|
|
||||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
||||||
const candidate = await this.db
|
|
||||||
.select()
|
|
||||||
.from(interactionOutbox)
|
|
||||||
.where(
|
|
||||||
and(eq(interactionOutbox.sessionId, sessionId), eq(interactionOutbox.status, 'pending')),
|
|
||||||
)
|
|
||||||
.orderBy(asc(interactionOutbox.createdAt))
|
|
||||||
.limit(1);
|
|
||||||
const entry = candidate[0];
|
|
||||||
if (!entry) return null;
|
|
||||||
const claimed = await this.db
|
|
||||||
.update(interactionOutbox)
|
|
||||||
.set({ status: 'processing', updatedAt: new Date() })
|
|
||||||
.where(and(eq(interactionOutbox.id, entry.id), eq(interactionOutbox.status, 'pending')))
|
|
||||||
.returning();
|
|
||||||
if (claimed[0]) return toOutbox(claimed[0]);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async claimOutboxByKey(
|
|
||||||
sessionId: string,
|
|
||||||
idempotencyKey: string,
|
|
||||||
): Promise<DurableOutboxEntry | null> {
|
|
||||||
const claimed = await this.db
|
|
||||||
.update(interactionOutbox)
|
|
||||||
.set({ status: 'processing', updatedAt: new Date() })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(interactionOutbox.sessionId, sessionId),
|
|
||||||
eq(interactionOutbox.idempotencyKey, idempotencyKey),
|
|
||||||
eq(interactionOutbox.status, 'pending'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
return claimed[0] ? toOutbox(claimed[0]) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async completeOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
|
||||||
await this.db
|
|
||||||
.update(interactionOutbox)
|
|
||||||
.set({ status: 'delivered', updatedAt: new Date() })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(interactionOutbox.sessionId, sessionId),
|
|
||||||
eq(interactionOutbox.idempotencyKey, idempotencyKey),
|
|
||||||
eq(interactionOutbox.status, 'processing'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
|
||||||
await this.db
|
|
||||||
.update(interactionOutbox)
|
|
||||||
.set({ status: 'pending', updatedAt: new Date() })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(interactionOutbox.sessionId, sessionId),
|
|
||||||
eq(interactionOutbox.idempotencyKey, idempotencyKey),
|
|
||||||
eq(interactionOutbox.status, 'processing'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkpoint(input: DurableCheckpointInput): Promise<void> {
|
|
||||||
// Compute identity before redaction. The persisted digest is keyed so a database
|
|
||||||
// reader cannot use it as an offline oracle for sensitive cursor/summary values.
|
|
||||||
const digest = contentDigest(JSON.stringify([input.cursor, input.summary]));
|
|
||||||
const checkpoint: DurableCheckpointInput = {
|
|
||||||
...input,
|
|
||||||
cursor: redactSensitiveContent(input.cursor).content,
|
|
||||||
summary: redactSensitiveContent(input.summary).content,
|
|
||||||
};
|
|
||||||
const inserted = await this.db
|
|
||||||
.insert(interactionCheckpoints)
|
|
||||||
.values({
|
|
||||||
...checkpoint,
|
|
||||||
contentDigest: digest,
|
|
||||||
cursor: seal(checkpoint.cursor),
|
|
||||||
summary: seal(checkpoint.summary),
|
|
||||||
})
|
|
||||||
.onConflictDoNothing()
|
|
||||||
.returning({ checkpointId: interactionCheckpoints.checkpointId });
|
|
||||||
if (inserted[0]) return;
|
|
||||||
|
|
||||||
const existing = await this.db
|
|
||||||
.select()
|
|
||||||
.from(interactionCheckpoints)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(interactionCheckpoints.sessionId, input.sessionId),
|
|
||||||
eq(interactionCheckpoints.checkpointId, input.checkpointId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (
|
|
||||||
!existing[0] ||
|
|
||||||
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
|
|
||||||
!matchesCheckpointDigest(existing[0].contentDigest, digest)
|
|
||||||
) {
|
|
||||||
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null> {
|
|
||||||
const checkpoints = await this.db
|
|
||||||
.select()
|
|
||||||
.from(interactionCheckpoints)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(interactionCheckpoints.sessionId, sessionId),
|
|
||||||
eq(interactionCheckpoints.checkpointId, checkpointId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
const checkpoint = checkpoints[0];
|
|
||||||
return checkpoint ? toCheckpoint(checkpoint) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async handoff(input: DurableHandoffInput): Promise<void> {
|
|
||||||
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
|
|
||||||
if (!checkpoint) {
|
|
||||||
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
|
|
||||||
}
|
|
||||||
const inserted = await this.db
|
|
||||||
.insert(interactionHandoffs)
|
|
||||||
.values({ ...input })
|
|
||||||
.onConflictDoNothing()
|
|
||||||
.returning({ handoffId: interactionHandoffs.handoffId });
|
|
||||||
if (inserted[0]) return;
|
|
||||||
|
|
||||||
const existing = await this.findHandoff(input.handoffId);
|
|
||||||
if (!existing || !sameHandoff(existing, input)) {
|
|
||||||
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async findHandoff(handoffId: string): Promise<DurableHandoff | null> {
|
|
||||||
const handoffs = await this.db
|
|
||||||
.select()
|
|
||||||
.from(interactionHandoffs)
|
|
||||||
.where(eq(interactionHandoffs.handoffId, handoffId))
|
|
||||||
.limit(1);
|
|
||||||
const handoff = handoffs[0];
|
|
||||||
return handoff ? toHandoff(handoff) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async requeueInFlight(sessionId: string): Promise<void> {
|
|
||||||
// Inbox handlers are process-local work. A provider outbox claim may have
|
|
||||||
// reached an external target before a crash, so it is deliberately not
|
|
||||||
// replayed by generic recovery.
|
|
||||||
await this.db
|
|
||||||
.update(interactionInbox)
|
|
||||||
.set({ status: 'pending', updatedAt: new Date() })
|
|
||||||
.where(
|
|
||||||
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'processing')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async session(sessionId: string): Promise<DurableSessionIdentity | null> {
|
|
||||||
const sessions = await this.db
|
|
||||||
.select()
|
|
||||||
.from(interactionSessions)
|
|
||||||
.where(eq(interactionSessions.id, sessionId))
|
|
||||||
.limit(1);
|
|
||||||
const session = sessions[0];
|
|
||||||
return session
|
|
||||||
? {
|
|
||||||
agentName: session.agentName,
|
|
||||||
sessionId: session.id,
|
|
||||||
tenantId: session.tenantId,
|
|
||||||
ownerId: session.ownerId,
|
|
||||||
providerId: session.providerId,
|
|
||||||
runtimeSessionId: session.runtimeSessionId,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function contentDigest(content: string): string {
|
|
||||||
const secret = process.env['BETTER_AUTH_SECRET'];
|
|
||||||
if (!secret) {
|
|
||||||
throw new Error('BETTER_AUTH_SECRET is required for durable idempotency digests');
|
|
||||||
}
|
|
||||||
return `hmac:v1:${createHmac('sha256', secret).update(content).digest('hex')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesContentDigest(stored: string, content: string): boolean {
|
|
||||||
return (
|
|
||||||
stored === contentDigest(content) ||
|
|
||||||
stored === createHash('sha256').update(content).digest('hex')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesCheckpointDigest(stored: string, digest: string): boolean {
|
|
||||||
// Legacy rows predate any pre-redaction identity and cannot safely prove equality.
|
|
||||||
// Reject rather than let redaction collapse distinct sensitive checkpoint payloads.
|
|
||||||
return stored === digest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameEnrollmentScope(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean {
|
|
||||||
return (
|
|
||||||
left.agentName === right.agentName &&
|
|
||||||
left.sessionId === right.sessionId &&
|
|
||||||
left.tenantId === right.tenantId &&
|
|
||||||
left.ownerId === right.ownerId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameInbox(left: DurableInboxEntry, right: DurableInboxInput): boolean {
|
|
||||||
return (
|
|
||||||
left.sessionId === right.sessionId &&
|
|
||||||
left.idempotencyKey === right.idempotencyKey &&
|
|
||||||
left.correlationId === right.correlationId &&
|
|
||||||
left.content === right.content
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameOutbox(left: DurableOutboxEntry, right: DurableOutboxInput): boolean {
|
|
||||||
return (
|
|
||||||
left.sessionId === right.sessionId &&
|
|
||||||
left.idempotencyKey === right.idempotencyKey &&
|
|
||||||
left.correlationId === right.correlationId &&
|
|
||||||
left.channelId === right.channelId &&
|
|
||||||
left.kind === right.kind &&
|
|
||||||
left.content === right.content
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameCheckpoint(left: DurableCheckpoint, right: DurableCheckpointInput): boolean {
|
|
||||||
return (
|
|
||||||
left.sessionId === right.sessionId &&
|
|
||||||
left.checkpointId === right.checkpointId &&
|
|
||||||
left.compactionEpoch === right.compactionEpoch
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameHandoff(left: DurableHandoff, right: DurableHandoffInput): boolean {
|
|
||||||
return (
|
|
||||||
left.sessionId === right.sessionId &&
|
|
||||||
left.handoffId === right.handoffId &&
|
|
||||||
left.destination === right.destination &&
|
|
||||||
left.correlationId === right.correlationId &&
|
|
||||||
left.checkpointId === right.checkpointId &&
|
|
||||||
left.status === right.status
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toInbox(row: typeof interactionInbox.$inferSelect): DurableInboxEntry {
|
|
||||||
return {
|
|
||||||
sessionId: row.sessionId,
|
|
||||||
idempotencyKey: row.idempotencyKey,
|
|
||||||
correlationId: row.correlationId,
|
|
||||||
content: unseal(row.content),
|
|
||||||
status: row.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toOutbox(row: typeof interactionOutbox.$inferSelect): DurableOutboxEntry {
|
|
||||||
return {
|
|
||||||
sessionId: row.sessionId,
|
|
||||||
idempotencyKey: row.idempotencyKey,
|
|
||||||
correlationId: row.correlationId,
|
|
||||||
channelId: row.channelId,
|
|
||||||
kind: row.kind,
|
|
||||||
content: unseal(row.content),
|
|
||||||
status: row.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCheckpoint(row: typeof interactionCheckpoints.$inferSelect): DurableCheckpoint {
|
|
||||||
return {
|
|
||||||
sessionId: row.sessionId,
|
|
||||||
checkpointId: row.checkpointId,
|
|
||||||
cursor: unseal(row.cursor),
|
|
||||||
summary: unseal(row.summary),
|
|
||||||
compactionEpoch: row.compactionEpoch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toHandoff(row: typeof interactionHandoffs.$inferSelect): DurableHandoff {
|
|
||||||
return {
|
|
||||||
sessionId: row.sessionId,
|
|
||||||
handoffId: row.handoffId,
|
|
||||||
destination: row.destination,
|
|
||||||
correlationId: row.correlationId,
|
|
||||||
checkpointId: row.checkpointId,
|
|
||||||
status: row.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
|
||||||
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
|
||||||
import type { ProviderOutboxDto } from './durable-session.dto.js';
|
|
||||||
import { DurableSessionRepository } from './durable-session.repository.js';
|
|
||||||
import {
|
|
||||||
RuntimeProviderService,
|
|
||||||
type RuntimeProviderRequestContext,
|
|
||||||
} from './runtime-provider-registry.service.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scoped gateway boundary for the canonical durable session state machine. It deliberately
|
|
||||||
* uses composition: raw state methods cannot be injected into channel, CLI, or
|
|
||||||
* MCP adapters without a server-derived actor/tenant/correlation context.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class DurableSessionService {
|
|
||||||
private readonly coordinator: DurableSessionCoordinator;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(DurableSessionRepository) repository: DurableSessionRepository,
|
|
||||||
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
|
|
||||||
) {
|
|
||||||
this.coordinator = new DurableSessionCoordinator(repository);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Enroll a verified runtime session under the stable cross-surface conversation handle. */
|
|
||||||
async enroll(
|
|
||||||
identity: DurableSessionIdentity,
|
|
||||||
context: RuntimeProviderRequestContext,
|
|
||||||
): Promise<void> {
|
|
||||||
if (
|
|
||||||
identity.ownerId !== context.actorScope.userId ||
|
|
||||||
identity.tenantId !== context.actorScope.tenantId
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException('Durable session enrollment scope mismatch');
|
|
||||||
}
|
|
||||||
await this.coordinator.create(identity);
|
|
||||||
}
|
|
||||||
|
|
||||||
async queueProviderSend(input: ProviderOutboxDto): Promise<void> {
|
|
||||||
const snapshot = await this.coordinator.snapshot(input.sessionId);
|
|
||||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
|
||||||
await this.coordinator.enqueueOutbox({
|
|
||||||
sessionId: input.sessionId,
|
|
||||||
idempotencyKey: input.idempotencyKey,
|
|
||||||
correlationId: input.correlationId,
|
|
||||||
channelId: input.context.channelId,
|
|
||||||
kind: 'provider.send',
|
|
||||||
content: input.content,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async dispatchProviderOutbox(sessionId: string, input: ProviderOutboxDto): Promise<void> {
|
|
||||||
if (sessionId !== input.sessionId) {
|
|
||||||
throw new ForbiddenException('Durable outbox session mismatch');
|
|
||||||
}
|
|
||||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
|
||||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
|
||||||
const pendingEntry = snapshot.outbox.find(
|
|
||||||
(entry): boolean => entry.idempotencyKey === input.idempotencyKey,
|
|
||||||
);
|
|
||||||
if (!pendingEntry) return;
|
|
||||||
// Validate immutable routing before claiming. A caller with a mismatched
|
|
||||||
// correlation/channel must not strand a pending external side effect.
|
|
||||||
this.assertOutboxScope(pendingEntry, input);
|
|
||||||
await this.coordinator.dispatchOutboxEntry(
|
|
||||||
sessionId,
|
|
||||||
input.idempotencyKey,
|
|
||||||
async (entry): Promise<void> => {
|
|
||||||
this.assertOutboxScope(entry, input);
|
|
||||||
await this.runtimeProviders.sendMessage(
|
|
||||||
snapshot.identity.providerId,
|
|
||||||
snapshot.identity.runtimeSessionId,
|
|
||||||
{ content: entry.content, idempotencyKey: entry.idempotencyKey },
|
|
||||||
input.context,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read durable identity/state only after deriving and checking the server-side actor scope. */
|
|
||||||
async getSnapshot(sessionId: string, context: RuntimeProviderRequestContext) {
|
|
||||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
|
||||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, {
|
|
||||||
sessionId,
|
|
||||||
content: '',
|
|
||||||
idempotencyKey: 'read-only',
|
|
||||||
correlationId: context.correlationId,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
|
|
||||||
async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): Promise<void> {
|
|
||||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
|
||||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
|
||||||
await this.coordinator.recover(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertOutboxScope(
|
|
||||||
entry: { kind: string; correlationId: string; channelId: string },
|
|
||||||
input: ProviderOutboxDto,
|
|
||||||
): void {
|
|
||||||
if (
|
|
||||||
entry.kind !== 'provider.send' ||
|
|
||||||
entry.correlationId !== input.correlationId ||
|
|
||||||
entry.channelId !== input.context.channelId
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException('Durable outbox scope or correlation mismatch');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertScope(ownerId: string, tenantId: string, input: ProviderOutboxDto): void {
|
|
||||||
if (
|
|
||||||
input.context.actorScope.userId !== ownerId ||
|
|
||||||
input.context.actorScope.tenantId !== tenantId ||
|
|
||||||
input.context.correlationId !== input.correlationId
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException('Durable session scope or correlation mismatch');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { Global, Module } from '@nestjs/common';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import { HermesRuntimeProvider } from '@mosaicstack/agent';
|
|
||||||
import { AgentModule } from './agent.module.js';
|
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
|
||||||
import { DB } from '../database/database.module.js';
|
|
||||||
import { CoordModule } from '../coord/coord.module.js';
|
|
||||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
|
||||||
import { SkillsModule } from '../skills/skills.module.js';
|
|
||||||
import { GCModule } from '../gc/gc.module.js';
|
|
||||||
import { LogModule } from '../log/log.module.js';
|
|
||||||
import { CommandsModule } from '../commands/commands.module.js';
|
|
||||||
import {
|
|
||||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
|
||||||
RUNTIME_APPROVAL_VERIFIER,
|
|
||||||
RUNTIME_PROVIDER_AUDIT_SINK,
|
|
||||||
RuntimeProviderAuditService,
|
|
||||||
} from './runtime-provider-registry.service.js';
|
|
||||||
import { DurableSessionService } from './durable-session.service.js';
|
|
||||||
import { DurableSessionRepository } from './durable-session.repository.js';
|
|
||||||
import { AgentService } from './agent.service.js';
|
|
||||||
import { ProviderService } from './provider.service.js';
|
|
||||||
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
|
||||||
import { RoutingService } from './routing.service.js';
|
|
||||||
import { RoutingEngineService } from './routing/routing-engine.service.js';
|
|
||||||
import { SkillLoaderService } from './skill-loader.service.js';
|
|
||||||
|
|
||||||
const authenticatedUser = { id: 'operator-1', tenantId: 'tenant-1' };
|
|
||||||
|
|
||||||
@Module({})
|
|
||||||
class EmptyAgentDependencyModule {}
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: AUTH,
|
|
||||||
useValue: {
|
|
||||||
api: {
|
|
||||||
getSession: vi.fn(async ({ headers }: { headers: Headers }) =>
|
|
||||||
headers.get('cookie') === 'session=trusted'
|
|
||||||
? { user: authenticatedUser, session: { id: 'session-1' } }
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AuthGuard,
|
|
||||||
{ provide: BRAIN, useValue: {} },
|
|
||||||
{ provide: DB, useValue: {} },
|
|
||||||
],
|
|
||||||
exports: [AUTH, AuthGuard, BRAIN, DB],
|
|
||||||
})
|
|
||||||
class AuthenticatedRequestModule {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is deliberately an HTTP test rather than a controller unit test: it
|
|
||||||
* exercises AgentModule's actual provider factory, Nest DI, and AuthGuard.
|
|
||||||
*/
|
|
||||||
describe('Hermes runtime provider reachability', (): void => {
|
|
||||||
let app: NestFastifyApplication | undefined;
|
|
||||||
|
|
||||||
beforeAll(async (): Promise<void> => {
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
imports: [AuthenticatedRequestModule, AgentModule],
|
|
||||||
})
|
|
||||||
.overrideModule(CoordModule)
|
|
||||||
.useModule(EmptyAgentDependencyModule)
|
|
||||||
.overrideModule(McpClientModule)
|
|
||||||
.useModule(EmptyAgentDependencyModule)
|
|
||||||
.overrideModule(SkillsModule)
|
|
||||||
.useModule(EmptyAgentDependencyModule)
|
|
||||||
.overrideModule(GCModule)
|
|
||||||
.useModule(EmptyAgentDependencyModule)
|
|
||||||
.overrideModule(LogModule)
|
|
||||||
.useModule(EmptyAgentDependencyModule)
|
|
||||||
.overrideModule(CommandsModule)
|
|
||||||
.useModule(EmptyAgentDependencyModule)
|
|
||||||
.overrideProvider(RuntimeProviderAuditService)
|
|
||||||
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
|
|
||||||
.overrideProvider(RUNTIME_PROVIDER_AUDIT_SINK)
|
|
||||||
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
|
|
||||||
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
|
|
||||||
.useValue({ consume: vi.fn().mockResolvedValue(false) })
|
|
||||||
.overrideProvider(DurableSessionService)
|
|
||||||
.useValue({})
|
|
||||||
.overrideProvider(DurableSessionRepository)
|
|
||||||
.useValue({})
|
|
||||||
.overrideProvider(AgentService)
|
|
||||||
.useValue({})
|
|
||||||
.overrideProvider(ProviderService)
|
|
||||||
.useValue({})
|
|
||||||
.overrideProvider(ProviderCredentialsService)
|
|
||||||
.useValue({})
|
|
||||||
.overrideProvider(RoutingService)
|
|
||||||
.useValue({})
|
|
||||||
.overrideProvider(RoutingEngineService)
|
|
||||||
.useValue({})
|
|
||||||
.overrideProvider(SkillLoaderService)
|
|
||||||
.useValue({})
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
||||||
await app.init();
|
|
||||||
await app.getHttpAdapter().getInstance().ready();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async (): Promise<void> => {
|
|
||||||
await app?.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns gateway denial responses from the actual guarded interaction routes', async (): Promise<void> => {
|
|
||||||
if (!app) throw new Error('Nest application did not initialize');
|
|
||||||
|
|
||||||
const attachDenied = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/interaction/Nova/sessions/session-1/attach',
|
|
||||||
headers: { 'x-correlation-id': 'correlation-1' },
|
|
||||||
payload: { mode: 'read' },
|
|
||||||
});
|
|
||||||
expect(attachDenied.statusCode).toBe(401);
|
|
||||||
|
|
||||||
const sendDenied = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/interaction/Nova/sessions/session-1/send',
|
|
||||||
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
expect(sendDenied.statusCode).toBe(403);
|
|
||||||
expect(sendDenied.json()).toMatchObject({
|
|
||||||
message: 'Content and idempotency key are required',
|
|
||||||
});
|
|
||||||
|
|
||||||
const stopDenied = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/interaction/Nova/sessions/session-1/stop',
|
|
||||||
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
expect(stopDenied.statusCode).toBe(403);
|
|
||||||
expect(stopDenied.json()).toMatchObject({ message: 'Exact-action approval is required' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requires authentication and reaches the Hermes provider registered by AgentModule', async (): Promise<void> => {
|
|
||||||
if (!app) throw new Error('Nest application did not initialize');
|
|
||||||
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
|
|
||||||
expect(registry.get('runtime.hermes')).toBeInstanceOf(HermesRuntimeProvider);
|
|
||||||
|
|
||||||
const denied = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
|
|
||||||
headers: { 'x-correlation-id': 'correlation-1' },
|
|
||||||
});
|
|
||||||
expect(denied.statusCode).toBe(401);
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
|
|
||||||
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
expect(response.json()).toEqual([
|
|
||||||
{ capability: 'kanban', status: 'unsupported' },
|
|
||||||
{ capability: 'skills', status: 'unsupported' },
|
|
||||||
{ capability: 'memory', status: 'unsupported' },
|
|
||||||
{ capability: 'tools', status: 'unsupported' },
|
|
||||||
{ capability: 'cron', status: 'unsupported' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
|
|
||||||
|
|
||||||
const scope = {
|
|
||||||
actorId: 'owner-1',
|
|
||||||
tenantId: 'tenant-1',
|
|
||||||
channelId: 'cli',
|
|
||||||
correlationId: 'correlation-1',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('GatewayHermesRuntimeTransport', () => {
|
|
||||||
it('preserves a configured path prefix and authenticates the concrete runtime request', async () => {
|
|
||||||
const fetchFn = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(new Response(JSON.stringify(['session.list']), { status: 200 }));
|
|
||||||
const transport = new GatewayHermesRuntimeTransport(
|
|
||||||
'https://runtime.example.test/hermes',
|
|
||||||
'test-service-token',
|
|
||||||
fetchFn,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(transport.capabilities(scope)).resolves.toEqual(['session.list']);
|
|
||||||
|
|
||||||
expect(fetchFn).toHaveBeenCalledWith(
|
|
||||||
new URL('https://runtime.example.test/hermes/capabilities'),
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
authorization: 'Bearer test-service-token',
|
|
||||||
'x-mosaic-channel-id': 'cli',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects non-loopback HTTP runtime endpoints before sending identity headers', async () => {
|
|
||||||
const fetchFn = vi.fn();
|
|
||||||
const transport = new GatewayHermesRuntimeTransport(
|
|
||||||
'http://runtime.example.test/hermes',
|
|
||||||
'test-service-token',
|
|
||||||
fetchFn,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(transport.capabilities(scope)).rejects.toThrow('requires HTTPS');
|
|
||||||
expect(fetchFn).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import type { HermesLegacySession, HermesRuntimeTransport } from '@mosaicstack/agent';
|
|
||||||
import type {
|
|
||||||
RuntimeAttachHandle,
|
|
||||||
RuntimeAttachMode,
|
|
||||||
RuntimeMessage,
|
|
||||||
RuntimeScope,
|
|
||||||
RuntimeStreamEvent,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
|
|
||||||
/** Concrete HTTP transport for a configured legacy Hermes runtime endpoint. */
|
|
||||||
export class GatewayHermesRuntimeTransport implements HermesRuntimeTransport {
|
|
||||||
constructor(
|
|
||||||
private readonly baseUrl = process.env['MOSAIC_HERMES_RUNTIME_URL']?.trim(),
|
|
||||||
private readonly serviceToken = process.env['MOSAIC_HERMES_RUNTIME_TOKEN']?.trim(),
|
|
||||||
private readonly fetchFn: typeof fetch = fetch,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async capabilities(scope: RuntimeScope): Promise<string[]> {
|
|
||||||
return this.request<string[]>('/capabilities', scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
async health(scope: RuntimeScope): Promise<{ status: string; detail?: string }> {
|
|
||||||
return this.request<{ status: string; detail?: string }>('/health', scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
async sessions(scope: RuntimeScope): Promise<HermesLegacySession[]> {
|
|
||||||
return this.request<HermesLegacySession[]>('/sessions', scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
async *stream(
|
|
||||||
sessionId: string,
|
|
||||||
cursor: string | undefined,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
): AsyncIterable<RuntimeStreamEvent> {
|
|
||||||
const params = new URLSearchParams(cursor ? { cursor } : {});
|
|
||||||
const events = await this.request<RuntimeStreamEvent[]>(
|
|
||||||
`/sessions/${encodeURIComponent(sessionId)}/stream?${params.toString()}`,
|
|
||||||
scope,
|
|
||||||
);
|
|
||||||
yield* events;
|
|
||||||
}
|
|
||||||
|
|
||||||
async send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void> {
|
|
||||||
await this.request(`/sessions/${encodeURIComponent(sessionId)}/messages`, scope, {
|
|
||||||
method: 'POST',
|
|
||||||
body: message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async attach(
|
|
||||||
sessionId: string,
|
|
||||||
mode: RuntimeAttachMode,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
): Promise<RuntimeAttachHandle> {
|
|
||||||
return this.request<RuntimeAttachHandle>(
|
|
||||||
`/sessions/${encodeURIComponent(sessionId)}/attach`,
|
|
||||||
scope,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
body: { mode },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
|
|
||||||
await this.request(`/attachments/${encodeURIComponent(attachmentId)}`, scope, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
|
|
||||||
await this.request(`/sessions/${encodeURIComponent(sessionId)}/terminate`, scope, {
|
|
||||||
method: 'POST',
|
|
||||||
body: { approvalRef },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async request<T>(
|
|
||||||
path: string,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
init: { method?: string; body?: unknown } = {},
|
|
||||||
): Promise<T> {
|
|
||||||
if (!this.baseUrl || !this.serviceToken) {
|
|
||||||
throw new Error(
|
|
||||||
'MOSAIC_HERMES_RUNTIME_URL and MOSAIC_HERMES_RUNTIME_TOKEN must configure Hermes transport',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const endpoint = new URL(this.baseUrl);
|
|
||||||
if (endpoint.protocol !== 'https:' && !isLoopbackHttp(endpoint)) {
|
|
||||||
throw new Error('Hermes runtime transport requires HTTPS outside loopback');
|
|
||||||
}
|
|
||||||
const response = await this.fetchFn(
|
|
||||||
new URL(path.replace(/^\//, ''), `${endpoint.toString().replace(/\/$/, '')}/`),
|
|
||||||
{
|
|
||||||
method: init.method ?? 'GET',
|
|
||||||
headers: {
|
|
||||||
accept: 'application/json',
|
|
||||||
authorization: `Bearer ${this.serviceToken}`,
|
|
||||||
'x-mosaic-actor-id': scope.actorId,
|
|
||||||
'x-mosaic-tenant-id': scope.tenantId,
|
|
||||||
'x-mosaic-channel-id': scope.channelId,
|
|
||||||
'x-correlation-id': scope.correlationId,
|
|
||||||
...(init.body ? { 'content-type': 'application/json' } : {}),
|
|
||||||
},
|
|
||||||
...(init.body ? { body: JSON.stringify(init.body) } : {}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) throw new Error(`Hermes runtime request failed: ${response.status}`);
|
|
||||||
if (response.status === 204) return undefined as T;
|
|
||||||
return (await response.json()) as T;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLoopbackHttp(endpoint: URL): boolean {
|
|
||||||
return (
|
|
||||||
endpoint.protocol === 'http:' &&
|
|
||||||
(endpoint.hostname === 'localhost' ||
|
|
||||||
endpoint.hostname === '127.0.0.1' ||
|
|
||||||
endpoint.hostname === '::1')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
import { createGatewayRuntimeProviderRegistry } from './agent.module.js';
|
|
||||||
import { firstValueFrom } from 'rxjs';
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
RuntimeApprovalDeniedError,
|
|
||||||
RuntimeProviderService,
|
|
||||||
} from './runtime-provider-registry.service.js';
|
|
||||||
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
|
||||||
import { InteractionController } from './interaction.controller.js';
|
|
||||||
|
|
||||||
describe('InteractionController', (): void => {
|
|
||||||
afterEach(() => vi.restoreAllMocks());
|
|
||||||
|
|
||||||
it('maps a denied runtime approval to Fastify HTTP 403', () => {
|
|
||||||
const send = vi.fn();
|
|
||||||
const status = vi.fn().mockReturnValue({ send });
|
|
||||||
const response = { status };
|
|
||||||
const host = { switchToHttp: () => ({ getResponse: () => response }) };
|
|
||||||
|
|
||||||
new RuntimeApprovalDeniedFilter().catch(new RuntimeApprovalDeniedError(), host as never);
|
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(403);
|
|
||||||
expect(send).toHaveBeenCalledWith({
|
|
||||||
statusCode: 403,
|
|
||||||
message: 'Runtime termination approval denied',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('honors a differently named configured instance without a code change', async () => {
|
|
||||||
const prior = process.env['MOSAIC_AGENT_NAME'];
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
||||||
const runtime = { listSessions: vi.fn().mockResolvedValue([]) };
|
|
||||||
const controller = new InteractionController(runtime as never, {} as never);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.sessions('Nova', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
|
|
||||||
).resolves.toEqual([]);
|
|
||||||
await expect(
|
|
||||||
controller.sessions('Other', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
|
|
||||||
).rejects.toThrow('Interaction agent is not configured');
|
|
||||||
|
|
||||||
if (prior === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
|
||||||
else process.env['MOSAIC_AGENT_NAME'] = prior;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reaches the registered Hermes provider through the authenticated transitional matrix route', async () => {
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
||||||
const registry = createGatewayRuntimeProviderRegistry();
|
|
||||||
const runtime = new RuntimeProviderService(
|
|
||||||
registry,
|
|
||||||
{ record: vi.fn().mockResolvedValue(undefined) },
|
|
||||||
{ consume: vi.fn().mockResolvedValue(false) },
|
|
||||||
);
|
|
||||||
const controller = new InteractionController(runtime, {} as never);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.transitionalCapabilities(
|
|
||||||
'Nova',
|
|
||||||
'runtime.hermes',
|
|
||||||
{ id: 'owner', tenantId: 'team' },
|
|
||||||
'corr-1',
|
|
||||||
),
|
|
||||||
).resolves.toEqual([
|
|
||||||
{ capability: 'kanban', status: 'unsupported' },
|
|
||||||
{ capability: 'skills', status: 'unsupported' },
|
|
||||||
{ capability: 'memory', status: 'unsupported' },
|
|
||||||
{ capability: 'tools', status: 'unsupported' },
|
|
||||||
{ capability: 'cron', status: 'unsupported' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a request without the non-simple correlation header', async () => {
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
||||||
const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never);
|
|
||||||
|
|
||||||
await expect(controller.sessions('Nova', 'fleet', { id: 'owner' })).rejects.toThrow(
|
|
||||||
'X-Correlation-Id is required',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enrolls a visible runtime session under the cross-surface conversation handle', async () => {
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
||||||
const runtime = {
|
|
||||||
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
|
|
||||||
};
|
|
||||||
const durable = { enroll: vi.fn().mockResolvedValue(undefined) };
|
|
||||||
const controller = new InteractionController(runtime as never, durable as never);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.enroll(
|
|
||||||
'Nova',
|
|
||||||
'conversation-1',
|
|
||||||
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
||||||
{ id: 'owner', tenantId: 'team' },
|
|
||||||
'corr-1',
|
|
||||||
),
|
|
||||||
).resolves.toEqual({ status: 'enrolled', sessionId: 'conversation-1' });
|
|
||||||
expect(durable.enroll).toHaveBeenCalledWith(
|
|
||||||
{
|
|
||||||
agentName: 'Nova',
|
|
||||||
sessionId: 'conversation-1',
|
|
||||||
tenantId: 'team',
|
|
||||||
ownerId: 'owner',
|
|
||||||
providerId: 'fleet',
|
|
||||||
runtimeSessionId: 'runtime-1',
|
|
||||||
},
|
|
||||||
expect.objectContaining({ correlationId: 'corr-1' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an invalid attach mode before invoking a provider', async () => {
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
||||||
const controller = new InteractionController({ attach: vi.fn() } as never, {} as never);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.attach('Nova', 'durable-1', { mode: 'write' as never }, { id: 'owner' }, 'corr-1'),
|
|
||||||
).rejects.toThrow('Interaction attach mode is invalid');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resumes a durable session by attaching and streaming its runtime events', async () => {
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
||||||
const runtimeEvent = {
|
|
||||||
type: 'message.delta' as const,
|
|
||||||
sessionId: 'runtime-1',
|
|
||||||
cursor: 'cursor-1',
|
|
||||||
occurredAt: '2026-07-13T00:00:00.000Z',
|
|
||||||
content: 'resumed',
|
|
||||||
};
|
|
||||||
const runtime = {
|
|
||||||
attach: vi.fn().mockResolvedValue({ attachmentId: 'attach-1', sessionId: 'runtime-1' }),
|
|
||||||
streamSession: vi.fn(async function* () {
|
|
||||||
yield runtimeEvent;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const durable = {
|
|
||||||
getSnapshot: vi.fn().mockResolvedValue({
|
|
||||||
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const controller = new InteractionController(runtime as never, durable as never);
|
|
||||||
|
|
||||||
await controller.attach('Nova', 'conversation-1', { mode: 'read' }, { id: 'owner' }, 'corr-1');
|
|
||||||
await expect(
|
|
||||||
firstValueFrom(
|
|
||||||
controller.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1'),
|
|
||||||
),
|
|
||||||
).resolves.toEqual({ data: runtimeEvent });
|
|
||||||
|
|
||||||
expect(runtime.attach).toHaveBeenCalledWith(
|
|
||||||
'fleet',
|
|
||||||
'runtime-1',
|
|
||||||
'read',
|
|
||||||
expect.objectContaining({ correlationId: 'corr-1' }),
|
|
||||||
);
|
|
||||||
expect(runtime.streamSession).toHaveBeenCalledWith(
|
|
||||||
'fleet',
|
|
||||||
'runtime-1',
|
|
||||||
undefined,
|
|
||||||
expect.objectContaining({ correlationId: 'corr-1' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not create a runtime stream after the SSE subscriber disconnects during snapshot lookup', async () => {
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
||||||
let resolveSnapshot!: (value: { identity: Record<string, string> }) => void;
|
|
||||||
const snapshot = new Promise<{ identity: Record<string, string> }>((resolve) => {
|
|
||||||
resolveSnapshot = resolve;
|
|
||||||
});
|
|
||||||
const runtime = { streamSession: vi.fn() };
|
|
||||||
const durable = { getSnapshot: vi.fn().mockReturnValue(snapshot) };
|
|
||||||
const controller = new InteractionController(runtime as never, durable as never);
|
|
||||||
|
|
||||||
const subscription = controller
|
|
||||||
.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1')
|
|
||||||
.subscribe();
|
|
||||||
subscription.unsubscribe();
|
|
||||||
resolveSnapshot({
|
|
||||||
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
||||||
});
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
expect(runtime.streamSession).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['wrong actor', { getSnapshot: vi.fn().mockRejectedValue(new Error('scope mismatch')) }],
|
|
||||||
[
|
|
||||||
'session-agent mismatch',
|
|
||||||
{
|
|
||||||
getSnapshot: vi.fn().mockResolvedValue({
|
|
||||||
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
])('denies a CLI stop for %s', async (_reason, durable) => {
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
||||||
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
|
|
||||||
const controller = new InteractionController(runtime as never, durable as never);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.stop(
|
|
||||||
'Nova',
|
|
||||||
'durable-1',
|
|
||||||
{ approvalRef: 'approval-1' },
|
|
||||||
{ id: 'owner' },
|
|
||||||
'corr-1',
|
|
||||||
),
|
|
||||||
).rejects.toBeDefined();
|
|
||||||
expect(runtime.terminate).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('surfaces a denied runtime approval to the CLI interaction surface', async () => {
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
||||||
const runtime = {
|
|
||||||
terminate: vi.fn().mockRejectedValue(new Error('Runtime termination approval denied')),
|
|
||||||
};
|
|
||||||
const durable = {
|
|
||||||
getSnapshot: vi.fn().mockResolvedValue({
|
|
||||||
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const controller = new InteractionController(runtime as never, durable as never);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.stop(
|
|
||||||
'Nova',
|
|
||||||
'durable-1',
|
|
||||||
{ approvalRef: 'approval-1' },
|
|
||||||
{ id: 'owner' },
|
|
||||||
'corr-1',
|
|
||||||
),
|
|
||||||
).rejects.toThrow('Runtime termination approval denied');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the durable session identity and runtime registry for an approved stop', async () => {
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
||||||
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
|
|
||||||
const durable = {
|
|
||||||
getSnapshot: vi.fn().mockResolvedValue({
|
|
||||||
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const controller = new InteractionController(runtime as never, durable as never);
|
|
||||||
|
|
||||||
await controller.stop(
|
|
||||||
'Nova',
|
|
||||||
'durable-1',
|
|
||||||
{ approvalRef: 'approval-1' },
|
|
||||||
{ id: 'owner' },
|
|
||||||
'corr-1',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(runtime.terminate).toHaveBeenCalledWith(
|
|
||||||
'fleet',
|
|
||||||
'runtime-1',
|
|
||||||
'approval-1',
|
|
||||||
expect.objectContaining({
|
|
||||||
correlationId: 'corr-1',
|
|
||||||
actorScope: { userId: 'owner', tenantId: 'owner' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
ForbiddenException,
|
|
||||||
Get,
|
|
||||||
Headers,
|
|
||||||
Sse,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
UseGuards,
|
|
||||||
UseFilters,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import type { RuntimeAttachMode, RuntimeStreamEvent } from '@mosaicstack/types';
|
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
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 { DurableSessionService } from './durable-session.service.js';
|
|
||||||
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
|
||||||
import {
|
|
||||||
RuntimeProviderService,
|
|
||||||
type RuntimeProviderRequestContext,
|
|
||||||
} from './runtime-provider-registry.service.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticated HTTP boundary for operator interaction clients. Identity is
|
|
||||||
* selected from deployment configuration, never a client-side command name.
|
|
||||||
*/
|
|
||||||
@Controller('api/interaction/:agentName')
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@UseFilters(RuntimeApprovalDeniedFilter)
|
|
||||||
export class InteractionController {
|
|
||||||
constructor(
|
|
||||||
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
|
|
||||||
@Inject(DurableSessionService) private readonly durable: DurableSessionService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get('sessions')
|
|
||||||
async sessions(
|
|
||||||
@Param('agentName') agentName: string,
|
|
||||||
@Query('provider') providerId: string,
|
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
|
||||||
) {
|
|
||||||
this.assertConfiguredAgent(agentName);
|
|
||||||
return this.runtime.listSessions(
|
|
||||||
this.requiredProvider(providerId),
|
|
||||||
this.context(user, correlationId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('transitional-capabilities')
|
|
||||||
async transitionalCapabilities(
|
|
||||||
@Param('agentName') agentName: string,
|
|
||||||
@Query('provider') providerId: string,
|
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
|
||||||
) {
|
|
||||||
this.assertConfiguredAgent(agentName);
|
|
||||||
return this.runtime.transitionalCapabilityMatrix(
|
|
||||||
this.requiredProvider(providerId),
|
|
||||||
this.context(user, correlationId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('tree')
|
|
||||||
async tree(
|
|
||||||
@Param('agentName') agentName: string,
|
|
||||||
@Query('provider') providerId: string,
|
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
|
||||||
) {
|
|
||||||
this.assertConfiguredAgent(agentName);
|
|
||||||
return this.runtime.getSessionTree(
|
|
||||||
this.requiredProvider(providerId),
|
|
||||||
this.context(user, correlationId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bind an existing, authorized runtime session to the stable conversation ID.
|
|
||||||
* This is the lifecycle boundary where both runtime identifiers are known.
|
|
||||||
*/
|
|
||||||
@Post('sessions/:sessionId/enroll')
|
|
||||||
async enroll(
|
|
||||||
@Param('agentName') agentName: string,
|
|
||||||
@Param('sessionId') sessionId: string,
|
|
||||||
@Body() body: { providerId?: string; runtimeSessionId?: string } = {},
|
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
|
||||||
) {
|
|
||||||
this.assertConfiguredAgent(agentName);
|
|
||||||
const providerId = this.requiredProvider(body.providerId ?? '');
|
|
||||||
const runtimeSessionId = body.runtimeSessionId?.trim();
|
|
||||||
if (!runtimeSessionId) throw new ForbiddenException('Runtime session identity is required');
|
|
||||||
const context = this.context(user, correlationId);
|
|
||||||
const sessions = await this.runtime.listSessions(providerId, context);
|
|
||||||
if (!sessions.some((session): boolean => session.id === runtimeSessionId)) {
|
|
||||||
throw new ForbiddenException('Runtime session is not visible to this actor');
|
|
||||||
}
|
|
||||||
await this.durable.enroll(
|
|
||||||
{
|
|
||||||
agentName,
|
|
||||||
sessionId,
|
|
||||||
tenantId: context.actorScope.tenantId,
|
|
||||||
ownerId: context.actorScope.userId,
|
|
||||||
providerId,
|
|
||||||
runtimeSessionId,
|
|
||||||
},
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
return { status: 'enrolled', sessionId };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('sessions/:sessionId/attach')
|
|
||||||
async attach(
|
|
||||||
@Param('agentName') agentName: string,
|
|
||||||
@Param('sessionId') sessionId: string,
|
|
||||||
@Body() body: { mode?: RuntimeAttachMode } = {},
|
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
|
||||||
) {
|
|
||||||
this.assertConfiguredAgent(agentName);
|
|
||||||
const context = this.context(user, correlationId);
|
|
||||||
const mode = body.mode ?? 'read';
|
|
||||||
if (mode !== 'read' && mode !== 'control') {
|
|
||||||
throw new ForbiddenException('Interaction attach mode is invalid');
|
|
||||||
}
|
|
||||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
|
||||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
|
||||||
return this.runtime.attach(
|
|
||||||
snapshot.identity.providerId,
|
|
||||||
snapshot.identity.runtimeSessionId,
|
|
||||||
mode,
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Sse('sessions/:sessionId/stream')
|
|
||||||
stream(
|
|
||||||
@Param('agentName') agentName: string,
|
|
||||||
@Param('sessionId') sessionId: string,
|
|
||||||
@Query('cursor') cursor: string | undefined,
|
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
|
||||||
): Observable<{ data: RuntimeStreamEvent }> {
|
|
||||||
this.assertConfiguredAgent(agentName);
|
|
||||||
const context = this.context(user, correlationId);
|
|
||||||
return new Observable((subscriber) => {
|
|
||||||
let iterator: AsyncIterator<RuntimeStreamEvent> | undefined;
|
|
||||||
let cancelled = false;
|
|
||||||
void (async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
|
||||||
if (cancelled || subscriber.closed) return;
|
|
||||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
|
||||||
iterator = this.runtime
|
|
||||||
.streamSession(
|
|
||||||
snapshot.identity.providerId,
|
|
||||||
snapshot.identity.runtimeSessionId,
|
|
||||||
cursor?.trim() || undefined,
|
|
||||||
context,
|
|
||||||
)
|
|
||||||
[Symbol.asyncIterator]();
|
|
||||||
if (cancelled || subscriber.closed) {
|
|
||||||
await iterator.return?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
while (!cancelled && !subscriber.closed) {
|
|
||||||
const next = await iterator.next();
|
|
||||||
if (next.done || cancelled || subscriber.closed) break;
|
|
||||||
subscriber.next({ data: next.value });
|
|
||||||
}
|
|
||||||
if (!subscriber.closed) subscriber.complete();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!subscriber.closed) subscriber.error(error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return (): void => {
|
|
||||||
cancelled = true;
|
|
||||||
void iterator?.return?.();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('sessions/:sessionId/send')
|
|
||||||
async send(
|
|
||||||
@Param('agentName') agentName: string,
|
|
||||||
@Param('sessionId') sessionId: string,
|
|
||||||
@Body() body: { content?: string; idempotencyKey?: string } = {},
|
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
|
||||||
) {
|
|
||||||
this.assertConfiguredAgent(agentName);
|
|
||||||
if (!body.content?.trim() || !body.idempotencyKey?.trim()) {
|
|
||||||
throw new ForbiddenException('Content and idempotency key are required');
|
|
||||||
}
|
|
||||||
const context = this.context(user, correlationId);
|
|
||||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
|
||||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
|
||||||
const input = {
|
|
||||||
sessionId,
|
|
||||||
content: body.content,
|
|
||||||
idempotencyKey: body.idempotencyKey,
|
|
||||||
correlationId: context.correlationId,
|
|
||||||
context,
|
|
||||||
};
|
|
||||||
await this.durable.queueProviderSend(input);
|
|
||||||
await this.durable.dispatchProviderOutbox(sessionId, input);
|
|
||||||
return { status: 'queued', sessionId };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('sessions/:sessionId/stop')
|
|
||||||
async stop(
|
|
||||||
@Param('agentName') agentName: string,
|
|
||||||
@Param('sessionId') sessionId: string,
|
|
||||||
@Body() body: { approvalRef?: string } = {},
|
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
|
||||||
) {
|
|
||||||
this.assertConfiguredAgent(agentName);
|
|
||||||
if (!body.approvalRef?.trim())
|
|
||||||
throw new ForbiddenException('Exact-action approval is required');
|
|
||||||
const context = this.context(user, correlationId);
|
|
||||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
|
||||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
|
||||||
await this.runtime.terminate(
|
|
||||||
snapshot.identity.providerId,
|
|
||||||
snapshot.identity.runtimeSessionId,
|
|
||||||
body.approvalRef,
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
return { status: 'stopped', sessionId };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('sessions/:sessionId/recover')
|
|
||||||
async recover(
|
|
||||||
@Param('agentName') agentName: string,
|
|
||||||
@Param('sessionId') sessionId: string,
|
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
|
||||||
) {
|
|
||||||
this.assertConfiguredAgent(agentName);
|
|
||||||
const context = this.context(user, correlationId);
|
|
||||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
|
||||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
|
||||||
await this.durable.recoverProviderSession(sessionId, {
|
|
||||||
sessionId,
|
|
||||||
content: '',
|
|
||||||
idempotencyKey: `recovery:${context.correlationId}`,
|
|
||||||
correlationId: context.correlationId,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
return { status: 'recovered', sessionId };
|
|
||||||
}
|
|
||||||
|
|
||||||
private context(
|
|
||||||
user: AuthenticatedUserLike,
|
|
||||||
correlationId?: string,
|
|
||||||
): RuntimeProviderRequestContext {
|
|
||||||
const requestCorrelationId = correlationId?.trim();
|
|
||||||
// This non-simple request header is mandatory for mutations. Browser
|
|
||||||
// cross-origin requests cannot set it without a CORS preflight, and the
|
|
||||||
// gateway's allowlist rejects untrusted origins before the handler runs.
|
|
||||||
if (!requestCorrelationId) {
|
|
||||||
throw new ForbiddenException('X-Correlation-Id is required');
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
actorScope: scopeFromUser(user),
|
|
||||||
channelId: 'cli',
|
|
||||||
correlationId: requestCorrelationId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertConfiguredAgent(agentName: string): void {
|
|
||||||
const configured = process.env['MOSAIC_AGENT_NAME']?.trim();
|
|
||||||
if (!configured || configured !== agentName) {
|
|
||||||
throw new ForbiddenException('Interaction agent is not configured for this request');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertSessionAgent(sessionAgentName: string, agentName: string): void {
|
|
||||||
if (sessionAgentName !== agentName)
|
|
||||||
throw new ForbiddenException('Interaction session identity mismatch');
|
|
||||||
}
|
|
||||||
|
|
||||||
private requiredProvider(providerId: string): string {
|
|
||||||
if (!providerId?.trim()) throw new ForbiddenException('Runtime provider is required');
|
|
||||||
return providerId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ModelCapability } from '@mosaicstack/types';
|
import type { ModelCapability } from '@mosaic/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comprehensive capability matrix for all target models.
|
* Comprehensive capability matrix for all target models.
|
||||||
|
|||||||
@@ -1,10 +1,62 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { seal, unseal } from '@mosaicstack/auth';
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||||
import type { Db } from '@mosaicstack/db';
|
import type { Db } from '@mosaic/db';
|
||||||
import { providerCredentials, eq, and } from '@mosaicstack/db';
|
import { providerCredentials, eq, and } from '@mosaic/db';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
import type { ProviderCredentialSummaryDto } from './provider-credentials.dto.js';
|
import type { ProviderCredentialSummaryDto } from './provider-credentials.dto.js';
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
const IV_LENGTH = 12; // 96-bit IV for GCM
|
||||||
|
const TAG_LENGTH = 16; // 128-bit auth tag
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a 32-byte AES-256 key from BETTER_AUTH_SECRET using SHA-256.
|
||||||
|
* The secret is assumed to be set in the environment.
|
||||||
|
*/
|
||||||
|
function deriveEncryptionKey(): Buffer {
|
||||||
|
const secret = process.env['BETTER_AUTH_SECRET'];
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('BETTER_AUTH_SECRET is not set — cannot derive encryption key');
|
||||||
|
}
|
||||||
|
return createHash('sha256').update(secret).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a plain-text value using AES-256-GCM.
|
||||||
|
* Output format: base64(iv + authTag + ciphertext)
|
||||||
|
*/
|
||||||
|
function encrypt(plaintext: string): string {
|
||||||
|
const key = deriveEncryptionKey();
|
||||||
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||||
|
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// Combine iv (12) + authTag (16) + ciphertext and base64-encode
|
||||||
|
const combined = Buffer.concat([iv, authTag, encrypted]);
|
||||||
|
return combined.toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a value encrypted by `encrypt()`.
|
||||||
|
* Throws on authentication failure (tampered data).
|
||||||
|
*/
|
||||||
|
function decrypt(encoded: string): string {
|
||||||
|
const key = deriveEncryptionKey();
|
||||||
|
const combined = Buffer.from(encoded, 'base64');
|
||||||
|
|
||||||
|
const iv = combined.subarray(0, IV_LENGTH);
|
||||||
|
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
||||||
|
const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH);
|
||||||
|
|
||||||
|
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProviderCredentialsService {
|
export class ProviderCredentialsService {
|
||||||
private readonly logger = new Logger(ProviderCredentialsService.name);
|
private readonly logger = new Logger(ProviderCredentialsService.name);
|
||||||
@@ -22,7 +74,7 @@ export class ProviderCredentialsService {
|
|||||||
value: string,
|
value: string,
|
||||||
metadata?: Record<string, unknown>,
|
metadata?: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const encryptedValue = seal(value);
|
const encryptedValue = encrypt(value);
|
||||||
|
|
||||||
await this.db
|
await this.db
|
||||||
.insert(providerCredentials)
|
.insert(providerCredentials)
|
||||||
@@ -70,7 +122,7 @@ export class ProviderCredentialsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return unseal(row.encryptedValue);
|
return decrypt(row.encryptedValue);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to decrypt credential for user=${userId} provider=${provider}`,
|
`Failed to decrypt credential for user=${userId} provider=${provider}`,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
ModelInfo,
|
ModelInfo,
|
||||||
ProviderHealth,
|
ProviderHealth,
|
||||||
ProviderInfo,
|
ProviderInfo,
|
||||||
} from '@mosaicstack/types';
|
} from '@mosaic/types';
|
||||||
import {
|
import {
|
||||||
AnthropicAdapter,
|
AnthropicAdapter,
|
||||||
OllamaAdapter,
|
OllamaAdapter,
|
||||||
@@ -67,7 +67,7 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
const authStorage = AuthStorage.inMemory();
|
const authStorage = AuthStorage.inMemory();
|
||||||
this.registry = ModelRegistry.inMemory(authStorage);
|
this.registry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
// Build the default set of adapters that rely on the registry
|
// Build the default set of adapters that rely on the registry
|
||||||
this.adapters = [
|
this.adapters = [
|
||||||
@@ -107,7 +107,8 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Interval is configurable via PROVIDER_HEALTH_INTERVAL env (seconds, default 60).
|
* Interval is configurable via PROVIDER_HEALTH_INTERVAL env (seconds, default 60).
|
||||||
*/
|
*/
|
||||||
private startHealthCheckScheduler(): void {
|
private startHealthCheckScheduler(): void {
|
||||||
const intervalSecs = this.effectiveHealthCheckIntervalSecs();
|
const intervalSecs =
|
||||||
|
parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS;
|
||||||
const intervalMs = intervalSecs * 1000;
|
const intervalMs = intervalSecs * 1000;
|
||||||
|
|
||||||
// Run an initial check immediately (non-blocking)
|
// Run an initial check immediately (non-blocking)
|
||||||
@@ -175,28 +176,6 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the effective provider operational policy without credentials,
|
|
||||||
* endpoints, request content, or provider error details.
|
|
||||||
*/
|
|
||||||
getEffectivePolicyStatus(): {
|
|
||||||
healthCheckIntervalSecs: number;
|
|
||||||
configuredProviders: string[];
|
|
||||||
availableModelCount: number;
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
healthCheckIntervalSecs: this.effectiveHealthCheckIntervalSecs(),
|
|
||||||
configuredProviders: this.adapters.map((adapter) => adapter.name),
|
|
||||||
availableModelCount: this.registry?.getAvailable().length ?? 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private effectiveHealthCheckIntervalSecs(): number {
|
|
||||||
return (
|
|
||||||
parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Adapter-pattern API
|
// Adapter-pattern API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { ProvidersController } from './providers.controller.js';
|
|
||||||
|
|
||||||
describe('ProvidersController operational status', (): void => {
|
|
||||||
it('reports provider latency and effective policy without exposing provider error details', (): void => {
|
|
||||||
const providerService = {
|
|
||||||
getProvidersHealth: vi.fn(() => [
|
|
||||||
{
|
|
||||||
name: 'fleet',
|
|
||||||
status: 'down',
|
|
||||||
latencyMs: 42,
|
|
||||||
lastChecked: '2026-07-12T00:00:00.000Z',
|
|
||||||
modelCount: 0,
|
|
||||||
error: 'credential-canary=secret-value',
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
getEffectivePolicyStatus: vi.fn(() => ({
|
|
||||||
healthCheckIntervalSecs: 60,
|
|
||||||
configuredProviders: ['fleet'],
|
|
||||||
availableModelCount: 0,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
const controller = new ProvidersController(providerService as never, {} as never, {} as never);
|
|
||||||
|
|
||||||
const status = controller.status();
|
|
||||||
|
|
||||||
expect(status).toEqual({
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
name: 'fleet',
|
|
||||||
status: 'down',
|
|
||||||
latencyMs: 42,
|
|
||||||
lastChecked: '2026-07-12T00:00:00.000Z',
|
|
||||||
modelCount: 0,
|
|
||||||
errorCode: 'provider_unavailable',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
effectivePolicy: {
|
|
||||||
healthCheckIntervalSecs: 60,
|
|
||||||
configuredProviders: ['fleet'],
|
|
||||||
availableModelCount: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(JSON.stringify(status)).not.toContain('secret-value');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Body, Controller, Delete, Get, Inject, Param, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Inject, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
import type { RoutingCriteria } from '@mosaicstack/types';
|
import type { RoutingCriteria } from '@mosaic/types';
|
||||||
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 { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
@@ -33,20 +33,7 @@ export class ProvidersController {
|
|||||||
|
|
||||||
@Get('health')
|
@Get('health')
|
||||||
health() {
|
health() {
|
||||||
return { providers: this.safeProviderHealth() };
|
return { providers: this.providerService.getProvidersHealth() };
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safe operational status for troubleshooting and readiness checks. Provider
|
|
||||||
* errors are reduced to a stable code so credentials and remote responses
|
|
||||||
* cannot leak through this endpoint.
|
|
||||||
*/
|
|
||||||
@Get('status')
|
|
||||||
status() {
|
|
||||||
return {
|
|
||||||
providers: this.safeProviderHealth(),
|
|
||||||
effectivePolicy: this.providerService.getEffectivePolicyStatus(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('test')
|
@Post('test')
|
||||||
@@ -64,13 +51,6 @@ export class ProvidersController {
|
|||||||
return this.routingService.rank(criteria);
|
return this.routingService.rank(criteria);
|
||||||
}
|
}
|
||||||
|
|
||||||
private safeProviderHealth() {
|
|
||||||
return this.providerService.getProvidersHealth().map(({ error, ...provider }) => ({
|
|
||||||
...provider,
|
|
||||||
...(error ? { errorCode: 'provider_unavailable' } : {}),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Credential CRUD ──────────────────────────────────────────────────────
|
// ── Credential CRUD ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import type { ModelInfo } from '@mosaicstack/types';
|
import type { ModelInfo } from '@mosaic/types';
|
||||||
import type { RoutingCriteria, RoutingResult, CostTier } from '@mosaicstack/types';
|
import type { RoutingCriteria, RoutingResult, CostTier } from '@mosaic/types';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
|
|
||||||
/** Per-million-token cost thresholds for tier classification */
|
/** Per-million-token cost thresholds for tier classification */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||||
import { routingRules, type Db, sql } from '@mosaicstack/db';
|
import { routingRules, type Db, sql } from '@mosaic/db';
|
||||||
import { DB } from '../../database/database.module.js';
|
import { DB } from '../../database/database.module.js';
|
||||||
import type { RoutingCondition, RoutingAction } from './routing.types.js';
|
import type { RoutingCondition, RoutingAction } from './routing.types.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +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 '@mosaic/db';
|
||||||
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';
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { routingRules, type Db, and, asc, eq, or, inArray } from '@mosaicstack/db';
|
import { routingRules, type Db, and, asc, eq, or, inArray } from '@mosaic/db';
|
||||||
import { DB } from '../../database/database.module.js';
|
import { DB } from '../../database/database.module.js';
|
||||||
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';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Routing engine types — M4-002 (condition types) and M4-003 (action types).
|
* Routing engine types — M4-002 (condition types) and M4-003 (action types).
|
||||||
*
|
*
|
||||||
* These types are re-exported from `@mosaicstack/types` for shared use across packages.
|
* These types are re-exported from `@mosaic/types` for shared use across packages.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ─── Classification primitives ───────────────────────────────────────────────
|
// ─── Classification primitives ───────────────────────────────────────────────
|
||||||
@@ -23,7 +23,7 @@ export type Domain = 'frontend' | 'backend' | 'devops' | 'docs' | 'general';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Cost tier for model selection.
|
* Cost tier for model selection.
|
||||||
* Extends the existing `CostTier` in `@mosaicstack/types` with `local` for self-hosted models.
|
* Extends the existing `CostTier` in `@mosaic/types` with `local` for self-hosted models.
|
||||||
*/
|
*/
|
||||||
export type CostTier = 'cheap' | 'standard' | 'premium' | 'local';
|
export type CostTier = 'cheap' | 'standard' | 'premium' | 'local';
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Catch, type ArgumentsHost, type ExceptionFilter } from '@nestjs/common';
|
|
||||||
import { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js';
|
|
||||||
|
|
||||||
/** Maps a consumed/missing runtime approval to a stable HTTP authorization response. */
|
|
||||||
@Catch(RuntimeApprovalDeniedError)
|
|
||||||
export class RuntimeApprovalDeniedFilter implements ExceptionFilter {
|
|
||||||
catch(_exception: RuntimeApprovalDeniedError, host: ArgumentsHost): void {
|
|
||||||
const response = host.switchToHttp().getResponse<{
|
|
||||||
status(code: number): { send(body: { statusCode: number; message: string }): void };
|
|
||||||
}>();
|
|
||||||
response.status(403).send({ statusCode: 403, message: 'Runtime termination approval denied' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,500 +0,0 @@
|
|||||||
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
||||||
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
|
||||||
import {
|
|
||||||
createRuntimeAuditLogEntry,
|
|
||||||
type LogService,
|
|
||||||
type RuntimeAuditErrorCode,
|
|
||||||
} from '@mosaicstack/log';
|
|
||||||
import type {
|
|
||||||
AgentRuntimeProvider,
|
|
||||||
RuntimeAttachHandle,
|
|
||||||
RuntimeAttachMode,
|
|
||||||
RuntimeCapability,
|
|
||||||
RuntimeCapabilitySet,
|
|
||||||
RuntimeHealth,
|
|
||||||
RuntimeMessage,
|
|
||||||
RuntimeScope,
|
|
||||||
RuntimeSession,
|
|
||||||
RuntimeSessionTree,
|
|
||||||
RuntimeStreamEvent,
|
|
||||||
TransitionalCapabilityInventoryEntry,
|
|
||||||
TransitionalCapabilityInventoryProvider,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
|
||||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
|
||||||
|
|
||||||
export const AGENT_RUNTIME_PROVIDER_REGISTRY = Symbol('AGENT_RUNTIME_PROVIDER_REGISTRY');
|
|
||||||
export const RUNTIME_PROVIDER_AUDIT_SINK = Symbol('RUNTIME_PROVIDER_AUDIT_SINK');
|
|
||||||
export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER');
|
|
||||||
|
|
||||||
export type RuntimeProviderOperation =
|
|
||||||
| RuntimeCapability
|
|
||||||
| 'runtime.capabilities'
|
|
||||||
| 'runtime.health'
|
|
||||||
| 'runtime.transitional-capabilities';
|
|
||||||
export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
|
|
||||||
|
|
||||||
/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */
|
|
||||||
export interface RuntimeProviderRequestContext {
|
|
||||||
actorScope: ActorTenantScope;
|
|
||||||
channelId: string;
|
|
||||||
correlationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Metadata-only audit record. Message bodies, idempotency keys, and approval refs are excluded. */
|
|
||||||
export interface RuntimeAuditEvent {
|
|
||||||
providerId: string;
|
|
||||||
operation: RuntimeProviderOperation;
|
|
||||||
outcome: RuntimeProviderAuditOutcome;
|
|
||||||
actorId: string;
|
|
||||||
tenantId: string;
|
|
||||||
channelId: string;
|
|
||||||
correlationId: string;
|
|
||||||
resourceId?: string;
|
|
||||||
durationMs?: number;
|
|
||||||
errorCode?: RuntimeAuditErrorCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeAuditSink {
|
|
||||||
record(event: RuntimeAuditEvent): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Exact action shape that a durable approval implementation must consume once. */
|
|
||||||
export interface RuntimeTerminationAction {
|
|
||||||
providerId: string;
|
|
||||||
sessionId: string;
|
|
||||||
actorId: string;
|
|
||||||
tenantId: string;
|
|
||||||
channelId: string;
|
|
||||||
correlationId: string;
|
|
||||||
agentName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeApprovalVerifier {
|
|
||||||
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTransitionalInventoryProvider(
|
|
||||||
provider: AgentRuntimeProvider,
|
|
||||||
): provider is AgentRuntimeProvider & TransitionalCapabilityInventoryProvider {
|
|
||||||
return (
|
|
||||||
typeof (provider as Partial<TransitionalCapabilityInventoryProvider>)
|
|
||||||
.transitionalCapabilityMatrix === 'function'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function configuredAgentName(): string {
|
|
||||||
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
|
|
||||||
if (!agentName) throw new RuntimeApprovalDeniedError();
|
|
||||||
return agentName;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RuntimeApprovalDeniedError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super('Runtime termination approval denied');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The default denies all runtime termination until a durable, exact-action
|
|
||||||
* approval implementation is configured. This is safer than a permissive stub.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class DenyRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
|
|
||||||
async consume(_approvalRef: string, _action: RuntimeTerminationAction): Promise<boolean> {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Temporary metadata-only audit sink. M1 observability can replace this token
|
|
||||||
* with a durable audit writer without changing provider call sites.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class RuntimeProviderAuditService implements RuntimeAuditSink {
|
|
||||||
private readonly logger = new Logger(RuntimeProviderAuditService.name);
|
|
||||||
|
|
||||||
constructor(@Inject(LOG_SERVICE) private readonly logService: LogService) {}
|
|
||||||
|
|
||||||
async record(event: RuntimeAuditEvent): Promise<void> {
|
|
||||||
const entry = createRuntimeAuditLogEntry(event);
|
|
||||||
await this.logService.logs.ingest(entry);
|
|
||||||
this.logger.log(JSON.stringify({ event: entry.content, metadata: entry.metadata }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RuntimeProviderService {
|
|
||||||
private readonly logger = new Logger(RuntimeProviderService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(AGENT_RUNTIME_PROVIDER_REGISTRY)
|
|
||||||
private readonly registry: AgentRuntimeProviderRegistry,
|
|
||||||
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
|
||||||
private readonly audit: RuntimeAuditSink,
|
|
||||||
@Inject(RUNTIME_APPROVAL_VERIFIER)
|
|
||||||
private readonly approvals: RuntimeApprovalVerifier,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async capabilities(
|
|
||||||
providerId: string,
|
|
||||||
context: RuntimeProviderRequestContext,
|
|
||||||
): Promise<RuntimeCapabilitySet> {
|
|
||||||
return this.execute(
|
|
||||||
providerId,
|
|
||||||
'runtime.capabilities',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
context,
|
|
||||||
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeCapabilitySet> =>
|
|
||||||
provider.capabilities(scope),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async health(providerId: string, context: RuntimeProviderRequestContext): Promise<RuntimeHealth> {
|
|
||||||
return this.execute(
|
|
||||||
providerId,
|
|
||||||
'runtime.health',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
context,
|
|
||||||
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeHealth> =>
|
|
||||||
provider.health(scope),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async transitionalCapabilityMatrix(
|
|
||||||
providerId: string,
|
|
||||||
context: RuntimeProviderRequestContext,
|
|
||||||
): Promise<TransitionalCapabilityInventoryEntry[]> {
|
|
||||||
return this.execute(
|
|
||||||
providerId,
|
|
||||||
'runtime.transitional-capabilities',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
context,
|
|
||||||
async (provider: AgentRuntimeProvider, scope: RuntimeScope) => {
|
|
||||||
if (!isTransitionalInventoryProvider(provider)) {
|
|
||||||
throw new NotFoundException('Runtime provider has no transitional capability inventory');
|
|
||||||
}
|
|
||||||
return provider.transitionalCapabilityMatrix(scope);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async listSessions(
|
|
||||||
providerId: string,
|
|
||||||
context: RuntimeProviderRequestContext,
|
|
||||||
): Promise<RuntimeSession[]> {
|
|
||||||
return this.execute(
|
|
||||||
providerId,
|
|
||||||
'session.list',
|
|
||||||
'session.list',
|
|
||||||
undefined,
|
|
||||||
context,
|
|
||||||
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSession[]> =>
|
|
||||||
provider.listSessions(scope),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSessionTree(
|
|
||||||
providerId: string,
|
|
||||||
context: RuntimeProviderRequestContext,
|
|
||||||
): Promise<RuntimeSessionTree[]> {
|
|
||||||
return this.execute(
|
|
||||||
providerId,
|
|
||||||
'session.tree',
|
|
||||||
'session.tree',
|
|
||||||
undefined,
|
|
||||||
context,
|
|
||||||
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSessionTree[]> =>
|
|
||||||
provider.getSessionTree(scope),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
streamSession(
|
|
||||||
providerId: string,
|
|
||||||
sessionId: string,
|
|
||||||
cursor: string | undefined,
|
|
||||||
context: RuntimeProviderRequestContext,
|
|
||||||
): AsyncIterable<RuntimeStreamEvent> {
|
|
||||||
return this.stream(
|
|
||||||
providerId,
|
|
||||||
'session.stream',
|
|
||||||
'session.stream',
|
|
||||||
sessionId,
|
|
||||||
context,
|
|
||||||
(provider: AgentRuntimeProvider, scope: RuntimeScope): AsyncIterable<RuntimeStreamEvent> =>
|
|
||||||
provider.streamSession(sessionId, cursor, scope),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendMessage(
|
|
||||||
providerId: string,
|
|
||||||
sessionId: string,
|
|
||||||
message: RuntimeMessage,
|
|
||||||
context: RuntimeProviderRequestContext,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.execute(
|
|
||||||
providerId,
|
|
||||||
'session.send',
|
|
||||||
'session.send',
|
|
||||||
sessionId,
|
|
||||||
context,
|
|
||||||
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
|
|
||||||
provider.sendMessage(sessionId, message, scope),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async attach(
|
|
||||||
providerId: string,
|
|
||||||
sessionId: string,
|
|
||||||
mode: RuntimeAttachMode,
|
|
||||||
context: RuntimeProviderRequestContext,
|
|
||||||
): Promise<RuntimeAttachHandle> {
|
|
||||||
return this.execute(
|
|
||||||
providerId,
|
|
||||||
'session.attach',
|
|
||||||
'session.attach',
|
|
||||||
sessionId,
|
|
||||||
context,
|
|
||||||
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeAttachHandle> =>
|
|
||||||
provider.attach(sessionId, mode, scope),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async detach(
|
|
||||||
providerId: string,
|
|
||||||
attachmentId: string,
|
|
||||||
context: RuntimeProviderRequestContext,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.execute(
|
|
||||||
providerId,
|
|
||||||
'session.attach',
|
|
||||||
'session.attach',
|
|
||||||
attachmentId,
|
|
||||||
context,
|
|
||||||
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
|
|
||||||
provider.detach(attachmentId, scope),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async terminate(
|
|
||||||
providerId: string,
|
|
||||||
sessionId: string,
|
|
||||||
approvalRef: string,
|
|
||||||
context: RuntimeProviderRequestContext,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.execute(
|
|
||||||
providerId,
|
|
||||||
'session.terminate',
|
|
||||||
'session.terminate',
|
|
||||||
sessionId,
|
|
||||||
context,
|
|
||||||
async (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> => {
|
|
||||||
const approved = await this.approvals.consume(approvalRef, {
|
|
||||||
providerId,
|
|
||||||
sessionId,
|
|
||||||
actorId: scope.actorId,
|
|
||||||
tenantId: scope.tenantId,
|
|
||||||
channelId: scope.channelId,
|
|
||||||
correlationId: scope.correlationId,
|
|
||||||
agentName: configuredAgentName(),
|
|
||||||
});
|
|
||||||
if (!approved) {
|
|
||||||
throw new RuntimeApprovalDeniedError();
|
|
||||||
}
|
|
||||||
await provider.terminate(sessionId, approvalRef, scope);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async execute<T>(
|
|
||||||
providerId: string,
|
|
||||||
operation: RuntimeProviderOperation,
|
|
||||||
requiredCapability: RuntimeCapability | undefined,
|
|
||||||
resourceId: string | undefined,
|
|
||||||
context: RuntimeProviderRequestContext,
|
|
||||||
invoke: (provider: AgentRuntimeProvider, scope: RuntimeScope) => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
const scope = this.deriveScope(context);
|
|
||||||
const startedAt = Date.now();
|
|
||||||
await this.record(providerId, operation, 'requested', scope, resourceId);
|
|
||||||
let invocationStarted = false;
|
|
||||||
try {
|
|
||||||
const provider = this.provider(providerId);
|
|
||||||
if (requiredCapability) {
|
|
||||||
await this.assertCapability(provider, requiredCapability, scope);
|
|
||||||
}
|
|
||||||
invocationStarted = true;
|
|
||||||
const result = await invoke(provider, scope);
|
|
||||||
await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt);
|
|
||||||
return result;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const durationMs = Date.now() - startedAt;
|
|
||||||
if (invocationStarted && !this.isAuthorizationDenied(error)) {
|
|
||||||
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
|
|
||||||
} else {
|
|
||||||
await this.record(
|
|
||||||
providerId,
|
|
||||||
operation,
|
|
||||||
'denied',
|
|
||||||
scope,
|
|
||||||
resourceId,
|
|
||||||
durationMs,
|
|
||||||
'policy_denied',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async *stream(
|
|
||||||
providerId: string,
|
|
||||||
operation: RuntimeProviderOperation,
|
|
||||||
requiredCapability: RuntimeCapability,
|
|
||||||
resourceId: string,
|
|
||||||
context: RuntimeProviderRequestContext,
|
|
||||||
invoke: (
|
|
||||||
provider: AgentRuntimeProvider,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
) => AsyncIterable<RuntimeStreamEvent>,
|
|
||||||
): AsyncIterable<RuntimeStreamEvent> {
|
|
||||||
const scope = this.deriveScope(context);
|
|
||||||
const startedAt = Date.now();
|
|
||||||
await this.record(providerId, operation, 'requested', scope, resourceId);
|
|
||||||
let invocationStarted = false;
|
|
||||||
try {
|
|
||||||
const provider = this.provider(providerId);
|
|
||||||
await this.assertCapability(provider, requiredCapability, scope);
|
|
||||||
invocationStarted = true;
|
|
||||||
for await (const event of invoke(provider, scope)) {
|
|
||||||
yield event;
|
|
||||||
}
|
|
||||||
await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const durationMs = Date.now() - startedAt;
|
|
||||||
if (invocationStarted) {
|
|
||||||
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
|
|
||||||
} else {
|
|
||||||
await this.record(
|
|
||||||
providerId,
|
|
||||||
operation,
|
|
||||||
'denied',
|
|
||||||
scope,
|
|
||||||
resourceId,
|
|
||||||
durationMs,
|
|
||||||
'policy_denied',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private isAuthorizationDenied(error: unknown): boolean {
|
|
||||||
return (
|
|
||||||
error instanceof RuntimeApprovalDeniedError ||
|
|
||||||
error instanceof ForbiddenException ||
|
|
||||||
(typeof error === 'object' &&
|
|
||||||
error !== null &&
|
|
||||||
'code' in error &&
|
|
||||||
(error as { code?: unknown }).code === 'forbidden')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private provider(providerId: string): AgentRuntimeProvider {
|
|
||||||
try {
|
|
||||||
return this.registry.require(providerId);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Runtime provider is not registered';
|
|
||||||
throw new NotFoundException(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertCapability(
|
|
||||||
provider: AgentRuntimeProvider,
|
|
||||||
requiredCapability: RuntimeCapability,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
): Promise<void> {
|
|
||||||
const capabilities = await provider.capabilities(scope);
|
|
||||||
if (!capabilities.supported.includes(requiredCapability)) {
|
|
||||||
throw new ForbiddenException(`Runtime provider capability denied: ${requiredCapability}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private deriveScope(context: RuntimeProviderRequestContext): RuntimeScope {
|
|
||||||
const actorId = context.actorScope.userId.trim();
|
|
||||||
const tenantId = context.actorScope.tenantId.trim();
|
|
||||||
const channelId = context.channelId.trim();
|
|
||||||
const correlationId = context.correlationId.trim();
|
|
||||||
if (!actorId || !tenantId || !channelId || !correlationId) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'Authenticated runtime actor scope and correlation are required',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Object.freeze({ actorId, tenantId, channelId, correlationId });
|
|
||||||
}
|
|
||||||
|
|
||||||
private async recordFailure(
|
|
||||||
providerId: string,
|
|
||||||
operation: RuntimeProviderOperation,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
resourceId: string | undefined,
|
|
||||||
durationMs: number,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.record(
|
|
||||||
providerId,
|
|
||||||
operation,
|
|
||||||
'failed',
|
|
||||||
scope,
|
|
||||||
resourceId,
|
|
||||||
durationMs,
|
|
||||||
'provider_error',
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
this.logger.error(
|
|
||||||
`Runtime provider failure audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async recordCompletion(
|
|
||||||
providerId: string,
|
|
||||||
operation: RuntimeProviderOperation,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
resourceId: string | undefined,
|
|
||||||
durationMs: number,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.record(providerId, operation, 'succeeded', scope, resourceId, durationMs);
|
|
||||||
} catch {
|
|
||||||
this.logger.error(
|
|
||||||
`Runtime provider completion audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async record(
|
|
||||||
providerId: string,
|
|
||||||
operation: RuntimeProviderOperation,
|
|
||||||
outcome: RuntimeProviderAuditOutcome,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
resourceId: string | undefined,
|
|
||||||
durationMs?: number,
|
|
||||||
errorCode?: RuntimeAuditErrorCode,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.audit.record({
|
|
||||||
providerId,
|
|
||||||
operation,
|
|
||||||
outcome,
|
|
||||||
actorId: scope.actorId,
|
|
||||||
tenantId: scope.tenantId,
|
|
||||||
channelId: scope.channelId,
|
|
||||||
correlationId: scope.correlationId,
|
|
||||||
...(resourceId ? { resourceId } : {}),
|
|
||||||
...(durationMs !== undefined ? { durationMs } : {}),
|
|
||||||
...(errorCode ? { errorCode } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,8 +10,6 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} 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 { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
|
||||||
import { AgentService } from './agent.service.js';
|
import { AgentService } from './agent.service.js';
|
||||||
|
|
||||||
@Controller('api/sessions')
|
@Controller('api/sessions')
|
||||||
@@ -20,24 +18,23 @@ export class SessionsController {
|
|||||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@CurrentUser() user: AuthenticatedUserLike) {
|
list() {
|
||||||
const sessions = this.agentService.listSessions(scopeFromUser(user));
|
const sessions = this.agentService.listSessions();
|
||||||
return { sessions, total: sessions.length };
|
return { sessions, total: sessions.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
|
findOne(@Param('id') id: string) {
|
||||||
const info = this.agentService.getSessionInfo(id, scopeFromUser(user));
|
const info = this.agentService.getSessionInfo(id);
|
||||||
if (!info) throw new NotFoundException('Session not found');
|
if (!info) throw new NotFoundException('Session not found');
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async destroy(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
|
async destroy(@Param('id') id: string) {
|
||||||
const scope = scopeFromUser(user);
|
const info = this.agentService.getSessionInfo(id);
|
||||||
const info = this.agentService.getSessionInfo(id, scope);
|
|
||||||
if (!info) throw new NotFoundException('Session not found');
|
if (!info) throw new NotFoundException('Session not found');
|
||||||
await this.agentService.destroySession(id, scope);
|
await this.agentService.destroySession(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Type } from '@sinclair/typebox';
|
import { Type } from '@sinclair/typebox';
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
import type { Brain } from '@mosaicstack/brain';
|
import type { Brain } from '@mosaic/brain';
|
||||||
|
|
||||||
export function createBrainTools(brain: Brain): ToolDefinition[] {
|
export function createBrainTools(brain: Brain): ToolDefinition[] {
|
||||||
const listProjects: ToolDefinition = {
|
const listProjects: ToolDefinition = {
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { createMemoryTools } from './memory-tools.js';
|
|
||||||
|
|
||||||
describe('createMemoryTools operator retrieval binding', () => {
|
|
||||||
const memory = {
|
|
||||||
insights: { searchByEmbedding: vi.fn(), create: vi.fn() },
|
|
||||||
preferences: { findByUserAndCategory: vi.fn(), findByUser: vi.fn(), upsert: vi.fn() },
|
|
||||||
};
|
|
||||||
const scope = { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' };
|
|
||||||
|
|
||||||
it('uses the configured plugin with the server-derived scope for retrieval and capture', async () => {
|
|
||||||
const plugin = {
|
|
||||||
search: vi.fn(async () => []),
|
|
||||||
capture: vi.fn(async () => ({ id: 'insight-1' })),
|
|
||||||
};
|
|
||||||
const tools = createMemoryTools(memory as never, null, 'owner-a', {
|
|
||||||
plugin: plugin as never,
|
|
||||||
scope,
|
|
||||||
});
|
|
||||||
|
|
||||||
await tools
|
|
||||||
.find((tool) => tool.name === 'memory_search')!
|
|
||||||
.execute('call-1', { query: 'plans' }, undefined, undefined, {} as never);
|
|
||||||
await tools
|
|
||||||
.find((tool) => tool.name === 'memory_save_insight')!
|
|
||||||
.execute(
|
|
||||||
'call-2',
|
|
||||||
{ content: 'secret', category: 'decision' },
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(plugin.search).toHaveBeenCalledWith(scope, 'plans', 5);
|
|
||||||
expect(plugin.capture).toHaveBeenCalledWith(scope, {
|
|
||||||
content: 'secret',
|
|
||||||
source: 'agent',
|
|
||||||
category: 'decision',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
import { Type } from '@sinclair/typebox';
|
import { Type } from '@sinclair/typebox';
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
import type {
|
import type { Memory } from '@mosaic/memory';
|
||||||
EmbeddingProvider,
|
import type { EmbeddingProvider } from '@mosaic/memory';
|
||||||
Memory,
|
|
||||||
OperatorMemoryPlugin,
|
|
||||||
OperatorMemoryScope,
|
|
||||||
} from '@mosaicstack/memory';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create memory tools bound to the session's authenticated userId.
|
* Create memory tools bound to the session's authenticated userId.
|
||||||
@@ -17,10 +13,8 @@ import type {
|
|||||||
export function createMemoryTools(
|
export function createMemoryTools(
|
||||||
memory: Memory,
|
memory: Memory,
|
||||||
embeddingProvider: EmbeddingProvider | null,
|
embeddingProvider: EmbeddingProvider | null,
|
||||||
/** Authenticated user ID from the session. All preference operations are scoped to this user. */
|
/** Authenticated user ID from the session. All memory operations are scoped to this user. */
|
||||||
sessionUserId: string | undefined,
|
sessionUserId: string | undefined,
|
||||||
/** Optional configured retrieval plugin, bound to a server-derived session scope. */
|
|
||||||
operatorMemory?: { plugin: OperatorMemoryPlugin; scope: OperatorMemoryScope },
|
|
||||||
): ToolDefinition[] {
|
): ToolDefinition[] {
|
||||||
/** Return an error result when no session user is bound. */
|
/** Return an error result when no session user is bound. */
|
||||||
function noUserError() {
|
function noUserError() {
|
||||||
@@ -52,14 +46,6 @@ export function createMemoryTools(
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (operatorMemory) {
|
|
||||||
const results = await operatorMemory.plugin.search(operatorMemory.scope, query, limit ?? 5);
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!embeddingProvider) {
|
if (!embeddingProvider) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -172,18 +158,6 @@ export function createMemoryTools(
|
|||||||
};
|
};
|
||||||
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
||||||
|
|
||||||
if (operatorMemory) {
|
|
||||||
const insight = await operatorMemory.plugin.capture(operatorMemory.scope, {
|
|
||||||
content,
|
|
||||||
source: 'agent',
|
|
||||||
category: category ?? 'learning',
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(insight, null, 2) }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let embedding: number[] | null = null;
|
let embedding: number[] | null = null;
|
||||||
if (embeddingProvider) {
|
if (embeddingProvider) {
|
||||||
embedding = await embeddingProvider.embed(content);
|
embedding = await embeddingProvider.embed(content);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { HealthController } from './health/health.controller.js';
|
import { HealthController } from './health/health.controller.js';
|
||||||
import { ConfigModule } from './config/config.module.js';
|
|
||||||
import { DatabaseModule } from './database/database.module.js';
|
import { DatabaseModule } from './database/database.module.js';
|
||||||
import { AuthModule } from './auth/auth.module.js';
|
import { AuthModule } from './auth/auth.module.js';
|
||||||
import { BrainModule } from './brain/brain.module.js';
|
import { BrainModule } from './brain/brain.module.js';
|
||||||
@@ -24,13 +23,11 @@ import { GCModule } from './gc/gc.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 { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ThrottlerModule.forRoot([{ name: 'default', ttl: 60_000, limit: 60 }]),
|
ThrottlerModule.forRoot([{ name: 'default', ttl: 60_000, limit: 60 }]),
|
||||||
ConfigModule,
|
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
BrainModule,
|
BrainModule,
|
||||||
@@ -53,7 +50,6 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
FederationModule,
|
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
import { toNodeHandler } from 'better-auth/node';
|
import { toNodeHandler } from 'better-auth/node';
|
||||||
import type { Auth } from '@mosaicstack/auth';
|
import type { Auth } from '@mosaic/auth';
|
||||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
import { AUTH } from './auth.tokens.js';
|
import { AUTH } from './auth.tokens.js';
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { fromNodeHeaders } from 'better-auth/node';
|
import { fromNodeHeaders } from 'better-auth/node';
|
||||||
import type { Auth } from '@mosaicstack/auth';
|
import type { Auth } from '@mosaic/auth';
|
||||||
import type { FastifyRequest } from 'fastify';
|
import type { FastifyRequest } from 'fastify';
|
||||||
import { AUTH } from './auth.tokens.js';
|
import { AUTH } from './auth.tokens.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { createAuth, type Auth } from '@mosaicstack/auth';
|
import { createAuth, type Auth } from '@mosaic/auth';
|
||||||
import type { Db } from '@mosaicstack/db';
|
import type { Db } from '@mosaic/db';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
import { AUTH } from './auth.tokens.js';
|
import { AUTH } from './auth.tokens.js';
|
||||||
import { SsoController } from './sso.controller.js';
|
import { SsoController } from './sso.controller.js';
|
||||||
@@ -14,7 +14,7 @@ import { SsoController } from './sso.controller.js';
|
|||||||
useFactory: (db: Db): Auth =>
|
useFactory: (db: Db): Auth =>
|
||||||
createAuth({
|
createAuth({
|
||||||
db,
|
db,
|
||||||
baseURL: process.env['BETTER_AUTH_URL'] ?? 'http://localhost:14242',
|
baseURL: process.env['BETTER_AUTH_URL'] ?? 'http://localhost:4000',
|
||||||
secret: process.env['BETTER_AUTH_SECRET'],
|
secret: process.env['BETTER_AUTH_SECRET'],
|
||||||
}),
|
}),
|
||||||
inject: [DB],
|
inject: [DB],
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
export interface AuthenticatedUserLike {
|
|
||||||
id: string;
|
|
||||||
tenantId?: string | null;
|
|
||||||
teamId?: string | null;
|
|
||||||
organizationId?: string | null;
|
|
||||||
orgId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActorTenantScope {
|
|
||||||
userId: string;
|
|
||||||
tenantId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the immutable server-derived scope used for Tess session operations.
|
|
||||||
* Current Mosaic auth is user-scoped; future org/team claims can populate one
|
|
||||||
* of the tenant fields without allowing clients to choose another tenant.
|
|
||||||
*/
|
|
||||||
export function scopeFromUser(user: AuthenticatedUserLike): ActorTenantScope {
|
|
||||||
return {
|
|
||||||
userId: user.id,
|
|
||||||
tenantId: user.tenantId ?? user.teamId ?? user.organizationId ?? user.orgId ?? user.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { buildSsoDiscovery, type SsoProviderDiscovery } from '@mosaicstack/auth';
|
import { buildSsoDiscovery, type SsoProviderDiscovery } from '@mosaic/auth';
|
||||||
|
|
||||||
@Controller('api/sso/providers')
|
@Controller('api/sso/providers')
|
||||||
export class SsoController {
|
export class SsoController {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { createBrain, type Brain } from '@mosaicstack/brain';
|
import { createBrain, type Brain } from '@mosaic/brain';
|
||||||
import type { Db } from '@mosaicstack/db';
|
import type { Db } from '@mosaic/db';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
import { BRAIN } from './brain.tokens.js';
|
import { BRAIN } from './brain.tokens.js';
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ describe('Chat controller source hardening', () => {
|
|||||||
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
||||||
|
|
||||||
expect(source).toContain('@UseGuards(AuthGuard)');
|
expect(source).toContain('@UseGuards(AuthGuard)');
|
||||||
expect(source).toContain('@CurrentUser() user: AuthenticatedUserLike');
|
expect(source).toContain('@CurrentUser() user: { id: string }');
|
||||||
expect(source).toContain('const scope = scopeFromUser(user);');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Body,
|
Body,
|
||||||
Logger,
|
Logger,
|
||||||
ForbiddenException,
|
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
NotFoundException,
|
|
||||||
Inject,
|
Inject,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -15,7 +13,6 @@ import { Throttle } from '@nestjs/throttler';
|
|||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
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 { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { ChatRequestDto } from './chat.dto.js';
|
import { ChatRequestDto } from './chat.dto.js';
|
||||||
|
|
||||||
@@ -35,23 +32,16 @@ export class ChatController {
|
|||||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||||
async chat(
|
async chat(
|
||||||
@Body() body: ChatRequestDto,
|
@Body() body: ChatRequestDto,
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
@CurrentUser() user: { id: string },
|
||||||
): Promise<ChatResponse> {
|
): Promise<ChatResponse> {
|
||||||
const conversationId = body.conversationId ?? uuid();
|
const conversationId = body.conversationId ?? uuid();
|
||||||
const scope = scopeFromUser(user);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let agentSession = this.agentService.getSession(conversationId, scope);
|
let agentSession = this.agentService.getSession(conversationId);
|
||||||
if (!agentSession) {
|
if (!agentSession) {
|
||||||
agentSession = await this.agentService.createSession(conversationId, {
|
agentSession = await this.agentService.createSession(conversationId);
|
||||||
userId: scope.userId,
|
|
||||||
tenantId: scope.tenantId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ForbiddenException) {
|
|
||||||
throw new NotFoundException('Session not found');
|
|
||||||
}
|
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Session creation failed for conversation=${conversationId}`,
|
`Session creation failed for conversation=${conversationId}`,
|
||||||
err instanceof Error ? err.stack : String(err),
|
err instanceof Error ? err.stack : String(err),
|
||||||
@@ -70,13 +60,8 @@ export class ChatController {
|
|||||||
reject(new Error('Agent response timed out'));
|
reject(new Error('Agent response timed out'));
|
||||||
}, 120_000);
|
}, 120_000);
|
||||||
|
|
||||||
const cleanup = this.agentService.onEvent(
|
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
||||||
conversationId,
|
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
|
||||||
(event: AgentSessionEvent) => {
|
|
||||||
if (
|
|
||||||
event.type === 'message_update' &&
|
|
||||||
event.assistantMessageEvent.type === 'text_delta'
|
|
||||||
) {
|
|
||||||
responseText += event.assistantMessageEvent.delta;
|
responseText += event.assistantMessageEvent.delta;
|
||||||
}
|
}
|
||||||
if (event.type === 'agent_end') {
|
if (event.type === 'agent_end') {
|
||||||
@@ -84,13 +69,11 @@ export class ChatController {
|
|||||||
cleanup();
|
cleanup();
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
scope,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.agentService.prompt(conversationId, body.content, scope);
|
await this.agentService.prompt(conversationId, body.content);
|
||||||
await done;
|
await done;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof HttpException) throw err;
|
if (err instanceof HttpException) throw err;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { timingSafeEqual } from 'node:crypto';
|
|
||||||
import type { IncomingHttpHeaders } from 'node:http';
|
import type { IncomingHttpHeaders } from 'node:http';
|
||||||
import { fromNodeHeaders } from 'better-auth/node';
|
import { fromNodeHeaders } from 'better-auth/node';
|
||||||
|
|
||||||
@@ -13,19 +12,6 @@ export interface SessionAuth {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateDiscordServiceToken(
|
|
||||||
candidate: unknown,
|
|
||||||
expected: string | undefined,
|
|
||||||
): boolean {
|
|
||||||
if (typeof candidate !== 'string' || !expected) return false;
|
|
||||||
const candidateBuffer = Buffer.from(candidate);
|
|
||||||
const expectedBuffer = Buffer.from(expected);
|
|
||||||
return (
|
|
||||||
candidateBuffer.length === expectedBuffer.length &&
|
|
||||||
timingSafeEqual(candidateBuffer, expectedBuffer)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function validateSocketSession(
|
export async function validateSocketSession(
|
||||||
headers: IncomingHttpHeaders,
|
headers: IncomingHttpHeaders,
|
||||||
auth: SessionAuth,
|
auth: SessionAuth,
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
|
||||||
import { ChatGateway } from './chat.gateway.js';
|
|
||||||
|
|
||||||
const payload: SlashCommandPayload = {
|
|
||||||
command: 'gc',
|
|
||||||
conversationId: 'conversation-1',
|
|
||||||
approvalId: 'approval-1',
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildGateway(commandExecutor: {
|
|
||||||
execute: ReturnType<typeof vi.fn>;
|
|
||||||
createApproval: ReturnType<typeof vi.fn>;
|
|
||||||
}): ChatGateway {
|
|
||||||
return new ChatGateway(
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
commandExecutor as never,
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ChatGateway command approval ingress', () => {
|
|
||||||
it('passes the client approval ID through to command execution while deriving the actor server-side', async (): Promise<void> => {
|
|
||||||
const commandExecutor = {
|
|
||||||
execute: vi.fn().mockResolvedValue({ ...payload, success: true }),
|
|
||||||
createApproval: vi.fn(),
|
|
||||||
};
|
|
||||||
const gateway = buildGateway(commandExecutor);
|
|
||||||
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
|
||||||
|
|
||||||
await gateway.handleCommandExecute(client as never, payload);
|
|
||||||
|
|
||||||
expect(commandExecutor.execute).toHaveBeenCalledWith(payload, {
|
|
||||||
userId: 'admin-1',
|
|
||||||
tenantId: 'admin-1',
|
|
||||||
});
|
|
||||||
expect(client.emit).toHaveBeenCalledWith(
|
|
||||||
'command:result',
|
|
||||||
expect.objectContaining({ success: true }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('issues a durable approval only for the authenticated actor', async (): Promise<void> => {
|
|
||||||
const commandExecutor = {
|
|
||||||
execute: vi.fn(),
|
|
||||||
createApproval: vi.fn().mockResolvedValue({
|
|
||||||
approvalId: 'approval-1',
|
|
||||||
expiresAt: '2026-07-12T00:05:00.000Z',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const gateway = buildGateway(commandExecutor);
|
|
||||||
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
|
||||||
|
|
||||||
await gateway.handleCommandApproval(client as never, {
|
|
||||||
command: 'gc',
|
|
||||||
conversationId: 'conversation-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
|
|
||||||
{ command: 'gc', conversationId: 'conversation-1' },
|
|
||||||
{ userId: 'admin-1', tenantId: 'admin-1' },
|
|
||||||
);
|
|
||||||
expect(client.emit).toHaveBeenCalledWith('command:approval', {
|
|
||||||
command: 'gc',
|
|
||||||
conversationId: 'conversation-1',
|
|
||||||
success: true,
|
|
||||||
approvalId: 'approval-1',
|
|
||||||
expiresAt: '2026-07-12T00:05:00.000Z',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { ChatGateway } from './chat.gateway.js';
|
|
||||||
|
|
||||||
const CONVERSATION_ID = 'conversation-1';
|
|
||||||
const CANARY = 'sk_canary12345678';
|
|
||||||
|
|
||||||
type GatewayInternals = {
|
|
||||||
clientSessions: Map<string, unknown>;
|
|
||||||
relayEvent(client: unknown, conversationId: string, event: unknown): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildGateway() {
|
|
||||||
const brain = {
|
|
||||||
conversations: {
|
|
||||||
addMessage: vi.fn().mockResolvedValue(undefined),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const agentService = {
|
|
||||||
getSession: vi.fn().mockReturnValue(undefined),
|
|
||||||
};
|
|
||||||
const gateway = new ChatGateway(
|
|
||||||
agentService as never,
|
|
||||||
{} as never,
|
|
||||||
brain as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { gateway: gateway as unknown as GatewayInternals, brain };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ChatGateway redaction boundary', (): void => {
|
|
||||||
it('redacts a secret split across assistant deltas before egress and persistence', (): void => {
|
|
||||||
const { gateway } = buildGateway();
|
|
||||||
const client = {
|
|
||||||
connected: true,
|
|
||||||
id: 'client-1',
|
|
||||||
data: { user: { id: 'user-1' } },
|
|
||||||
emit: vi.fn(),
|
|
||||||
};
|
|
||||||
const session = {
|
|
||||||
conversationId: CONVERSATION_ID,
|
|
||||||
cleanup: vi.fn(),
|
|
||||||
assistantText: '',
|
|
||||||
toolCalls: [],
|
|
||||||
pendingToolCalls: new Map(),
|
|
||||||
scope: { userId: 'user-1', tenantId: 'tenant-1' },
|
|
||||||
};
|
|
||||||
gateway.clientSessions.set(client.id, session);
|
|
||||||
|
|
||||||
gateway.relayEvent(client, CONVERSATION_ID, {
|
|
||||||
type: 'message_update',
|
|
||||||
assistantMessageEvent: { type: 'text_delta', delta: 'sk_canary' },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('sk_canary');
|
|
||||||
|
|
||||||
gateway.relayEvent(client, CONVERSATION_ID, {
|
|
||||||
type: 'message_update',
|
|
||||||
assistantMessageEvent: { type: 'text_delta', delta: '12345678 ' },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(client.emit).toHaveBeenCalledWith('agent:text', {
|
|
||||||
conversationId: CONVERSATION_ID,
|
|
||||||
text: '[REDACTED_SECRET] ',
|
|
||||||
});
|
|
||||||
expect(session.assistantText).toBe(`${CANARY} `);
|
|
||||||
expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retains a split secret label until its value can be redacted', (): void => {
|
|
||||||
const { gateway } = buildGateway();
|
|
||||||
const client = {
|
|
||||||
connected: true,
|
|
||||||
id: 'client-1',
|
|
||||||
data: { user: { id: 'user-1' } },
|
|
||||||
emit: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
gateway.relayEvent(client, CONVERSATION_ID, {
|
|
||||||
type: 'message_update',
|
|
||||||
assistantMessageEvent: { type: 'text_delta', delta: 'token ' },
|
|
||||||
});
|
|
||||||
gateway.relayEvent(client, CONVERSATION_ID, {
|
|
||||||
type: 'message_update',
|
|
||||||
assistantMessageEvent: { type: 'text_delta', delta: '=canaryvalue123 ' },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(client.emit).toHaveBeenCalledWith('agent:text', {
|
|
||||||
conversationId: CONVERSATION_ID,
|
|
||||||
text: '[REDACTED_SECRET] ',
|
|
||||||
});
|
|
||||||
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canaryvalue123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('holds a streamed private key until it can be redacted', (): void => {
|
|
||||||
const { gateway } = buildGateway();
|
|
||||||
const client = {
|
|
||||||
connected: true,
|
|
||||||
id: 'client-1',
|
|
||||||
data: { user: { id: 'user-1' } },
|
|
||||||
emit: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
gateway.relayEvent(client, CONVERSATION_ID, {
|
|
||||||
type: 'message_update',
|
|
||||||
assistantMessageEvent: { type: 'text_delta', delta: '-----BEGIN PRIVATE KEY-----\ncanary' },
|
|
||||||
});
|
|
||||||
gateway.relayEvent(client, CONVERSATION_ID, {
|
|
||||||
type: 'message_update',
|
|
||||||
assistantMessageEvent: { type: 'text_delta', delta: '\n-----END PRIVATE KEY-----' },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(client.emit).toHaveBeenCalledWith('agent:text', {
|
|
||||||
conversationId: CONVERSATION_ID,
|
|
||||||
text: '[REDACTED_SECRET]',
|
|
||||||
});
|
|
||||||
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canary');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('drops an oversized unterminated stream fragment rather than retaining it', (): void => {
|
|
||||||
const { gateway } = buildGateway();
|
|
||||||
const client = {
|
|
||||||
connected: true,
|
|
||||||
id: 'client-1',
|
|
||||||
data: { user: { id: 'user-1' } },
|
|
||||||
emit: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
gateway.relayEvent(client, CONVERSATION_ID, {
|
|
||||||
type: 'message_update',
|
|
||||||
assistantMessageEvent: { type: 'text_delta', delta: 'x'.repeat(8_193) },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(client.emit).toHaveBeenCalledWith('agent:text', {
|
|
||||||
conversationId: CONVERSATION_ID,
|
|
||||||
text: '[REDACTED_STREAM_OVERFLOW]',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('persists only redacted assistant content with classifications', (): void => {
|
|
||||||
const { gateway, brain } = buildGateway();
|
|
||||||
const client = {
|
|
||||||
connected: true,
|
|
||||||
id: 'client-1',
|
|
||||||
data: { user: { id: 'user-1' } },
|
|
||||||
emit: vi.fn(),
|
|
||||||
};
|
|
||||||
gateway.clientSessions.set(client.id, {
|
|
||||||
conversationId: CONVERSATION_ID,
|
|
||||||
cleanup: vi.fn(),
|
|
||||||
assistantText: CANARY,
|
|
||||||
toolCalls: [],
|
|
||||||
pendingToolCalls: new Map(),
|
|
||||||
scope: { userId: 'user-1', tenantId: 'tenant-1' },
|
|
||||||
});
|
|
||||||
|
|
||||||
gateway.relayEvent(client, CONVERSATION_ID, { type: 'agent_end' });
|
|
||||||
|
|
||||||
expect(brain.conversations.addMessage).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
content: '[REDACTED_SECRET]',
|
|
||||||
metadata: expect.objectContaining({ classifications: ['secret'] }),
|
|
||||||
}),
|
|
||||||
'user-1',
|
|
||||||
);
|
|
||||||
expect(JSON.stringify(brain.conversations.addMessage.mock.calls)).not.toContain(CANARY);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { Inject, Logger } from '@nestjs/common';
|
||||||
import { Inject, Logger, Optional } from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
WebSocketGateway,
|
WebSocketGateway,
|
||||||
WebSocketServer,
|
WebSocketServer,
|
||||||
@@ -12,47 +11,24 @@ import {
|
|||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
import {
|
import type { Auth } from '@mosaic/auth';
|
||||||
verifyDiscordIngressEnvelope,
|
import type { Brain } from '@mosaic/brain';
|
||||||
parseDiscordInteractionBindings,
|
|
||||||
resolveDiscordInteractionActorId,
|
|
||||||
resolveDiscordInteractionBinding,
|
|
||||||
type DiscordIngressEnvelope,
|
|
||||||
type DiscordIngressPayload,
|
|
||||||
} from '@mosaicstack/discord-plugin';
|
|
||||||
import type { Auth } from '@mosaicstack/auth';
|
|
||||||
import type { Brain } from '@mosaicstack/brain';
|
|
||||||
import { redactSensitiveContent } from '@mosaicstack/log';
|
|
||||||
import type {
|
import type {
|
||||||
SetThinkingPayload,
|
SetThinkingPayload,
|
||||||
SlashCommandApprovalResultPayload,
|
|
||||||
SlashCommandPayload,
|
SlashCommandPayload,
|
||||||
SystemReloadPayload,
|
SystemReloadPayload,
|
||||||
RoutingDecisionInfo,
|
RoutingDecisionInfo,
|
||||||
AbortPayload,
|
AbortPayload,
|
||||||
} from '@mosaicstack/types';
|
} from '@mosaic/types';
|
||||||
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
||||||
import {
|
|
||||||
RUNTIME_PROVIDER_AUDIT_SINK,
|
|
||||||
RuntimeProviderService,
|
|
||||||
type RuntimeAuditSink,
|
|
||||||
} from '../agent/runtime-provider-registry.service.js';
|
|
||||||
import { DurableSessionService } from '../agent/durable-session.service.js';
|
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
import {
|
|
||||||
scopeFromUser,
|
|
||||||
type ActorTenantScope,
|
|
||||||
type AuthenticatedUserLike,
|
|
||||||
} from '../auth/session-scope.js';
|
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||||
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
|
||||||
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
|
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||||
import { validateDiscordServiceToken, validateSocketSession } from './chat.gateway-auth.js';
|
import { validateSocketSession } from './chat.gateway-auth.js';
|
||||||
import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js';
|
|
||||||
|
|
||||||
/** Per-client state tracking streaming accumulation for persistence. */
|
/** Per-client state tracking streaming accumulation for persistence. */
|
||||||
interface ClientSession {
|
interface ClientSession {
|
||||||
@@ -64,8 +40,6 @@ interface ClientSession {
|
|||||||
toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>;
|
toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>;
|
||||||
/** Tool calls in-flight (started but not ended yet). */
|
/** Tool calls in-flight (started but not ended yet). */
|
||||||
pendingToolCalls: Map<string, { toolName: string; args: unknown }>;
|
pendingToolCalls: Map<string, { toolName: string; args: unknown }>;
|
||||||
/** Server-derived owner/tenant scope for this socket's conversation attachment. */
|
|
||||||
scope: ActorTenantScope;
|
|
||||||
/** Last routing decision made for this session (M4-008) */
|
/** Last routing decision made for this session (M4-008) */
|
||||||
lastRoutingDecision?: RoutingDecisionInfo;
|
lastRoutingDecision?: RoutingDecisionInfo;
|
||||||
}
|
}
|
||||||
@@ -75,38 +49,6 @@ interface ClientSession {
|
|||||||
* Keyed by conversationId, value is the model name to use.
|
* Keyed by conversationId, value is the model name to use.
|
||||||
*/
|
*/
|
||||||
const modelOverrides = new Map<string, string>();
|
const modelOverrides = new Map<string, string>();
|
||||||
const MAX_REDACTION_BUFFER_LENGTH = 8_192;
|
|
||||||
|
|
||||||
function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope {
|
|
||||||
if (typeof value !== 'object' || value === null) return false;
|
|
||||||
const envelope = value as { payload?: unknown; signature?: unknown };
|
|
||||||
if (
|
|
||||||
typeof envelope.signature !== 'string' ||
|
|
||||||
typeof envelope.payload !== 'object' ||
|
|
||||||
envelope.payload === null
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const payload = envelope.payload as Record<string, unknown>;
|
|
||||||
return [
|
|
||||||
payload['correlationId'],
|
|
||||||
payload['messageId'],
|
|
||||||
payload['guildId'],
|
|
||||||
payload['channelId'],
|
|
||||||
payload['userId'],
|
|
||||||
payload['conversationId'],
|
|
||||||
payload['content'],
|
|
||||||
].every((field: unknown): boolean => typeof field === 'string');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto {
|
|
||||||
if (typeof value !== 'object' || value === null) return false;
|
|
||||||
const payload = value as { content?: unknown; conversationId?: unknown };
|
|
||||||
return (
|
|
||||||
typeof payload.content === 'string' &&
|
|
||||||
(payload.conversationId === undefined || typeof payload.conversationId === 'string')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
cors: {
|
cors: {
|
||||||
@@ -120,11 +62,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
|
|
||||||
private readonly logger = new Logger(ChatGateway.name);
|
private readonly logger = new Logger(ChatGateway.name);
|
||||||
private readonly clientSessions = new Map<string, ClientSession>();
|
private readonly clientSessions = new Map<string, ClientSession>();
|
||||||
/** Raw stream fragments are kept in memory only until they are safe to redact and emit. */
|
|
||||||
private readonly textEgressBuffers = new Map<string, string>();
|
|
||||||
private readonly thinkingEgressBuffers = new Map<string, string>();
|
|
||||||
private readonly overflowedEgress = new Set<string>();
|
|
||||||
private readonly discordReplayProtector = new DiscordReplayProtector();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@@ -133,18 +70,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||||
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
||||||
@Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService,
|
@Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService,
|
||||||
@Optional()
|
|
||||||
@Inject(CommandAuthorizationService)
|
|
||||||
private readonly commandAuthorization: CommandAuthorizationService | null = null,
|
|
||||||
@Optional()
|
|
||||||
@Inject(RuntimeProviderService)
|
|
||||||
private readonly runtimeRegistry: RuntimeProviderService | null = null,
|
|
||||||
@Optional()
|
|
||||||
@Inject(DurableSessionService)
|
|
||||||
private readonly durableSessions: DurableSessionService | null = null,
|
|
||||||
@Optional()
|
|
||||||
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
|
||||||
private readonly runtimeAudit: RuntimeAuditSink | null = null,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
afterInit(): void {
|
afterInit(): void {
|
||||||
@@ -152,13 +77,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleConnection(client: Socket): Promise<void> {
|
async handleConnection(client: Socket): Promise<void> {
|
||||||
const serviceToken = client.handshake.auth['discordServiceToken'];
|
|
||||||
if (validateDiscordServiceToken(serviceToken, process.env['DISCORD_SERVICE_TOKEN'])) {
|
|
||||||
client.data.discordService = true;
|
|
||||||
this.logger.log(`Authenticated Discord service connected: ${client.id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await validateSocketSession(client.handshake.headers, this.auth);
|
const session = await validateSocketSession(client.handshake.headers, this.auth);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`);
|
this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`);
|
||||||
@@ -169,6 +87,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
client.data.user = session.user;
|
client.data.user = session.user;
|
||||||
client.data.session = session.session;
|
client.data.session = session.session;
|
||||||
this.logger.log(`Client connected: ${client.id}`);
|
this.logger.log(`Client connected: ${client.id}`);
|
||||||
|
|
||||||
|
// Broadcast command manifest to the newly connected client
|
||||||
client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() });
|
client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,84 +97,25 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
const session = this.clientSessions.get(client.id);
|
const session = this.clientSessions.get(client.id);
|
||||||
if (session) {
|
if (session) {
|
||||||
session.cleanup();
|
session.cleanup();
|
||||||
this.agentService.removeChannel(
|
this.agentService.removeChannel(session.conversationId, `websocket:${client.id}`);
|
||||||
session.conversationId,
|
|
||||||
`websocket:${client.id}`,
|
|
||||||
session.scope,
|
|
||||||
);
|
|
||||||
this.clientSessions.delete(client.id);
|
this.clientSessions.delete(client.id);
|
||||||
}
|
}
|
||||||
this.textEgressBuffers.delete(client.id);
|
|
||||||
this.thinkingEgressBuffers.delete(client.id);
|
|
||||||
this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
|
|
||||||
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking'));
|
|
||||||
}
|
|
||||||
|
|
||||||
private getClientScope(client: Socket): ActorTenantScope | null {
|
|
||||||
const user = client.data.user as AuthenticatedUserLike | undefined;
|
|
||||||
if (!user?.id) return null;
|
|
||||||
return scopeFromUser(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
private modelOverrideKey(conversationId: string, scope: ActorTenantScope): string {
|
|
||||||
return `${scope.tenantId}:${scope.userId}:${conversationId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private scopesEqual(a: ActorTenantScope, b: ActorTenantScope): boolean {
|
|
||||||
return a.userId === b.userId && a.tenantId === b.tenantId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('message')
|
@SubscribeMessage('message')
|
||||||
async handleMessage(
|
async handleMessage(
|
||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() rawData: unknown,
|
@MessageBody() data: ChatSocketMessageDto,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let discordIngress: DiscordIngressPayload | null = null;
|
|
||||||
let data: ChatSocketMessageDto;
|
|
||||||
if (client.data.discordService) {
|
|
||||||
if (!isDiscordIngressEnvelope(rawData)) {
|
|
||||||
this.logger.warn(`Rejected malformed Discord ingress from ${client.id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
discordIngress = this.resolveDiscordIngress(client, rawData);
|
|
||||||
if (!discordIngress) return;
|
|
||||||
data = { conversationId: discordIngress.conversationId, content: discordIngress.content };
|
|
||||||
} else {
|
|
||||||
if (!isChatSocketMessage(rawData)) {
|
|
||||||
this.logger.warn(`Rejected malformed chat message from ${client.id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
data = rawData;
|
|
||||||
}
|
|
||||||
const conversationId = data.conversationId ?? uuid();
|
const conversationId = data.conversationId ?? uuid();
|
||||||
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
|
const userId = (client.data.user as { id: string } | undefined)?.id;
|
||||||
if (discordIngress && !discordServiceUserId) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Rejected Discord ingress without configured service owner from ${client.id}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const scope = discordIngress
|
|
||||||
? {
|
|
||||||
userId: discordServiceUserId!,
|
|
||||||
tenantId: process.env['DISCORD_SERVICE_TENANT_ID'] ?? discordServiceUserId!,
|
|
||||||
}
|
|
||||||
: this.getClientScope(client);
|
|
||||||
if (!scope) {
|
|
||||||
client.emit('error', { conversationId, error: 'Authenticated user scope is required.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const userId = scope.userId;
|
|
||||||
const correlationId = discordIngress?.correlationId;
|
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Message from ${client.id} in conversation ${conversationId}`);
|
||||||
`Message from ${client.id} in conversation ${conversationId}${correlationId ? ` correlation=${correlationId}` : ''}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ensure agent session exists for this conversation
|
// Ensure agent session exists for this conversation
|
||||||
let sessionRoutingDecision: RoutingDecisionInfo | undefined;
|
let sessionRoutingDecision: RoutingDecisionInfo | undefined;
|
||||||
try {
|
try {
|
||||||
let agentSession = this.agentService.getSession(conversationId, scope);
|
let agentSession = this.agentService.getSession(conversationId);
|
||||||
if (!agentSession) {
|
if (!agentSession) {
|
||||||
// When resuming an existing conversation, load prior messages to inject as context (M1-004)
|
// When resuming an existing conversation, load prior messages to inject as context (M1-004)
|
||||||
const conversationHistory = await this.loadConversationHistory(conversationId, userId);
|
const conversationHistory = await this.loadConversationHistory(conversationId, userId);
|
||||||
@@ -274,7 +135,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
let resolvedProvider = data.provider;
|
let resolvedProvider = data.provider;
|
||||||
let resolvedModelId = data.modelId;
|
let resolvedModelId = data.modelId;
|
||||||
|
|
||||||
const modelOverride = modelOverrides.get(this.modelOverrideKey(conversationId, scope));
|
const modelOverride = modelOverrides.get(conversationId);
|
||||||
if (modelOverride) {
|
if (modelOverride) {
|
||||||
// /model override bypasses routing engine (M4-007)
|
// /model override bypasses routing engine (M4-007)
|
||||||
resolvedModelId = modelOverride;
|
resolvedModelId = modelOverride;
|
||||||
@@ -311,7 +172,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
modelId: resolvedModelId,
|
modelId: resolvedModelId,
|
||||||
agentConfigId: data.agentId,
|
agentConfigId: data.agentId,
|
||||||
userId,
|
userId,
|
||||||
tenantId: scope.tenantId,
|
|
||||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -350,17 +210,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
{
|
{
|
||||||
conversationId,
|
conversationId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: redactSensitiveContent(data.content).content,
|
content: data.content,
|
||||||
metadata: {
|
metadata: {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
...(correlationId
|
|
||||||
? {
|
|
||||||
correlationId,
|
|
||||||
discordMessageId: discordIngress?.messageId,
|
|
||||||
discordUserId: discordIngress?.userId,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
classifications: redactSensitiveContent(data.content).classifications,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
@@ -380,13 +232,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to agent events and relay to client
|
// Subscribe to agent events and relay to client
|
||||||
const cleanup = this.agentService.onEvent(
|
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
||||||
conversationId,
|
|
||||||
(event: AgentSessionEvent) => {
|
|
||||||
this.relayEvent(client, conversationId, event);
|
this.relayEvent(client, conversationId, event);
|
||||||
},
|
});
|
||||||
scope,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Preserve routing decision from the existing client session if we didn't get a new one
|
// Preserve routing decision from the existing client session if we didn't get a new one
|
||||||
const prevClientSession = this.clientSessions.get(client.id);
|
const prevClientSession = this.clientSessions.get(client.id);
|
||||||
@@ -398,17 +246,16 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
assistantText: '',
|
assistantText: '',
|
||||||
toolCalls: [],
|
toolCalls: [],
|
||||||
pendingToolCalls: new Map(),
|
pendingToolCalls: new Map(),
|
||||||
scope,
|
|
||||||
lastRoutingDecision: routingDecisionToStore,
|
lastRoutingDecision: routingDecisionToStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track channel connection
|
// Track channel connection
|
||||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`, scope);
|
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
||||||
|
|
||||||
// Send session info so the client knows the model/provider (M4-008: include routing decision)
|
// Send session info so the client knows the model/provider (M4-008: include routing decision)
|
||||||
// Include agentName when a named agent config is active (M5-001)
|
// Include agentName when a named agent config is active (M5-001)
|
||||||
{
|
{
|
||||||
const agentSession = this.agentService.getSession(conversationId, scope);
|
const agentSession = this.agentService.getSession(conversationId);
|
||||||
if (agentSession) {
|
if (agentSession) {
|
||||||
const piSession = agentSession.piSession;
|
const piSession = agentSession.piSession;
|
||||||
client.emit('session:info', {
|
client.emit('session:info', {
|
||||||
@@ -424,21 +271,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send acknowledgment
|
// Send acknowledgment
|
||||||
client.emit('message:ack', {
|
client.emit('message:ack', { conversationId, messageId: uuid() });
|
||||||
conversationId,
|
|
||||||
messageId: uuid(),
|
|
||||||
...(correlationId
|
|
||||||
? {
|
|
||||||
correlationId,
|
|
||||||
discordMessageId: discordIngress?.messageId,
|
|
||||||
discordUserId: discordIngress?.userId,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Dispatch to agent
|
// Dispatch to agent
|
||||||
try {
|
try {
|
||||||
await this.agentService.prompt(conversationId, data.content, scope);
|
await this.agentService.prompt(conversationId, data.content);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
|
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
|
||||||
@@ -456,16 +293,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() data: SetThinkingPayload,
|
@MessageBody() data: SetThinkingPayload,
|
||||||
): void {
|
): void {
|
||||||
const scope = this.getClientScope(client);
|
const session = this.agentService.getSession(data.conversationId);
|
||||||
if (!scope) {
|
|
||||||
client.emit('error', {
|
|
||||||
conversationId: data.conversationId,
|
|
||||||
error: 'Authenticated user scope is required.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = this.agentService.getSession(data.conversationId, scope);
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
client.emit('error', {
|
client.emit('error', {
|
||||||
conversationId: data.conversationId,
|
conversationId: data.conversationId,
|
||||||
@@ -506,13 +334,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
const conversationId = data.conversationId;
|
const conversationId = data.conversationId;
|
||||||
this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`);
|
this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`);
|
||||||
|
|
||||||
const scope = this.getClientScope(client);
|
const session = this.agentService.getSession(conversationId);
|
||||||
if (!scope) {
|
|
||||||
client.emit('error', { conversationId, error: 'Authenticated user scope is required.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = this.agentService.getSession(conversationId, scope);
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
client.emit('error', {
|
client.emit('error', {
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -541,45 +363,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() payload: SlashCommandPayload,
|
@MessageBody() payload: SlashCommandPayload,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const scope = this.getClientScope(client);
|
const userId = (client.data.user as { id: string } | undefined)?.id ?? 'unknown';
|
||||||
if (!scope) {
|
const result = await this.commandExecutor.execute(payload, userId);
|
||||||
client.emit('command:result', {
|
|
||||||
command: payload.command,
|
|
||||||
conversationId: payload.conversationId,
|
|
||||||
success: false,
|
|
||||||
message: 'Authenticated user scope is required.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.commandExecutor.execute(payload, scope);
|
|
||||||
client.emit('command:result', result);
|
client.emit('command:result', result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('command:approve')
|
|
||||||
async handleCommandApproval(
|
|
||||||
@ConnectedSocket() client: Socket,
|
|
||||||
@MessageBody() payload: SlashCommandPayload,
|
|
||||||
): Promise<void> {
|
|
||||||
const scope = this.getClientScope(client);
|
|
||||||
const approval = scope ? await this.commandExecutor.createApproval(payload, scope) : null;
|
|
||||||
const result: SlashCommandApprovalResultPayload = approval
|
|
||||||
? {
|
|
||||||
command: payload.command,
|
|
||||||
conversationId: payload.conversationId,
|
|
||||||
success: true,
|
|
||||||
approvalId: approval.approvalId,
|
|
||||||
expiresAt: approval.expiresAt,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
command: payload.command,
|
|
||||||
conversationId: payload.conversationId,
|
|
||||||
success: false,
|
|
||||||
message: 'Not authorized to approve this command.',
|
|
||||||
};
|
|
||||||
client.emit('command:approval', result);
|
|
||||||
}
|
|
||||||
|
|
||||||
broadcastReload(payload: SystemReloadPayload): void {
|
broadcastReload(payload: SystemReloadPayload): void {
|
||||||
this.server.emit('system:reload', payload);
|
this.server.emit('system:reload', payload);
|
||||||
this.logger.log('Broadcasted system:reload to all connected clients');
|
this.logger.log('Broadcasted system:reload to all connected clients');
|
||||||
@@ -592,23 +380,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
* M5-005: Emits session:info to clients subscribed to this conversation when a model is set.
|
* M5-005: Emits session:info to clients subscribed to this conversation when a model is set.
|
||||||
* M5-007: Records a model switch in session metrics.
|
* M5-007: Records a model switch in session metrics.
|
||||||
*/
|
*/
|
||||||
setModelOverride(
|
setModelOverride(conversationId: string, modelName: string | null): void {
|
||||||
conversationId: string,
|
|
||||||
modelName: string | null,
|
|
||||||
scope: ActorTenantScope,
|
|
||||||
): void {
|
|
||||||
const key = this.modelOverrideKey(conversationId, scope);
|
|
||||||
if (modelName) {
|
if (modelName) {
|
||||||
modelOverrides.set(key, modelName);
|
modelOverrides.set(conversationId, modelName);
|
||||||
this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`);
|
this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`);
|
||||||
|
|
||||||
// M5-002: Update the live session's modelId so session:info reflects the new model immediately
|
// M5-002: Update the live session's modelId so session:info reflects the new model immediately
|
||||||
this.agentService.updateSessionModel(conversationId, modelName, scope);
|
this.agentService.updateSessionModel(conversationId, modelName);
|
||||||
|
|
||||||
// M5-005: Broadcast session:info to all clients subscribed to this conversation
|
// M5-005: Broadcast session:info to all clients subscribed to this conversation
|
||||||
this.broadcastSessionInfo(conversationId, scope);
|
this.broadcastSessionInfo(conversationId);
|
||||||
} else {
|
} else {
|
||||||
modelOverrides.delete(key);
|
modelOverrides.delete(conversationId);
|
||||||
this.logger.log(`Model override cleared: conversation=${conversationId}`);
|
this.logger.log(`Model override cleared: conversation=${conversationId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -616,8 +399,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
/**
|
/**
|
||||||
* Return the active model override for a conversation, or undefined if none.
|
* Return the active model override for a conversation, or undefined if none.
|
||||||
*/
|
*/
|
||||||
getModelOverride(conversationId: string, scope: ActorTenantScope): string | undefined {
|
getModelOverride(conversationId: string): string | undefined {
|
||||||
return modelOverrides.get(this.modelOverrideKey(conversationId, scope));
|
return modelOverrides.get(conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -626,10 +409,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
*/
|
*/
|
||||||
broadcastSessionInfo(
|
broadcastSessionInfo(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
scope: ActorTenantScope,
|
|
||||||
extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo },
|
extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo },
|
||||||
): void {
|
): void {
|
||||||
const agentSession = this.agentService.getSession(conversationId, scope);
|
const agentSession = this.agentService.getSession(conversationId);
|
||||||
if (!agentSession) return;
|
if (!agentSession) return;
|
||||||
|
|
||||||
const piSession = agentSession.piSession;
|
const piSession = agentSession.piSession;
|
||||||
@@ -646,7 +428,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
|
|
||||||
// Emit to all clients currently subscribed to this conversation
|
// Emit to all clients currently subscribed to this conversation
|
||||||
for (const [clientId, session] of this.clientSessions) {
|
for (const [clientId, session] of this.clientSessions) {
|
||||||
if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) {
|
if (session.conversationId === conversationId) {
|
||||||
const socket = this.server.sockets.sockets.get(clientId);
|
const socket = this.server.sockets.sockets.get(clientId);
|
||||||
if (socket?.connected) {
|
if (socket?.connected) {
|
||||||
socket.emit('session:info', payload);
|
socket.emit('session:info', payload);
|
||||||
@@ -660,233 +442,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
* Creates it if absent — safe to call concurrently since a duplicate insert
|
* Creates it if absent — safe to call concurrently since a duplicate insert
|
||||||
* would fail on the PK constraint and be caught here.
|
* would fail on the PK constraint and be caught here.
|
||||||
*/
|
*/
|
||||||
@SubscribeMessage('discord:approve')
|
|
||||||
async handleDiscordApproval(
|
|
||||||
@ConnectedSocket() client: Socket,
|
|
||||||
@MessageBody() envelope: DiscordIngressEnvelope,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!client.data.discordService) return;
|
|
||||||
const ingress = this.resolveDiscordIngress(client, envelope, 'approve');
|
|
||||||
const isApprovalCommand = /^\/approve\s*$/i.test(ingress?.content ?? '');
|
|
||||||
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
|
|
||||||
if (
|
|
||||||
!ingress ||
|
|
||||||
!isApprovalCommand ||
|
|
||||||
!tenantId ||
|
|
||||||
!this.commandAuthorization ||
|
|
||||||
!this.durableSessions
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
const binding = resolveDiscordInteractionBinding(
|
|
||||||
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
|
||||||
ingress.guildId,
|
|
||||||
ingress.channelId,
|
|
||||||
ingress.userId,
|
|
||||||
'approve',
|
|
||||||
);
|
|
||||||
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
|
||||||
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
|
|
||||||
if (!actorId || !agentName || binding.instanceId !== agentName) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Rejected Discord approval without a matching runtime agent from ${client.id}`,
|
|
||||||
);
|
|
||||||
client.emit('discord:approval', {
|
|
||||||
correlationId: ingress.correlationId,
|
|
||||||
success: false,
|
|
||||||
approvalId: undefined,
|
|
||||||
expiresAt: undefined,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let snapshot;
|
|
||||||
try {
|
|
||||||
snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, {
|
|
||||||
actorScope: { userId: actorId, tenantId },
|
|
||||||
channelId: ingress.channelId,
|
|
||||||
correlationId: ingress.correlationId,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
client.emit('discord:approval', {
|
|
||||||
correlationId: ingress.correlationId,
|
|
||||||
success: false,
|
|
||||||
approvalId: undefined,
|
|
||||||
expiresAt: undefined,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (snapshot.identity.agentName !== agentName) {
|
|
||||||
client.emit('discord:approval', {
|
|
||||||
correlationId: ingress.correlationId,
|
|
||||||
success: false,
|
|
||||||
approvalId: undefined,
|
|
||||||
expiresAt: undefined,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const approval = await this.commandAuthorization.createRuntimeTerminationApproval({
|
|
||||||
providerId: snapshot.identity.providerId,
|
|
||||||
sessionId: snapshot.identity.runtimeSessionId,
|
|
||||||
actorId,
|
|
||||||
tenantId,
|
|
||||||
channelId: ingress.channelId,
|
|
||||||
correlationId: this.discordRuntimeActionCorrelation(
|
|
||||||
binding.instanceId,
|
|
||||||
ingress,
|
|
||||||
snapshot.identity.providerId,
|
|
||||||
snapshot.identity.runtimeSessionId,
|
|
||||||
),
|
|
||||||
agentName,
|
|
||||||
});
|
|
||||||
if (!approval) {
|
|
||||||
await this.runtimeAudit?.record({
|
|
||||||
providerId: snapshot.identity.providerId,
|
|
||||||
operation: 'session.terminate',
|
|
||||||
outcome: 'denied',
|
|
||||||
actorId,
|
|
||||||
tenantId,
|
|
||||||
channelId: ingress.channelId,
|
|
||||||
correlationId: this.discordRuntimeActionCorrelation(
|
|
||||||
binding.instanceId,
|
|
||||||
ingress,
|
|
||||||
snapshot.identity.providerId,
|
|
||||||
snapshot.identity.runtimeSessionId,
|
|
||||||
),
|
|
||||||
resourceId: snapshot.identity.runtimeSessionId,
|
|
||||||
errorCode: 'policy_denied',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
client.emit('discord:approval', {
|
|
||||||
correlationId: ingress.correlationId,
|
|
||||||
success: approval !== null,
|
|
||||||
approvalId: approval?.approvalId,
|
|
||||||
expiresAt: approval?.expiresAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@SubscribeMessage('discord:stop')
|
|
||||||
async handleDiscordStop(
|
|
||||||
@ConnectedSocket() client: Socket,
|
|
||||||
@MessageBody() envelope: DiscordIngressEnvelope,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!client.data.discordService) return;
|
|
||||||
const ingress = this.resolveDiscordIngress(client, envelope, 'stop');
|
|
||||||
const approvalRef = /^\/stop\s+([^\s]+)$/i.exec(ingress?.content ?? '')?.[1];
|
|
||||||
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
|
|
||||||
if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions)
|
|
||||||
return;
|
|
||||||
const binding = resolveDiscordInteractionBinding(
|
|
||||||
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
|
||||||
ingress.guildId,
|
|
||||||
ingress.channelId,
|
|
||||||
ingress.userId,
|
|
||||||
'stop',
|
|
||||||
);
|
|
||||||
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
|
||||||
if (!actorId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const context = {
|
|
||||||
actorScope: { userId: actorId, tenantId },
|
|
||||||
channelId: ingress.channelId,
|
|
||||||
correlationId: ingress.correlationId,
|
|
||||||
};
|
|
||||||
const snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, context);
|
|
||||||
if (snapshot.identity.agentName !== binding.instanceId) throw new Error('agent mismatch');
|
|
||||||
// RuntimeProviderService consumes the durable approval exactly once using the
|
|
||||||
// provisioned approving-admin identity, never the Discord service account.
|
|
||||||
await this.runtimeRegistry.terminate(
|
|
||||||
snapshot.identity.providerId,
|
|
||||||
snapshot.identity.runtimeSessionId,
|
|
||||||
approvalRef,
|
|
||||||
{
|
|
||||||
...context,
|
|
||||||
correlationId: this.discordRuntimeActionCorrelation(
|
|
||||||
binding.instanceId,
|
|
||||||
ingress,
|
|
||||||
snapshot.identity.providerId,
|
|
||||||
snapshot.identity.runtimeSessionId,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
client.emit('discord:stop', { correlationId: ingress.correlationId, success: true });
|
|
||||||
} catch {
|
|
||||||
client.emit('discord:stop', { correlationId: ingress.correlationId, success: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Correlates the immutable termination target rather than either Discord message.
|
|
||||||
* Approval and stop are distinct ingress events, but must consume the same seven-field action.
|
|
||||||
*/
|
|
||||||
private discordRuntimeActionCorrelation(
|
|
||||||
instanceId: string,
|
|
||||||
ingress: DiscordIngressPayload,
|
|
||||||
providerId: string,
|
|
||||||
sessionId: string,
|
|
||||||
): string {
|
|
||||||
const target = [
|
|
||||||
instanceId,
|
|
||||||
ingress.guildId,
|
|
||||||
ingress.channelId,
|
|
||||||
ingress.conversationId,
|
|
||||||
providerId,
|
|
||||||
sessionId,
|
|
||||||
];
|
|
||||||
return `discord-action:v1:${createHash('sha256').update(JSON.stringify(target)).digest('hex')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveDiscordIngress(
|
|
||||||
client: Socket,
|
|
||||||
envelope: DiscordIngressEnvelope,
|
|
||||||
operation: 'send' | 'approve' | 'stop' = 'send',
|
|
||||||
): DiscordIngressPayload | null {
|
|
||||||
const payload = verifyDiscordIngressEnvelope(
|
|
||||||
envelope,
|
|
||||||
process.env['DISCORD_SERVICE_TOKEN'] ?? '',
|
|
||||||
{
|
|
||||||
guildIds: this.readDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
|
|
||||||
channelIds: this.readDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
|
|
||||||
userIds: this.readDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!payload) {
|
|
||||||
this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const binding = resolveDiscordInteractionBinding(
|
|
||||||
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
|
||||||
payload.guildId,
|
|
||||||
payload.channelId,
|
|
||||||
payload.userId,
|
|
||||||
operation,
|
|
||||||
);
|
|
||||||
if (!binding) {
|
|
||||||
this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
this.logger.warn(
|
|
||||||
`Rejected Discord ingress without valid binding configuration from ${client.id}`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!this.discordReplayProtector.claim(payload.messageId)) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readDiscordAllowlist(name: string): string[] {
|
|
||||||
return (process.env[name] ?? '')
|
|
||||||
.split(',')
|
|
||||||
.map((id: string): string => id.trim())
|
|
||||||
.filter((id: string): boolean => id.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureConversation(conversationId: string, userId: string): Promise<void> {
|
private async ensureConversation(conversationId: string, userId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const existing = await this.brain.conversations.findById(conversationId, userId);
|
const existing = await this.brain.conversations.findById(conversationId, userId);
|
||||||
@@ -972,127 +527,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private appendAndFlushRedactedEgress(
|
|
||||||
client: Socket,
|
|
||||||
conversationId: string,
|
|
||||||
eventName: 'agent:text' | 'agent:thinking',
|
|
||||||
buffers: Map<string, string>,
|
|
||||||
delta: string,
|
|
||||||
): void {
|
|
||||||
const key = this.egressKey(client, eventName);
|
|
||||||
if (this.overflowedEgress.has(key)) return;
|
|
||||||
|
|
||||||
const buffered = `${buffers.get(client.id) ?? ''}${delta}`;
|
|
||||||
if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) {
|
|
||||||
buffers.delete(client.id);
|
|
||||||
this.overflowedEgress.add(key);
|
|
||||||
client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
buffers.set(client.id, buffered);
|
|
||||||
this.flushRedactedEgress(client, conversationId, eventName, buffers, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Holds any suffix that could become a secret, email, or phone number after a
|
|
||||||
* later stream chunk. This avoids relying on downstream redaction after data
|
|
||||||
* has already reached the socket.
|
|
||||||
*/
|
|
||||||
private flushRedactedEgress(
|
|
||||||
client: Socket,
|
|
||||||
conversationId: string,
|
|
||||||
eventName: 'agent:text' | 'agent:thinking',
|
|
||||||
buffers: Map<string, string>,
|
|
||||||
final: boolean,
|
|
||||||
): void {
|
|
||||||
const key = this.egressKey(client, eventName);
|
|
||||||
if (this.overflowedEgress.has(key)) {
|
|
||||||
if (final) this.overflowedEgress.delete(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffered = buffers.get(client.id) ?? '';
|
|
||||||
const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered);
|
|
||||||
const released = buffered.slice(0, releaseLength);
|
|
||||||
const pending = buffered.slice(releaseLength);
|
|
||||||
|
|
||||||
if (pending) {
|
|
||||||
buffers.set(client.id, pending);
|
|
||||||
} else {
|
|
||||||
buffers.delete(client.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (released) {
|
|
||||||
client.emit(eventName, {
|
|
||||||
conversationId,
|
|
||||||
text: redactSensitiveContent(released).content,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private safeRedactionPrefixLength(content: string): number {
|
|
||||||
let retainedFrom = content.length;
|
|
||||||
|
|
||||||
// Retain the current token because it may become a split secret or email.
|
|
||||||
const token = /(?:^|\s)(\S*)$/.exec(content);
|
|
||||||
if (token) {
|
|
||||||
const matched = token[0] ?? '';
|
|
||||||
const trailingToken = token[1] ?? '';
|
|
||||||
retainedFrom = token.index + matched.length - trailingToken.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The secret classifier accepts whitespace around ':' and '=', so preserve
|
|
||||||
// a pending label until its value and delimiter are both complete.
|
|
||||||
const pendingSecretLabel =
|
|
||||||
/(?:^|[^A-Za-z0-9_])((?:api[_-]?key|token|password|secret|bearer|authorization)\s*)$/i.exec(
|
|
||||||
content,
|
|
||||||
);
|
|
||||||
if (pendingSecretLabel) {
|
|
||||||
const label = pendingSecretLabel[1] ?? '';
|
|
||||||
retainedFrom = Math.min(
|
|
||||||
retainedFrom,
|
|
||||||
pendingSecretLabel.index + pendingSecretLabel[0].length - label.length,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const secretLabel = /(?:api[_-]?key|token|password|secret|authorization)\s*[:=]\s*$/i.exec(
|
|
||||||
content,
|
|
||||||
);
|
|
||||||
if (secretLabel) {
|
|
||||||
retainedFrom = Math.min(retainedFrom, secretLabel.index);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phone numbers can contain whitespace and punctuation; preserve the full
|
|
||||||
// trailing numeric candidate until a non-phone character establishes a boundary.
|
|
||||||
const phone = /(?:^|[^A-Za-z0-9_])(\+?\d[\d(). -]*)$/.exec(content);
|
|
||||||
if (phone) {
|
|
||||||
const matched = phone[0] ?? '';
|
|
||||||
const trailingPhoneCandidate = phone[1] ?? '';
|
|
||||||
retainedFrom = Math.min(
|
|
||||||
retainedFrom,
|
|
||||||
phone.index + matched.length - trailingPhoneCandidate.length,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const privateKeyStart = content.lastIndexOf('-----BEGIN');
|
|
||||||
if (privateKeyStart >= 0) {
|
|
||||||
const privateKey = content.slice(privateKeyStart);
|
|
||||||
if (/-----END(?: [A-Z]+)* KEY-----/.test(privateKey)) {
|
|
||||||
// Release the complete block in one pass so the full-block classifier can redact it.
|
|
||||||
retainedFrom = content.length;
|
|
||||||
} else {
|
|
||||||
retainedFrom = Math.min(retainedFrom, privateKeyStart);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return retainedFrom;
|
|
||||||
}
|
|
||||||
|
|
||||||
private egressKey(client: Socket, eventName: 'agent:text' | 'agent:thinking'): string {
|
|
||||||
return `${client.id}:${eventName}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
||||||
if (!client.connected) {
|
if (!client.connected) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -1110,20 +544,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
cs.toolCalls = [];
|
cs.toolCalls = [];
|
||||||
cs.pendingToolCalls.clear();
|
cs.pendingToolCalls.clear();
|
||||||
}
|
}
|
||||||
this.textEgressBuffers.set(client.id, '');
|
|
||||||
this.thinkingEgressBuffers.set(client.id, '');
|
|
||||||
this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
|
|
||||||
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking'));
|
|
||||||
client.emit('agent:start', { conversationId });
|
client.emit('agent:start', { conversationId });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'agent_end': {
|
case 'agent_end': {
|
||||||
// Gather usage stats from the Pi session
|
// Gather usage stats from the Pi session
|
||||||
const activeClientSession = this.clientSessions.get(client.id);
|
const agentSession = this.agentService.getSession(conversationId);
|
||||||
const agentSession = activeClientSession
|
|
||||||
? this.agentService.getSession(conversationId, activeClientSession.scope)
|
|
||||||
: undefined;
|
|
||||||
const piSession = agentSession?.piSession;
|
const piSession = agentSession?.piSession;
|
||||||
const stats = piSession?.getSessionStats();
|
const stats = piSession?.getSessionStats();
|
||||||
const contextUsage = piSession?.getContextUsage();
|
const contextUsage = piSession?.getContextUsage();
|
||||||
@@ -1142,20 +569,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
this.flushRedactedEgress(
|
|
||||||
client,
|
|
||||||
conversationId,
|
|
||||||
'agent:text',
|
|
||||||
this.textEgressBuffers,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
this.flushRedactedEgress(
|
|
||||||
client,
|
|
||||||
conversationId,
|
|
||||||
'agent:thinking',
|
|
||||||
this.thinkingEgressBuffers,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
client.emit('agent:end', {
|
client.emit('agent:end', {
|
||||||
conversationId,
|
conversationId,
|
||||||
usage: usagePayload,
|
usage: usagePayload,
|
||||||
@@ -1198,11 +611,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
{
|
{
|
||||||
conversationId,
|
conversationId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: redactSensitiveContent(cs.assistantText).content,
|
content: cs.assistantText,
|
||||||
metadata: {
|
metadata,
|
||||||
...metadata,
|
|
||||||
classifications: redactSensitiveContent(cs.assistantText).classifications,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
)
|
)
|
||||||
@@ -1224,26 +634,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
case 'message_update': {
|
case 'message_update': {
|
||||||
const assistantEvent = event.assistantMessageEvent;
|
const assistantEvent = event.assistantMessageEvent;
|
||||||
if (assistantEvent.type === 'text_delta') {
|
if (assistantEvent.type === 'text_delta') {
|
||||||
// Keep raw stream material in memory only; persist and emit only redacted text.
|
// Accumulate assistant text for persistence
|
||||||
const cs = this.clientSessions.get(client.id);
|
const cs = this.clientSessions.get(client.id);
|
||||||
if (cs) {
|
if (cs) {
|
||||||
cs.assistantText += assistantEvent.delta;
|
cs.assistantText += assistantEvent.delta;
|
||||||
}
|
}
|
||||||
this.appendAndFlushRedactedEgress(
|
client.emit('agent:text', {
|
||||||
client,
|
|
||||||
conversationId,
|
conversationId,
|
||||||
'agent:text',
|
text: assistantEvent.delta,
|
||||||
this.textEgressBuffers,
|
});
|
||||||
assistantEvent.delta,
|
|
||||||
);
|
|
||||||
} else if (assistantEvent.type === 'thinking_delta') {
|
} else if (assistantEvent.type === 'thinking_delta') {
|
||||||
this.appendAndFlushRedactedEgress(
|
client.emit('agent:thinking', {
|
||||||
client,
|
|
||||||
conversationId,
|
conversationId,
|
||||||
'agent:thinking',
|
text: assistantEvent.delta,
|
||||||
this.thinkingEgressBuffers,
|
});
|
||||||
assistantEvent.delta,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
|
|
||||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
|
||||||
|
|
||||||
const adminCommand: CommandDef = {
|
|
||||||
name: 'gc',
|
|
||||||
description: 'GC',
|
|
||||||
aliases: [],
|
|
||||||
scope: 'admin',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
};
|
|
||||||
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
|
||||||
|
|
||||||
function createService(
|
|
||||||
role: string,
|
|
||||||
entries: Map<string, string> = new Map<string, string>(),
|
|
||||||
): CommandAuthorizationService {
|
|
||||||
const db = {
|
|
||||||
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
|
|
||||||
};
|
|
||||||
const redis = {
|
|
||||||
get: async (key: string) => entries.get(key) ?? null,
|
|
||||||
set: async (key: string, value: string) => {
|
|
||||||
entries.set(key, value);
|
|
||||||
},
|
|
||||||
del: async (key: string) => Number(entries.delete(key)),
|
|
||||||
};
|
|
||||||
return new CommandAuthorizationService(db as never, redis);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('CommandAuthorizationService', () => {
|
|
||||||
it('consumes one exact actor-bound approval once', async (): Promise<void> => {
|
|
||||||
const service = createService('admin');
|
|
||||||
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
|
|
||||||
expect(approval).not.toBeNull();
|
|
||||||
expect(
|
|
||||||
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an approval when the structured action is mutated', async (): Promise<void> => {
|
|
||||||
const service = createService('admin');
|
|
||||||
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
|
|
||||||
const mutated = { ...payload, conversationId: 'other-conversation' };
|
|
||||||
expect(approval).not.toBeNull();
|
|
||||||
expect(
|
|
||||||
(await service.authorize(adminCommand, mutated, 'admin-1', approval!.approvalId)).allowed,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies an admin command to a member before approval is considered', async (): Promise<void> => {
|
|
||||||
const service = createService('member');
|
|
||||||
const approval = await service.createApproval(adminCommand, payload, 'member-1');
|
|
||||||
expect(approval).toBeNull();
|
|
||||||
expect(
|
|
||||||
(await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
|
|
||||||
const entries = new Map<string, string>();
|
|
||||||
const action = {
|
|
||||||
providerId: 'fleet',
|
|
||||||
sessionId: 'nova',
|
|
||||||
actorId: 'admin-1',
|
|
||||||
tenantId: 'tenant-1',
|
|
||||||
channelId: 'discord:operator',
|
|
||||||
correlationId: 'correlation-malformed-expiry',
|
|
||||||
agentName: 'Nova',
|
|
||||||
};
|
|
||||||
const service = createService('admin', entries);
|
|
||||||
const approval = await service.createRuntimeTerminationApproval(action);
|
|
||||||
expect(approval).not.toBeNull();
|
|
||||||
const key = `agent:Nova:command-approval:${approval!.approvalId}`;
|
|
||||||
const stored = entries.get(key);
|
|
||||||
expect(stored).toBeDefined();
|
|
||||||
entries.set(key, JSON.stringify({ ...JSON.parse(stored!), expiresAt: 'not-a-date' }));
|
|
||||||
|
|
||||||
expect(await service.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('persists and consumes one exact runtime termination approval across a service restart', async (): Promise<void> => {
|
|
||||||
const entries = new Map<string, string>();
|
|
||||||
const action = {
|
|
||||||
providerId: 'fleet',
|
|
||||||
sessionId: 'nova',
|
|
||||||
actorId: 'admin-1',
|
|
||||||
tenantId: 'tenant-1',
|
|
||||||
channelId: 'discord:operator',
|
|
||||||
correlationId: 'correlation-1',
|
|
||||||
agentName: 'Nova',
|
|
||||||
};
|
|
||||||
const beforeRestart = createService('admin', entries);
|
|
||||||
const approval = await beforeRestart.createRuntimeTerminationApproval(action);
|
|
||||||
|
|
||||||
const afterRestart = createService('admin', entries);
|
|
||||||
expect(
|
|
||||||
await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, {
|
|
||||||
...action,
|
|
||||||
sessionId: 'forged-session',
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
import { createHash, randomUUID } from 'node:crypto';
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import { eq, users as usersTable, type Db } from '@mosaicstack/db';
|
|
||||||
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
|
|
||||||
import { DB } from '../database/database.module.js';
|
|
||||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
|
||||||
|
|
||||||
export type CommandRole = 'admin' | 'member' | 'viewer';
|
|
||||||
|
|
||||||
export interface CommandApproval {
|
|
||||||
approvalId: string;
|
|
||||||
actionDigest: string;
|
|
||||||
actorId: string;
|
|
||||||
command: string;
|
|
||||||
expiresAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Exact immutable binding for a privileged runtime termination. */
|
|
||||||
export interface RuntimeTerminationApprovalAction {
|
|
||||||
providerId: string;
|
|
||||||
sessionId: string;
|
|
||||||
actorId: string;
|
|
||||||
tenantId: string;
|
|
||||||
channelId: string;
|
|
||||||
correlationId: string;
|
|
||||||
/** Provisioned roster identity; isolates approvals between interaction agents. */
|
|
||||||
agentName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeTerminationApproval extends RuntimeTerminationApprovalAction {
|
|
||||||
approvalId: string;
|
|
||||||
actionDigest: string;
|
|
||||||
expiresAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommandAuthorizationResult {
|
|
||||||
allowed: boolean;
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CommandAuthorizationService {
|
|
||||||
constructor(
|
|
||||||
@Inject(DB) private readonly db: Db,
|
|
||||||
@Inject(COMMANDS_REDIS)
|
|
||||||
private readonly redis: {
|
|
||||||
get(key: string): Promise<string | null>;
|
|
||||||
set(key: string, value: string, ...args: string[]): Promise<unknown>;
|
|
||||||
del(key: string): Promise<number>;
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async authorize(
|
|
||||||
command: CommandDef,
|
|
||||||
payload: SlashCommandPayload,
|
|
||||||
actorId: string,
|
|
||||||
approvalId?: string,
|
|
||||||
): Promise<CommandAuthorizationResult> {
|
|
||||||
const role = await this.resolveRole(actorId);
|
|
||||||
if (!role || !this.hasScope(role, command.scope)) {
|
|
||||||
return { allowed: false, reason: 'not authorized for this command scope' };
|
|
||||||
}
|
|
||||||
if (command.scope !== 'admin') return { allowed: true };
|
|
||||||
if (!approvalId) return { allowed: false, reason: 'durable approval is required' };
|
|
||||||
const actionDigest = this.actionDigest(command.name, payload);
|
|
||||||
const approved = await this.consumeApproval(approvalId, actorId, actionDigest);
|
|
||||||
return approved
|
|
||||||
? { allowed: true }
|
|
||||||
: {
|
|
||||||
allowed: false,
|
|
||||||
reason: 'approval is invalid, expired, replayed, or does not match this action',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async createApproval(
|
|
||||||
command: CommandDef,
|
|
||||||
payload: SlashCommandPayload,
|
|
||||||
actorId: string,
|
|
||||||
): Promise<CommandApproval | null> {
|
|
||||||
const role = await this.resolveRole(actorId);
|
|
||||||
if (!role || command.scope !== 'admin' || !this.hasScope(role, command.scope)) return null;
|
|
||||||
|
|
||||||
const approvalId = randomUUID();
|
|
||||||
const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
|
|
||||||
const approval: CommandApproval = {
|
|
||||||
approvalId,
|
|
||||||
actionDigest: this.actionDigest(command.name, payload),
|
|
||||||
actorId,
|
|
||||||
command: command.name,
|
|
||||||
expiresAt,
|
|
||||||
};
|
|
||||||
await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300');
|
|
||||||
return approval;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uses the same `interaction:command-approval:*` store and one-time deletion rule as
|
|
||||||
* command approvals. This deliberately avoids a parallel approval database.
|
|
||||||
*/
|
|
||||||
async createRuntimeTerminationApproval(
|
|
||||||
action: RuntimeTerminationApprovalAction,
|
|
||||||
): Promise<RuntimeTerminationApproval | null> {
|
|
||||||
if (!this.hasRuntimeTerminationAction(action)) return null;
|
|
||||||
const role = await this.resolveRole(action.actorId);
|
|
||||||
if (role !== 'admin') return null;
|
|
||||||
|
|
||||||
const approval: RuntimeTerminationApproval = {
|
|
||||||
approvalId: randomUUID(),
|
|
||||||
actionDigest: this.runtimeActionDigest(action),
|
|
||||||
...action,
|
|
||||||
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
|
|
||||||
};
|
|
||||||
await this.redis.set(
|
|
||||||
this.runtimeKey(action.agentName, approval.approvalId),
|
|
||||||
JSON.stringify(approval),
|
|
||||||
'EX',
|
|
||||||
'300',
|
|
||||||
);
|
|
||||||
return approval;
|
|
||||||
}
|
|
||||||
|
|
||||||
async consumeRuntimeTerminationApproval(
|
|
||||||
approvalId: string,
|
|
||||||
action: RuntimeTerminationApprovalAction,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const encoded = await this.redis.get(this.runtimeKey(action.agentName, approvalId));
|
|
||||||
if (!encoded) return false;
|
|
||||||
let approval: unknown;
|
|
||||||
try {
|
|
||||||
approval = JSON.parse(encoded);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!this.isRuntimeTerminationApproval(approval) ||
|
|
||||||
approval.actionDigest !== this.runtimeActionDigest(action) ||
|
|
||||||
approval.actorId !== action.actorId ||
|
|
||||||
approval.tenantId !== action.tenantId ||
|
|
||||||
!this.isUnexpired(approval.expiresAt)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if ((await this.resolveRole(approval.actorId)) !== 'admin') return false;
|
|
||||||
return (await this.redis.del(this.runtimeKey(action.agentName, approvalId))) === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveRole(actorId: string): Promise<CommandRole | null> {
|
|
||||||
const [user] = await this.db
|
|
||||||
.select({ role: usersTable.role })
|
|
||||||
.from(usersTable)
|
|
||||||
.where(eq(usersTable.id, actorId))
|
|
||||||
.limit(1);
|
|
||||||
const role = user?.role;
|
|
||||||
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
|
|
||||||
if (role === 'admin') return true;
|
|
||||||
return role === 'member' && (scope === 'core' || scope === 'agent');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async consumeApproval(
|
|
||||||
approvalId: string,
|
|
||||||
actorId: string,
|
|
||||||
actionDigest: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const key = this.key(approvalId);
|
|
||||||
const encoded = await this.redis.get(key);
|
|
||||||
if (!encoded) return false;
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(encoded);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!this.isCommandApproval(parsed) ||
|
|
||||||
parsed.actorId !== actorId ||
|
|
||||||
parsed.actionDigest !== actionDigest ||
|
|
||||||
!this.isUnexpired(parsed.expiresAt)
|
|
||||||
)
|
|
||||||
return false;
|
|
||||||
return (await this.redis.del(key)) === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private actionDigest(command: string, payload: SlashCommandPayload): string {
|
|
||||||
return createHash('sha256')
|
|
||||||
.update(
|
|
||||||
JSON.stringify({
|
|
||||||
command,
|
|
||||||
args: payload.args?.trim() ?? '',
|
|
||||||
conversationId: payload.conversationId,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
private hasRuntimeTerminationAction(action: RuntimeTerminationApprovalAction): boolean {
|
|
||||||
return [
|
|
||||||
action.providerId,
|
|
||||||
action.sessionId,
|
|
||||||
action.actorId,
|
|
||||||
action.tenantId,
|
|
||||||
action.channelId,
|
|
||||||
action.correlationId,
|
|
||||||
action.agentName,
|
|
||||||
].every((value: string): boolean => value.trim().length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private runtimeActionDigest(action: RuntimeTerminationApprovalAction): string {
|
|
||||||
return createHash('sha256')
|
|
||||||
.update(
|
|
||||||
JSON.stringify({
|
|
||||||
providerId: action.providerId,
|
|
||||||
sessionId: action.sessionId,
|
|
||||||
actorId: action.actorId,
|
|
||||||
tenantId: action.tenantId,
|
|
||||||
channelId: action.channelId,
|
|
||||||
correlationId: action.correlationId,
|
|
||||||
agentName: action.agentName,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
private isUnexpired(expiresAt: unknown): expiresAt is string {
|
|
||||||
if (typeof expiresAt !== 'string') return false;
|
|
||||||
const expiresAtMs = Date.parse(expiresAt);
|
|
||||||
return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
private isCommandApproval(value: unknown): value is CommandApproval {
|
|
||||||
return (
|
|
||||||
typeof value === 'object' &&
|
|
||||||
value !== null &&
|
|
||||||
'approvalId' in value &&
|
|
||||||
'actionDigest' in value &&
|
|
||||||
'actorId' in value &&
|
|
||||||
'expiresAt' in value &&
|
|
||||||
'command' in value
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isRuntimeTerminationApproval(value: unknown): value is RuntimeTerminationApproval {
|
|
||||||
return (
|
|
||||||
typeof value === 'object' &&
|
|
||||||
value !== null &&
|
|
||||||
'approvalId' in value &&
|
|
||||||
'actionDigest' in value &&
|
|
||||||
'actorId' in value &&
|
|
||||||
'tenantId' in value &&
|
|
||||||
'providerId' in value &&
|
|
||||||
'sessionId' in value &&
|
|
||||||
'channelId' in value &&
|
|
||||||
'correlationId' in value &&
|
|
||||||
'agentName' in value &&
|
|
||||||
'expiresAt' in value
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private key(approvalId: string): string {
|
|
||||||
return `interaction:command-approval:${approvalId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private runtimeKey(agentName: string, approvalId: string): string {
|
|
||||||
return `agent:${encodeURIComponent(agentName)}:command-approval:${approvalId}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
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 '@mosaic/types';
|
||||||
|
|
||||||
// Minimal mock implementations
|
// Minimal mock implementations
|
||||||
const mockRegistry = {
|
const mockRegistry = {
|
||||||
@@ -89,7 +89,6 @@ function buildService(): CommandExecutorService {
|
|||||||
describe('CommandExecutorService — P8-012 commands', () => {
|
describe('CommandExecutorService — P8-012 commands', () => {
|
||||||
let service: CommandExecutorService;
|
let service: CommandExecutorService;
|
||||||
const userId = 'user-123';
|
const userId = 'user-123';
|
||||||
const userScope = { userId, tenantId: userId };
|
|
||||||
const conversationId = 'conv-456';
|
const conversationId = 'conv-456';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -100,26 +99,31 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /provider login — missing provider name
|
// /provider login — missing provider name
|
||||||
it('/provider login with no provider name returns usage error', async () => {
|
it('/provider login with no provider name returns usage error', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
|
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toContain('Usage: /provider login');
|
expect(result.message).toContain('Usage: /provider login');
|
||||||
expect(result.command).toBe('provider');
|
expect(result.command).toBe('provider');
|
||||||
});
|
});
|
||||||
|
|
||||||
// /provider login anthropic — no bearer token or auth URL reaches chat output
|
// /provider login anthropic — success with URL containing poll token
|
||||||
it('/provider login <name> keeps its one-time token out of chat output', async () => {
|
it('/provider login <name> returns success with URL and poll token', async () => {
|
||||||
const payload: SlashCommandPayload = {
|
const payload: SlashCommandPayload = {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
args: 'login anthropic',
|
args: 'login anthropic',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('provider');
|
expect(result.command).toBe('provider');
|
||||||
expect(result.message).toContain('anthropic');
|
expect(result.message).toContain('anthropic');
|
||||||
expect(result.message).not.toContain('http');
|
expect(result.message).toContain('http');
|
||||||
expect(result.message).not.toContain('token=');
|
// data should contain loginUrl and pollToken
|
||||||
expect(result.data).toEqual({ provider: 'anthropic' });
|
expect(result.data).toBeDefined();
|
||||||
|
const data = result.data as Record<string, unknown>;
|
||||||
|
expect(typeof data['loginUrl']).toBe('string');
|
||||||
|
expect(typeof data['pollToken']).toBe('string');
|
||||||
|
expect(data['loginUrl'] as string).toContain('anthropic');
|
||||||
|
expect(data['loginUrl'] as string).toContain(data['pollToken'] as string);
|
||||||
// Verify Valkey was called
|
// Verify Valkey was called
|
||||||
expect(mockRedis.set).toHaveBeenCalledOnce();
|
expect(mockRedis.set).toHaveBeenCalledOnce();
|
||||||
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
|
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
|
||||||
@@ -134,7 +138,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /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 };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('Usage: /provider');
|
expect(result.message).toContain('Usage: /provider');
|
||||||
});
|
});
|
||||||
@@ -142,7 +146,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /provider list
|
// /provider list
|
||||||
it('/provider list returns success', async () => {
|
it('/provider list returns success', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
|
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('provider');
|
expect(result.command).toBe('provider');
|
||||||
});
|
});
|
||||||
@@ -150,7 +154,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /provider logout with no name — usage error
|
// /provider logout with no name — usage error
|
||||||
it('/provider logout with no name returns error', async () => {
|
it('/provider logout with no name returns error', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
|
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toContain('Usage: /provider logout');
|
expect(result.message).toContain('Usage: /provider logout');
|
||||||
});
|
});
|
||||||
@@ -162,7 +166,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
args: 'unknown',
|
args: 'unknown',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toContain('Unknown subcommand');
|
expect(result.message).toContain('Unknown subcommand');
|
||||||
});
|
});
|
||||||
@@ -170,7 +174,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /mission status
|
// /mission status
|
||||||
it('/mission status returns stub message', async () => {
|
it('/mission status returns stub message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
|
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('mission');
|
expect(result.command).toBe('mission');
|
||||||
expect(result.message).toContain('Mission status');
|
expect(result.message).toContain('Mission status');
|
||||||
@@ -179,7 +183,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /mission with no args
|
// /mission with no args
|
||||||
it('/mission with no args returns status stub', async () => {
|
it('/mission with no args returns status stub', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'mission', conversationId };
|
const payload: SlashCommandPayload = { command: 'mission', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('Mission status');
|
expect(result.message).toContain('Mission status');
|
||||||
});
|
});
|
||||||
@@ -191,7 +195,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
args: 'set my-mission-123',
|
args: 'set my-mission-123',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('my-mission-123');
|
expect(result.message).toContain('my-mission-123');
|
||||||
});
|
});
|
||||||
@@ -199,7 +203,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /agent list
|
// /agent list
|
||||||
it('/agent list returns stub message', async () => {
|
it('/agent list returns stub message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
|
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('agent');
|
expect(result.command).toBe('agent');
|
||||||
expect(result.message).toContain('agent');
|
expect(result.message).toContain('agent');
|
||||||
@@ -208,7 +212,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /agent with no args
|
// /agent with no args
|
||||||
it('/agent with no args returns usage', async () => {
|
it('/agent with no args returns usage', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'agent', conversationId };
|
const payload: SlashCommandPayload = { command: 'agent', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('Usage: /agent');
|
expect(result.message).toContain('Usage: /agent');
|
||||||
});
|
});
|
||||||
@@ -220,7 +224,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
args: 'my-agent-id',
|
args: 'my-agent-id',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('my-agent-id');
|
expect(result.message).toContain('my-agent-id');
|
||||||
});
|
});
|
||||||
@@ -228,7 +232,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /prdy
|
// /prdy
|
||||||
it('/prdy returns PRD wizard message', async () => {
|
it('/prdy returns PRD wizard message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
|
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('prdy');
|
expect(result.command).toBe('prdy');
|
||||||
expect(result.message).toContain('mosaic prdy');
|
expect(result.message).toContain('mosaic prdy');
|
||||||
@@ -237,7 +241,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /tools
|
// /tools
|
||||||
it('/tools returns tools stub message', async () => {
|
it('/tools returns tools stub message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'tools', conversationId };
|
const payload: SlashCommandPayload = { command: 'tools', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('tools');
|
expect(result.command).toBe('tools');
|
||||||
expect(result.message).toContain('tools');
|
expect(result.message).toContain('tools');
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
|
||||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
|
||||||
|
|
||||||
const registry = {
|
|
||||||
getManifest: vi.fn(() => ({
|
|
||||||
version: 1,
|
|
||||||
commands: [
|
|
||||||
{
|
|
||||||
name: 'gc',
|
|
||||||
description: 'System-wide garbage collection',
|
|
||||||
aliases: [],
|
|
||||||
scope: 'admin' as const,
|
|
||||||
execution: 'socket' as const,
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
skills: [],
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const sessionGc = {
|
|
||||||
sweepOrphans: vi.fn().mockResolvedValue({ orphanedSessions: 1, totalCleaned: [], duration: 1 }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const scope = (userId: string) => ({ userId, tenantId: 'tenant-1' });
|
|
||||||
|
|
||||||
const authorization = {
|
|
||||||
authorize: vi.fn((_command: unknown, _payload: unknown, actorId: string) =>
|
|
||||||
Promise.resolve(
|
|
||||||
actorId === 'member-1'
|
|
||||||
? { allowed: false, reason: 'durable approval is required' }
|
|
||||||
: { allowed: false, reason: 'not authorized for this command scope' },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
|
|
||||||
return new CommandExecutorService(
|
|
||||||
registry as never,
|
|
||||||
{ getSession: vi.fn() } as never,
|
|
||||||
{ clear: vi.fn(), set: vi.fn() } as never,
|
|
||||||
sessionGc as never,
|
|
||||||
{ set: vi.fn() } as never,
|
|
||||||
{ agents: {} } as never,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
authorizationService as never,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDurableAuthorization(): CommandAuthorizationService {
|
|
||||||
const entries = new Map<string, string>();
|
|
||||||
const db = {
|
|
||||||
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }) }),
|
|
||||||
};
|
|
||||||
const redis = {
|
|
||||||
get: async (key: string) => entries.get(key) ?? null,
|
|
||||||
set: async (key: string, value: string) => {
|
|
||||||
entries.set(key, value);
|
|
||||||
},
|
|
||||||
del: async (key: string) => Number(entries.delete(key)),
|
|
||||||
};
|
|
||||||
return new CommandAuthorizationService(db as never, redis);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('TESS-M1-SEC-001 command authorization abuse cases', () => {
|
|
||||||
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
|
||||||
|
|
||||||
beforeEach((): void => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies a forged admin identity and does not execute a system-wide command', async (): Promise<void> => {
|
|
||||||
const result = await buildExecutor().execute(payload, scope('admin-forged-by-client'));
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.message).toContain('not authorized');
|
|
||||||
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies a privileged command without a server-bound durable approval', async (): Promise<void> => {
|
|
||||||
const result = await buildExecutor().execute(payload, scope('member-1'));
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.message).toContain('approval');
|
|
||||||
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('executes an admin command only after a valid durable approval is issued and supplied', async (): Promise<void> => {
|
|
||||||
const executor = buildExecutor(createDurableAuthorization());
|
|
||||||
|
|
||||||
const adminScope = scope('admin-1');
|
|
||||||
const denied = await executor.execute(payload, adminScope);
|
|
||||||
const approval = await executor.createApproval(payload, adminScope);
|
|
||||||
const approved = await executor.execute(
|
|
||||||
{ ...payload, approvalId: approval?.approvalId },
|
|
||||||
adminScope,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(denied.success).toBe(false);
|
|
||||||
expect(denied.message).toContain('approval');
|
|
||||||
expect(approval).not.toBeNull();
|
|
||||||
// A valid durable approval is consumed, but cannot authorize an unimplemented
|
|
||||||
// global retention operation. Session-scoped cleanup remains lifecycle-only.
|
|
||||||
expect(approved.success).toBe(false);
|
|
||||||
expect(approved.message).toContain('Global GC is disabled');
|
|
||||||
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
||||||
import type { QueueHandle } from '@mosaicstack/queue';
|
import type { QueueHandle } from '@mosaic/queue';
|
||||||
import type { Brain } from '@mosaicstack/brain';
|
import type { Brain } from '@mosaic/brain';
|
||||||
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types';
|
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/types';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
|
||||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||||
@@ -11,7 +10,6 @@ import { ReloadService } from '../reload/reload.service.js';
|
|||||||
import { McpClientService } from '../mcp-client/mcp-client.service.js';
|
import { McpClientService } from '../mcp-client/mcp-client.service.js';
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -34,17 +32,10 @@ export class CommandExecutorService {
|
|||||||
@Optional()
|
@Optional()
|
||||||
@Inject(McpClientService)
|
@Inject(McpClientService)
|
||||||
private readonly mcpClient: McpClientService | null,
|
private readonly mcpClient: McpClientService | null,
|
||||||
@Optional()
|
|
||||||
@Inject(CommandAuthorizationService)
|
|
||||||
private readonly authorization: CommandAuthorizationService | null = null,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(
|
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
|
||||||
payload: SlashCommandPayload,
|
|
||||||
scope: ActorTenantScope,
|
|
||||||
): Promise<SlashCommandResultPayload> {
|
|
||||||
const { command, args, conversationId } = payload;
|
const { command, args, conversationId } = payload;
|
||||||
const userId = scope.userId;
|
|
||||||
|
|
||||||
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
||||||
if (!def) {
|
if (!def) {
|
||||||
@@ -56,24 +47,14 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorization = await this.authorization?.authorize(
|
|
||||||
def,
|
|
||||||
payload,
|
|
||||||
userId,
|
|
||||||
payload.approvalId,
|
|
||||||
);
|
|
||||||
if (authorization && !authorization.allowed) {
|
|
||||||
return { command, conversationId, success: false, message: authorization.reason };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'model':
|
case 'model':
|
||||||
return await this.handleModel(args ?? null, conversationId, scope);
|
return await this.handleModel(args ?? null, conversationId);
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
return await this.handleThinking(args ?? null, conversationId);
|
return await this.handleThinking(args ?? null, conversationId);
|
||||||
case 'system':
|
case 'system':
|
||||||
return await this.handleSystem(args ?? null, conversationId, scope);
|
return await this.handleSystem(args ?? null, conversationId);
|
||||||
case 'new':
|
case 'new':
|
||||||
return {
|
return {
|
||||||
command,
|
command,
|
||||||
@@ -102,17 +83,18 @@ export class CommandExecutorService {
|
|||||||
success: true,
|
success: true,
|
||||||
message: 'Retry last message requested.',
|
message: 'Retry last message requested.',
|
||||||
};
|
};
|
||||||
case 'gc':
|
case 'gc': {
|
||||||
// Global retention requires a separate, authorized and audited job.
|
// Admin-only: system-wide GC sweep across all sessions
|
||||||
// Session cleanup is performed only through the session lifecycle.
|
const result = await this.sessionGC.sweepOrphans();
|
||||||
return {
|
return {
|
||||||
command: 'gc',
|
command: 'gc',
|
||||||
success: false,
|
success: true,
|
||||||
message: 'Global GC is disabled pending an authorized retention job.',
|
message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`,
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
case 'agent':
|
case 'agent':
|
||||||
return await this.handleAgent(args ?? null, conversationId, scope);
|
return await this.handleAgent(args ?? null, conversationId, userId);
|
||||||
case 'provider':
|
case 'provider':
|
||||||
return await this.handleProvider(args ?? null, userId, conversationId);
|
return await this.handleProvider(args ?? null, userId, conversationId);
|
||||||
case 'mission':
|
case 'mission':
|
||||||
@@ -161,22 +143,13 @@ export class CommandExecutorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createApproval(payload: SlashCommandPayload, scope: ActorTenantScope) {
|
|
||||||
const def = this.registry
|
|
||||||
.getManifest()
|
|
||||||
.commands.find((command) => command.name === payload.command);
|
|
||||||
if (!def || !this.authorization) return null;
|
|
||||||
return this.authorization.createApproval(def, payload, scope.userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleModel(
|
private async handleModel(
|
||||||
args: string | null,
|
args: string | null,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
scope: ActorTenantScope,
|
|
||||||
): Promise<SlashCommandResultPayload> {
|
): Promise<SlashCommandResultPayload> {
|
||||||
if (!args || args.trim().length === 0) {
|
if (!args || args.trim().length === 0) {
|
||||||
// Show current override or usage hint
|
// Show current override or usage hint
|
||||||
const currentOverride = this.chatGateway?.getModelOverride(conversationId, scope);
|
const currentOverride = this.chatGateway?.getModelOverride(conversationId);
|
||||||
if (currentOverride) {
|
if (currentOverride) {
|
||||||
return {
|
return {
|
||||||
command: 'model',
|
command: 'model',
|
||||||
@@ -198,7 +171,7 @@ export class CommandExecutorService {
|
|||||||
|
|
||||||
// /model clear removes the override and re-enables automatic routing
|
// /model clear removes the override and re-enables automatic routing
|
||||||
if (modelName === 'clear') {
|
if (modelName === 'clear') {
|
||||||
this.chatGateway?.setModelOverride(conversationId, null, scope);
|
this.chatGateway?.setModelOverride(conversationId, null);
|
||||||
return {
|
return {
|
||||||
command: 'model',
|
command: 'model',
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -208,9 +181,9 @@ export class CommandExecutorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set the sticky per-session override (M4-007)
|
// Set the sticky per-session override (M4-007)
|
||||||
this.chatGateway?.setModelOverride(conversationId, modelName, scope);
|
this.chatGateway?.setModelOverride(conversationId, modelName);
|
||||||
|
|
||||||
const session = this.agentService.getSession(conversationId, scope);
|
const session = this.agentService.getSession(conversationId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return {
|
return {
|
||||||
command: 'model',
|
command: 'model',
|
||||||
@@ -251,11 +224,10 @@ export class CommandExecutorService {
|
|||||||
private async handleSystem(
|
private async handleSystem(
|
||||||
args: string | null,
|
args: string | null,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
scope: ActorTenantScope,
|
|
||||||
): Promise<SlashCommandResultPayload> {
|
): Promise<SlashCommandResultPayload> {
|
||||||
if (!args || args.trim().length === 0) {
|
if (!args || args.trim().length === 0) {
|
||||||
// Clear the override when called with no args
|
// Clear the override when called with no args
|
||||||
await this.systemOverride.clear(conversationId, scope);
|
await this.systemOverride.clear(conversationId);
|
||||||
return {
|
return {
|
||||||
command: 'system',
|
command: 'system',
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -264,7 +236,7 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.systemOverride.set(conversationId, args.trim(), scope);
|
await this.systemOverride.set(conversationId, args.trim());
|
||||||
return {
|
return {
|
||||||
command: 'system',
|
command: 'system',
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -276,9 +248,8 @@ export class CommandExecutorService {
|
|||||||
private async handleAgent(
|
private async handleAgent(
|
||||||
args: string | null,
|
args: string | null,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
scope: ActorTenantScope,
|
userId: string,
|
||||||
): Promise<SlashCommandResultPayload> {
|
): Promise<SlashCommandResultPayload> {
|
||||||
const userId = scope.userId;
|
|
||||||
if (!args) {
|
if (!args) {
|
||||||
return {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
@@ -367,14 +338,11 @@ export class CommandExecutorService {
|
|||||||
conversationId,
|
conversationId,
|
||||||
agentConfig.id,
|
agentConfig.id,
|
||||||
agentConfig.name,
|
agentConfig.name,
|
||||||
scope,
|
|
||||||
agentConfig.model ?? undefined,
|
agentConfig.model ?? undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Broadcast updated session:info so TUI TopBar reflects new agent/model
|
// Broadcast updated session:info so TUI TopBar reflects new agent/model
|
||||||
this.chatGateway?.broadcastSessionInfo(conversationId, scope, {
|
this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name });
|
||||||
agentName: agentConfig.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
|
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
|
||||||
@@ -435,28 +403,22 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const pollToken = crypto.randomUUID();
|
const pollToken = crypto.randomUUID();
|
||||||
const tokenDigest = await crypto.subtle.digest(
|
const key = `mosaic:auth:poll:${pollToken}`;
|
||||||
'SHA-256',
|
// Store pending state in Valkey (TTL 5 minutes)
|
||||||
new TextEncoder().encode(pollToken),
|
|
||||||
);
|
|
||||||
const tokenHash = Array.from(new Uint8Array(tokenDigest), (byte: number): string =>
|
|
||||||
byte.toString(16).padStart(2, '0'),
|
|
||||||
).join('');
|
|
||||||
const key = `mosaic:auth:poll:${tokenHash}`;
|
|
||||||
// Persist only a short-lived token digest. The raw token is delivered only by
|
|
||||||
// the authenticated dashboard flow, never in chat output or command metadata.
|
|
||||||
await this.redis.set(
|
await this.redis.set(
|
||||||
key,
|
key,
|
||||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||||
'EX',
|
'EX',
|
||||||
300,
|
300,
|
||||||
);
|
);
|
||||||
|
// In production this would construct an OAuth URL
|
||||||
|
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
|
||||||
return {
|
return {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
success: true,
|
success: true,
|
||||||
message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`,
|
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`,
|
||||||
conversationId,
|
conversationId,
|
||||||
data: { provider: providerName },
|
data: { loginUrl, pollToken, provider: providerName },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
import type { CommandDef } from '@mosaicstack/types';
|
import type { CommandDef } from '@mosaic/types';
|
||||||
|
|
||||||
const mockCmd: CommandDef = {
|
const mockCmd: CommandDef = {
|
||||||
name: 'test',
|
name: 'test',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||||
import type { CommandDef, CommandManifest } from '@mosaicstack/types';
|
import type { CommandDef, CommandManifest } from '@mosaic/types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommandRegistryService implements OnModuleInit {
|
export class CommandRegistryService implements OnModuleInit {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
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 '@mosaic/types';
|
||||||
|
|
||||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -159,7 +159,6 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
let registry: CommandRegistryService;
|
let registry: CommandRegistryService;
|
||||||
let executor: CommandExecutorService;
|
let executor: CommandExecutorService;
|
||||||
const userId = 'user-integ-001';
|
const userId = 'user-integ-001';
|
||||||
const userScope = { userId, tenantId: userId };
|
|
||||||
const conversationId = 'conv-integ-001';
|
const conversationId = 'conv-integ-001';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -171,26 +170,28 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// Unknown command returns error
|
// Unknown command returns error
|
||||||
it('unknown command returns success:false with descriptive message', async () => {
|
it('unknown command returns success:false with descriptive message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'nonexistent', conversationId };
|
const payload: SlashCommandPayload = { command: 'nonexistent', conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toContain('nonexistent');
|
expect(result.message).toContain('nonexistent');
|
||||||
expect(result.command).toBe('nonexistent');
|
expect(result.command).toBe('nonexistent');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/gc refuses an unaudited global sweep', async () => {
|
// /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg)
|
||||||
|
it('/gc calls SessionGCService.sweepOrphans without arguments', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'gc', conversationId };
|
const payload: SlashCommandPayload = { command: 'gc', conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(mockSessionGC.sweepOrphans).not.toHaveBeenCalled();
|
expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith();
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('disabled pending an authorized retention job');
|
expect(result.message).toContain('GC sweep complete');
|
||||||
|
expect(result.message).toContain('3 orphaned sessions');
|
||||||
});
|
});
|
||||||
|
|
||||||
// /system with args calls SystemOverrideService.set
|
// /system with args calls SystemOverrideService.set
|
||||||
it('/system with text calls SystemOverrideService.set', async () => {
|
it('/system with text calls SystemOverrideService.set', async () => {
|
||||||
const override = 'You are a helpful assistant.';
|
const override = 'You are a helpful assistant.';
|
||||||
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
|
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override, userScope);
|
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('override set');
|
expect(result.message).toContain('override set');
|
||||||
});
|
});
|
||||||
@@ -198,8 +199,8 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /system with no args clears the override
|
// /system with no args clears the override
|
||||||
it('/system with no args calls SystemOverrideService.clear', async () => {
|
it('/system with no args calls SystemOverrideService.clear', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'system', conversationId };
|
const payload: SlashCommandPayload = { command: 'system', conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId, userScope);
|
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('cleared');
|
expect(result.message).toContain('cleared');
|
||||||
});
|
});
|
||||||
@@ -211,7 +212,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
args: 'claude-3-opus',
|
args: 'claude-3-opus',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('model');
|
expect(result.command).toBe('model');
|
||||||
expect(result.message).toContain('claude-3-opus');
|
expect(result.message).toContain('claude-3-opus');
|
||||||
@@ -220,7 +221,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /thinking with valid level returns success
|
// /thinking with valid level returns success
|
||||||
it('/thinking with valid level returns success', async () => {
|
it('/thinking with valid level returns success', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId };
|
const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('high');
|
expect(result.message).toContain('high');
|
||||||
});
|
});
|
||||||
@@ -228,7 +229,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /thinking with invalid level returns usage message
|
// /thinking with invalid level returns usage message
|
||||||
it('/thinking with invalid level returns usage message', async () => {
|
it('/thinking with invalid level returns usage message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId };
|
const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('Usage:');
|
expect(result.message).toContain('Usage:');
|
||||||
});
|
});
|
||||||
@@ -236,7 +237,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /new command returns success
|
// /new command returns success
|
||||||
it('/new returns success', async () => {
|
it('/new returns success', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'new', conversationId };
|
const payload: SlashCommandPayload = { command: 'new', conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('new');
|
expect(result.command).toBe('new');
|
||||||
});
|
});
|
||||||
@@ -244,7 +245,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /reload without reloadService returns failure
|
// /reload without reloadService returns failure
|
||||||
it('/reload without ReloadService returns failure', async () => {
|
it('/reload without ReloadService returns failure', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'reload', conversationId };
|
const payload: SlashCommandPayload = { command: 'reload', conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toContain('ReloadService');
|
expect(result.message).toContain('ReloadService');
|
||||||
});
|
});
|
||||||
@@ -254,7 +255,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
for (const cmd of stubCommands) {
|
for (const cmd of stubCommands) {
|
||||||
it(`/${cmd} returns success (stub)`, async () => {
|
it(`/${cmd} returns success (stub)`, async () => {
|
||||||
const payload: SlashCommandPayload = { command: cmd, conversationId };
|
const payload: SlashCommandPayload = { command: cmd, conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe(cmd);
|
expect(result.command).toBe(cmd);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { forwardRef, Inject, Module, 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 '@mosaic/queue';
|
||||||
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 { ReloadModule } from '../reload/reload.module.js';
|
import { ReloadModule } from '../reload/reload.module.js';
|
||||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
import { CommandRuntimeApprovalVerifier } from './runtime-approval-verifier.js';
|
|
||||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
|
|
||||||
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||||
@@ -26,16 +24,9 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
inject: [COMMANDS_QUEUE_HANDLE],
|
inject: [COMMANDS_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
CommandRegistryService,
|
CommandRegistryService,
|
||||||
CommandAuthorizationService,
|
|
||||||
CommandRuntimeApprovalVerifier,
|
|
||||||
CommandExecutorService,
|
|
||||||
],
|
|
||||||
exports: [
|
|
||||||
CommandRegistryService,
|
|
||||||
CommandAuthorizationService,
|
|
||||||
CommandRuntimeApprovalVerifier,
|
|
||||||
CommandExecutorService,
|
CommandExecutorService,
|
||||||
],
|
],
|
||||||
|
exports: [CommandRegistryService, CommandExecutorService],
|
||||||
})
|
})
|
||||||
export class CommandsModule implements OnApplicationShutdown {
|
export class CommandsModule implements OnApplicationShutdown {
|
||||||
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import type {
|
|
||||||
RuntimeApprovalVerifier,
|
|
||||||
RuntimeTerminationAction,
|
|
||||||
} from '../agent/runtime-provider-registry.service.js';
|
|
||||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adapter from the provider registry's exact termination action to the shared,
|
|
||||||
* Redis-backed `interaction:command-approval:*` store. It has no separate approval
|
|
||||||
* persistence or replay semantics.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class CommandRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
|
|
||||||
constructor(
|
|
||||||
@Inject(CommandAuthorizationService)
|
|
||||||
private readonly authorization: CommandAuthorizationService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean> {
|
|
||||||
return this.authorization.consumeRuntimeTerminationApproval(approvalRef, action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
|
||||||
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
|
||||||
|
|
||||||
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: MOSAIC_CONFIG,
|
|
||||||
useFactory: (): MosaicConfig => loadConfig(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
exports: [MOSAIC_CONFIG],
|
|
||||||
})
|
|
||||||
export class ConfigModule {}
|
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Brain } from '@mosaicstack/brain';
|
import type { Brain } from '@mosaic/brain';
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
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';
|
||||||
|
|||||||
@@ -1,32 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { InMemoryInteractionCoordinationPort } from '@mosaicstack/coord';
|
|
||||||
import { CoordService } from './coord.service.js';
|
import { CoordService } from './coord.service.js';
|
||||||
import { CoordController } from './coord.controller.js';
|
import { CoordController } from './coord.controller.js';
|
||||||
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
|
||||||
import {
|
|
||||||
COORDINATION_CONFIG,
|
|
||||||
COORDINATION_PORT,
|
|
||||||
InteractionCoordinationService,
|
|
||||||
} from './interaction-coordination.service.js';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [CoordService],
|
||||||
CoordService,
|
controllers: [CoordController],
|
||||||
{
|
exports: [CoordService],
|
||||||
provide: COORDINATION_PORT,
|
|
||||||
useFactory: (): InMemoryInteractionCoordinationPort =>
|
|
||||||
new InMemoryInteractionCoordinationPort(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: COORDINATION_CONFIG,
|
|
||||||
useFactory: () => ({
|
|
||||||
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
|
|
||||||
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
InteractionCoordinationService,
|
|
||||||
],
|
|
||||||
controllers: [CoordController, InteractionCoordinationController],
|
|
||||||
exports: [CoordService, InteractionCoordinationService],
|
|
||||||
})
|
})
|
||||||
export class CoordModule {}
|
export class CoordModule {}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user