Compare commits
1 Commits
ed1d985c90
...
release/mo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac79922c3f |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -15,10 +15,3 @@ infra/step-ca/dev-password
|
|||||||
|
|
||||||
# Scratch dirs created by the framework git-wrapper shell test harnesses
|
# Scratch dirs created by the framework git-wrapper shell test harnesses
|
||||||
.mosaic-test-work/
|
.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
|
|
||||||
|
|||||||
4
.npmrc
4
.npmrc
@@ -1,5 +1 @@
|
|||||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||||
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
|
|
||||||
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
|
||||||
# instead of repopulating a fresh one.
|
|
||||||
store-dir=/root/.local/share/pnpm/store
|
|
||||||
|
|||||||
@@ -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,8 @@ 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
|
- apk add --no-cache python3 make g++
|
||||||
# the baked pnpm store.
|
- pnpm install --frozen-lockfile
|
||||||
- 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 +25,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:
|
||||||
@@ -76,7 +51,8 @@ steps:
|
|||||||
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
# postgresql-client (pg_isready) is baked into ci-base.
|
# Install postgresql-client for pg_isready
|
||||||
|
- apk add --no-cache postgresql-client
|
||||||
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
||||||
- |
|
- |
|
||||||
ready=0
|
ready=0
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -120,7 +91,6 @@ steps:
|
|||||||
|
|
||||||
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
|
||||||
@@ -146,7 +116,6 @@ steps:
|
|||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
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
|
||||||
@@ -172,7 +141,6 @@ steps:
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -31,7 +31,6 @@
|
|||||||
"@mariozechner/pi-ai": "^0.65.0",
|
"@mariozechner/pi-ai": "^0.65.0",
|
||||||
"@mariozechner/pi-coding-agent": "^0.65.0",
|
"@mariozechner/pi-coding-agent": "^0.65.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"@mosaicstack/agent": "workspace:^",
|
|
||||||
"@mosaicstack/auth": "workspace:^",
|
"@mosaicstack/auth": "workspace:^",
|
||||||
"@mosaicstack/brain": "workspace:^",
|
"@mosaicstack/brain": "workspace:^",
|
||||||
"@mosaicstack/config": "workspace:^",
|
"@mosaicstack/config": "workspace:^",
|
||||||
|
|||||||
@@ -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 { TessDurableSessionService } from '../../agent/tess-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('Tess 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 TessDurableSessionService(
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,142 +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(): AgentService {
|
|
||||||
return new AgentService(
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{ available: false } as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
{ collect: vi.fn().mockResolvedValue(undefined) } 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('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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { AgentRuntimeProviderRegistry } 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,59 +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 { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
|
||||||
import { TessDurableSessionService } from './tess-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 {
|
|
||||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
|
||||||
RUNTIME_APPROVAL_VERIFIER,
|
|
||||||
RUNTIME_PROVIDER_AUDIT_SINK,
|
|
||||||
RuntimeProviderAuditService,
|
|
||||||
RuntimeProviderService,
|
|
||||||
} from './runtime-provider-registry.service.js';
|
|
||||||
|
|
||||||
@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,
|
||||||
TessDurableSessionRepository,
|
|
||||||
TessDurableSessionService,
|
|
||||||
{
|
|
||||||
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
|
||||||
useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(),
|
|
||||||
},
|
|
||||||
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,
|
||||||
@@ -69,9 +33,6 @@ import {
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
TessDurableSessionService,
|
|
||||||
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,
|
||||||
@@ -35,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 {
|
||||||
@@ -76,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
|
||||||
@@ -104,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). */
|
||||||
@@ -186,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);
|
||||||
@@ -362,7 +342,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
sandboxDir,
|
sandboxDir,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
userId: mergedOptions?.userId,
|
userId: mergedOptions?.userId,
|
||||||
tenantId: this.tenantIdFor(mergedOptions?.userId, mergedOptions?.tenantId),
|
|
||||||
agentConfigId: mergedOptions?.agentConfigId,
|
agentConfigId: mergedOptions?.agentConfigId,
|
||||||
agentName: resolvedAgentName,
|
agentName: resolvedAgentName,
|
||||||
metrics: {
|
metrics: {
|
||||||
@@ -494,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 },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,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);
|
||||||
@@ -626,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 {
|
removeChannel(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.delete(channel);
|
||||||
session.channels.delete(channel);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void> {
|
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -686,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}`);
|
||||||
@@ -736,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,233 +0,0 @@
|
|||||||
import { firstValueFrom } from 'rxjs';
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { RuntimeApprovalDeniedError } 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('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,279 +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 { TessDurableSessionService } from './tess-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(TessDurableSessionService) private readonly durable: TessDurableSessionService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@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('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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,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,469 +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,
|
|
||||||
} 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';
|
|
||||||
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 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 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,10 +0,0 @@
|
|||||||
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
|
|
||||||
|
|
||||||
/** Server-side request for a replay-safe provider message. */
|
|
||||||
export interface TessProviderOutboxDto {
|
|
||||||
sessionId: string;
|
|
||||||
idempotencyKey: string;
|
|
||||||
correlationId: string;
|
|
||||||
content: string;
|
|
||||||
context: RuntimeProviderRequestContext;
|
|
||||||
}
|
|
||||||
@@ -1,418 +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 { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
|
||||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
|
||||||
|
|
||||||
const IDENTITY: DurableSessionIdentity = {
|
|
||||||
agentName: 'Nova',
|
|
||||||
sessionId: 'tess-pglite-session',
|
|
||||||
tenantId: 'tenant-pglite',
|
|
||||||
ownerId: 'tess-owner',
|
|
||||||
providerId: 'fleet',
|
|
||||||
runtimeSessionId: 'nova',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('TessDurableSessionRepository', () => {
|
|
||||||
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 TessDurableSessionRepository(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 TessDurableSessionRepository(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 TessDurableSessionRepository(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 TessDurableSessionRepository(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 TessDurableSessionRepository(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 TessDurableSessionRepository(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 TessDurableSessionRepository(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 TessDurableSessionRepository(handle.db);
|
|
||||||
const coordinator = new DurableSessionCoordinator(repository);
|
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
|
||||||
const service = new TessDurableSessionService(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 TessDurableSessionRepository(handle.db);
|
|
||||||
const coordinator = new DurableSessionCoordinator(repository);
|
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
|
||||||
const service = new TessDurableSessionService(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 TessDurableSessionRepository(handle.db);
|
|
||||||
const coordinator = new DurableSessionCoordinator(repository);
|
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
|
||||||
const service = new TessDurableSessionService(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,530 +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 TessDurableSessionRepository 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 Tess 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 Tess inbox enqueue failed: ${input.idempotencyKey}`);
|
|
||||||
const entry = toInbox(existing[0]);
|
|
||||||
if (
|
|
||||||
!sameInbox(entry, record) ||
|
|
||||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
|
||||||
) {
|
|
||||||
throw new Error(`Durable Tess 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 Tess outbox enqueue failed: ${input.idempotencyKey}`);
|
|
||||||
const entry = toOutbox(existing[0]);
|
|
||||||
if (
|
|
||||||
!sameOutbox(entry, record) ||
|
|
||||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
|
||||||
) {
|
|
||||||
throw new Error(`Durable Tess 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 Tess 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 Tess 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 Tess 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 { TessProviderOutboxDto } from './tess-durable-session.dto.js';
|
|
||||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
|
||||||
import {
|
|
||||||
RuntimeProviderService,
|
|
||||||
type RuntimeProviderRequestContext,
|
|
||||||
} from './runtime-provider-registry.service.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scoped gateway boundary for the canonical Tess 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 TessDurableSessionService {
|
|
||||||
private readonly coordinator: DurableSessionCoordinator;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(TessDurableSessionRepository) repository: TessDurableSessionRepository,
|
|
||||||
@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 Tess enrollment scope mismatch');
|
|
||||||
}
|
|
||||||
await this.coordinator.create(identity);
|
|
||||||
}
|
|
||||||
|
|
||||||
async queueProviderSend(input: TessProviderOutboxDto): 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: TessProviderOutboxDto): Promise<void> {
|
|
||||||
if (sessionId !== input.sessionId) {
|
|
||||||
throw new ForbiddenException('Durable Tess 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: TessProviderOutboxDto): 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: TessProviderOutboxDto,
|
|
||||||
): void {
|
|
||||||
if (
|
|
||||||
entry.kind !== 'provider.send' ||
|
|
||||||
entry.correlationId !== input.correlationId ||
|
|
||||||
entry.channelId !== input.context.channelId
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException('Durable Tess outbox scope or correlation mismatch');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertScope(ownerId: string, tenantId: string, input: TessProviderOutboxDto): void {
|
|
||||||
if (
|
|
||||||
input.context.actorScope.userId !== ownerId ||
|
|
||||||
input.context.actorScope.tenantId !== tenantId ||
|
|
||||||
input.context.correlationId !== input.correlationId
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException('Durable Tess session scope or correlation mismatch');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,27 +60,20 @@ 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) => {
|
responseText += event.assistantMessageEvent.delta;
|
||||||
if (
|
}
|
||||||
event.type === 'message_update' &&
|
if (event.type === 'agent_end') {
|
||||||
event.assistantMessageEvent.type === 'text_delta'
|
clearTimeout(timer);
|
||||||
) {
|
cleanup();
|
||||||
responseText += event.assistantMessageEvent.delta;
|
resolve();
|
||||||
}
|
}
|
||||||
if (event.type === 'agent_end') {
|
});
|
||||||
clearTimeout(timer);
|
|
||||||
cleanup();
|
|
||||||
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 {
|
|
||||||
verifyDiscordIngressEnvelope,
|
|
||||||
parseDiscordInteractionBindings,
|
|
||||||
resolveDiscordInteractionActorId,
|
|
||||||
resolveDiscordInteractionBinding,
|
|
||||||
type DiscordIngressEnvelope,
|
|
||||||
type DiscordIngressPayload,
|
|
||||||
} from '@mosaicstack/discord-plugin';
|
|
||||||
import type { Auth } from '@mosaicstack/auth';
|
import type { Auth } from '@mosaicstack/auth';
|
||||||
import type { Brain } from '@mosaicstack/brain';
|
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 '@mosaicstack/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 { TessDurableSessionService } from '../agent/tess-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(TessDurableSessionService)
|
|
||||||
private readonly durableSessions: TessDurableSessionService | 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,
|
this.relayEvent(client, conversationId, event);
|
||||||
(event: AgentSessionEvent) => {
|
});
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,7 +3,6 @@ import type { QueueHandle } from '@mosaicstack/queue';
|
|||||||
import type { Brain } from '@mosaicstack/brain';
|
import type { Brain } from '@mosaicstack/brain';
|
||||||
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types';
|
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/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 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { createQueue, type QueueHandle } from '@mosaicstack/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,255 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import type { Db } from '@mosaicstack/db';
|
|
||||||
import type { FederationListResponse } from '@mosaicstack/types';
|
|
||||||
import {
|
|
||||||
FederationClientError,
|
|
||||||
type FederationClientService,
|
|
||||||
} from '../federation-client.service.js';
|
|
||||||
import { type QuerySourceError, QuerySourceService } from '../query-source.service.js';
|
|
||||||
|
|
||||||
interface TestRow {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PeerRow {
|
|
||||||
id: string;
|
|
||||||
commonName: string;
|
|
||||||
endpointUrl: string | null;
|
|
||||||
clientKeyPem: string | null;
|
|
||||||
state: 'active' | 'pending' | 'suspended' | 'revoked';
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOCAL_ROWS: TestRow[] = [
|
|
||||||
{ id: 'local-1', title: 'Local One' },
|
|
||||||
{ id: 'local-2', title: 'Local Two' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const PEER_A: PeerRow = {
|
|
||||||
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
|
||||||
commonName: 'peer-a',
|
|
||||||
endpointUrl: 'https://peer-a.example.com',
|
|
||||||
clientKeyPem: 'sealed-key-a',
|
|
||||||
state: 'active',
|
|
||||||
};
|
|
||||||
|
|
||||||
const PEER_B: PeerRow = {
|
|
||||||
id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
|
||||||
commonName: 'peer-b',
|
|
||||||
endpointUrl: 'https://peer-b.example.com',
|
|
||||||
clientKeyPem: 'sealed-key-b',
|
|
||||||
state: 'active',
|
|
||||||
};
|
|
||||||
|
|
||||||
const PEER_LOCALHOST: PeerRow = {
|
|
||||||
id: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
|
|
||||||
commonName: 'peer-localhost',
|
|
||||||
endpointUrl: 'https://localhost:3001',
|
|
||||||
clientKeyPem: 'sealed-key-c',
|
|
||||||
state: 'active',
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeDb(activePeers: PeerRow[]): Db {
|
|
||||||
const orderBy = vi.fn().mockResolvedValue(activePeers);
|
|
||||||
const where = vi.fn().mockReturnValue({ orderBy });
|
|
||||||
const from = vi.fn().mockReturnValue({ where });
|
|
||||||
const select = vi.fn().mockReturnValue({ from });
|
|
||||||
|
|
||||||
return {
|
|
||||||
select,
|
|
||||||
insert: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
transaction: vi.fn(),
|
|
||||||
} as unknown as Db;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeFederationClient(
|
|
||||||
list: (
|
|
||||||
peerId: string,
|
|
||||||
resource: string,
|
|
||||||
request: Record<string, unknown>,
|
|
||||||
) => Promise<FederationListResponse<TestRow>>,
|
|
||||||
): FederationClientService {
|
|
||||||
return {
|
|
||||||
list: list as unknown as FederationClientService['list'],
|
|
||||||
} as FederationClientService;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeLocalResponse(rows: TestRow[] = LOCAL_ROWS): Promise<FederationListResponse<TestRow>> {
|
|
||||||
return Promise.resolve({ items: rows });
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('QuerySourceService', () => {
|
|
||||||
it('routes source="local" to the local executor and tags rows as local', async () => {
|
|
||||||
const list = vi.fn(async (): Promise<FederationListResponse<TestRow>> => ({ items: [] }));
|
|
||||||
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
|
|
||||||
|
|
||||||
const result = await service.list<TestRow>({
|
|
||||||
source: 'local',
|
|
||||||
resource: 'tasks',
|
|
||||||
request: { cursor: 'ignored-for-local-test' },
|
|
||||||
local: () => makeLocalResponse(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
items: [
|
|
||||||
{ id: 'local-1', title: 'Local One', _source: 'local' },
|
|
||||||
{ id: 'local-2', title: 'Local Two', _source: 'local' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(list).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('routes source="federated:<host>" to the matching active peer and tags rows with peer commonName', async () => {
|
|
||||||
const list = vi.fn(
|
|
||||||
async (): Promise<FederationListResponse<TestRow>> => ({
|
|
||||||
items: [{ id: 'remote-1', title: 'Remote One' }],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
|
|
||||||
|
|
||||||
const result = await service.list<TestRow>({
|
|
||||||
source: 'federated:peer-b.example.com',
|
|
||||||
resource: 'tasks',
|
|
||||||
request: { status: 'open' },
|
|
||||||
local: () => makeLocalResponse(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
items: [{ id: 'remote-1', title: 'Remote One', _source: 'peer-b' }],
|
|
||||||
});
|
|
||||||
expect(list).toHaveBeenCalledWith(PEER_B.id, 'tasks', { status: 'open' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('matches federated hosts by endpoint host including non-default port', async () => {
|
|
||||||
const list = vi.fn(
|
|
||||||
async (): Promise<FederationListResponse<TestRow>> => ({
|
|
||||||
items: [{ id: 'remote-port', title: 'Remote Port' }],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const service = new QuerySourceService(makeDb([PEER_LOCALHOST]), makeFederationClient(list));
|
|
||||||
|
|
||||||
const result = await service.list<TestRow>({
|
|
||||||
source: 'federated:localhost:3001',
|
|
||||||
resource: 'tasks',
|
|
||||||
request: {},
|
|
||||||
local: () => makeLocalResponse(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
items: [{ id: 'remote-port', title: 'Remote Port', _source: 'peer-localhost' }],
|
|
||||||
});
|
|
||||||
expect(list).toHaveBeenCalledWith(PEER_LOCALHOST.id, 'tasks', {});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fans out source="all" to local plus every active outbound peer in parallel and merges tagged rows', async () => {
|
|
||||||
const callOrder: string[] = [];
|
|
||||||
const list = vi.fn(async (peerId: string): Promise<FederationListResponse<TestRow>> => {
|
|
||||||
callOrder.push(`remote-start:${peerId}`);
|
|
||||||
await Promise.resolve();
|
|
||||||
return {
|
|
||||||
items: [{ id: `remote-${peerId.slice(0, 1)}`, title: `Remote ${peerId.slice(0, 1)}` }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
|
|
||||||
|
|
||||||
const result = await service.list<TestRow>({
|
|
||||||
source: 'all',
|
|
||||||
resource: 'tasks',
|
|
||||||
request: { limit: 25 },
|
|
||||||
local: async () => {
|
|
||||||
callOrder.push('local-start');
|
|
||||||
await Promise.resolve();
|
|
||||||
return { items: [{ id: 'local-1', title: 'Local One' }] };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
items: [
|
|
||||||
{ id: 'local-1', title: 'Local One', _source: 'local' },
|
|
||||||
{ id: 'remote-a', title: 'Remote a', _source: 'peer-a' },
|
|
||||||
{ id: 'remote-b', title: 'Remote b', _source: 'peer-b' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(list).toHaveBeenCalledTimes(2);
|
|
||||||
expect(callOrder).toEqual([
|
|
||||||
'local-start',
|
|
||||||
`remote-start:${PEER_A.id}`,
|
|
||||||
`remote-start:${PEER_B.id}`,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks source="all" as partial and truncated when any subquery returns a cursor', async () => {
|
|
||||||
const list = vi.fn(
|
|
||||||
async (): Promise<FederationListResponse<TestRow>> => ({
|
|
||||||
items: [{ id: 'remote-a', title: 'Remote A' }],
|
|
||||||
nextCursor: 'remote-next',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
|
|
||||||
|
|
||||||
const result = await service.list<TestRow>({
|
|
||||||
source: 'all',
|
|
||||||
resource: 'tasks',
|
|
||||||
request: {},
|
|
||||||
local: () => makeLocalResponse([{ id: 'local-1', title: 'Local One' }]),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
items: [
|
|
||||||
{ id: 'local-1', title: 'Local One', _source: 'local' },
|
|
||||||
{ id: 'remote-a', title: 'Remote A', _source: 'peer-a' },
|
|
||||||
],
|
|
||||||
_partial: true,
|
|
||||||
_truncated: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns _partial=true for source="all" when one peer fails without dropping successful sources', async () => {
|
|
||||||
const list = vi.fn(async (peerId: string): Promise<FederationListResponse<TestRow>> => {
|
|
||||||
if (peerId === PEER_B.id) {
|
|
||||||
throw new FederationClientError({
|
|
||||||
code: 'NETWORK',
|
|
||||||
message: 'peer unavailable',
|
|
||||||
peerId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { items: [{ id: 'remote-a', title: 'Remote A' }] };
|
|
||||||
});
|
|
||||||
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
|
|
||||||
|
|
||||||
const result = await service.list<TestRow>({
|
|
||||||
source: 'all',
|
|
||||||
resource: 'tasks',
|
|
||||||
request: {},
|
|
||||||
local: () => makeLocalResponse([{ id: 'local-1', title: 'Local One' }]),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
items: [
|
|
||||||
{ id: 'local-1', title: 'Local One', _source: 'local' },
|
|
||||||
{ id: 'remote-a', title: 'Remote A', _source: 'peer-a' },
|
|
||||||
],
|
|
||||||
_partial: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws QuerySourceError when a federated host does not match an active outbound peer', async () => {
|
|
||||||
const list = vi.fn(async (): Promise<FederationListResponse<TestRow>> => ({ items: [] }));
|
|
||||||
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.list<TestRow>({
|
|
||||||
source: 'federated:missing.example.com',
|
|
||||||
resource: 'tasks',
|
|
||||||
request: {},
|
|
||||||
local: () => makeLocalResponse(),
|
|
||||||
}),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
name: 'QuerySourceError',
|
|
||||||
code: 'PEER_NOT_FOUND',
|
|
||||||
} satisfies Partial<QuerySourceError>);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -11,13 +11,3 @@ export {
|
|||||||
type FederationClientErrorCode,
|
type FederationClientErrorCode,
|
||||||
type FederationClientErrorOptions,
|
type FederationClientErrorOptions,
|
||||||
} from './federation-client.service.js';
|
} from './federation-client.service.js';
|
||||||
export {
|
|
||||||
QuerySourceService,
|
|
||||||
QuerySourceError,
|
|
||||||
type QuerySource,
|
|
||||||
type QuerySourceErrorCode,
|
|
||||||
type QuerySourceErrorOptions,
|
|
||||||
type QuerySourceListOptions,
|
|
||||||
type QuerySourceListResponse,
|
|
||||||
type LocalListExecutor,
|
|
||||||
} from './query-source.service.js';
|
|
||||||
|
|||||||
@@ -1,261 +0,0 @@
|
|||||||
/**
|
|
||||||
* QuerySourceService — gateway query source router (FED-M3-09).
|
|
||||||
*
|
|
||||||
* Accepts the federation query-layer `source` selector and routes list-style
|
|
||||||
* reads to local storage, one federated peer, or all active outbound peers.
|
|
||||||
*
|
|
||||||
* `source: "all"` is intentionally tolerant of per-peer failures: local data
|
|
||||||
* and successful peer responses are returned, and the envelope is marked
|
|
||||||
* `_partial: true`. Local failures still reject because there is no safe local
|
|
||||||
* fallback and the gateway's own storage is expected to be authoritative.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { and, eq, federationPeers, isNotNull, type Db } from '@mosaicstack/db';
|
|
||||||
import {
|
|
||||||
SOURCE_LOCAL,
|
|
||||||
tagWithSource,
|
|
||||||
type FederationListResponse,
|
|
||||||
type SourceTag,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import { DB } from '../../database/database.module.js';
|
|
||||||
import { FederationClientService } from './federation-client.service.js';
|
|
||||||
|
|
||||||
export type QuerySource = 'local' | 'all' | `federated:${string}`;
|
|
||||||
|
|
||||||
export type QuerySourceErrorCode = 'INVALID_SOURCE' | 'PEER_NOT_FOUND';
|
|
||||||
|
|
||||||
export interface QuerySourceErrorOptions {
|
|
||||||
code: QuerySourceErrorCode;
|
|
||||||
message: string;
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class QuerySourceError extends Error {
|
|
||||||
readonly code: QuerySourceErrorCode;
|
|
||||||
readonly source: string;
|
|
||||||
|
|
||||||
constructor(opts: QuerySourceErrorOptions) {
|
|
||||||
super(opts.message);
|
|
||||||
this.name = 'QuerySourceError';
|
|
||||||
this.code = opts.code;
|
|
||||||
this.source = opts.source;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LocalListExecutor<T extends object> = () => Promise<FederationListResponse<T> | T[]>;
|
|
||||||
|
|
||||||
export interface QuerySourceListOptions<T extends object> {
|
|
||||||
source: QuerySource;
|
|
||||||
resource: string;
|
|
||||||
request?: Record<string, unknown>;
|
|
||||||
local: LocalListExecutor<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QuerySourceListResponse<T extends object> = FederationListResponse<T & SourceTag>;
|
|
||||||
|
|
||||||
interface OutboundPeer {
|
|
||||||
id: string;
|
|
||||||
commonName: string;
|
|
||||||
endpointUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TaggedList<T extends object> {
|
|
||||||
items: Array<T & SourceTag>;
|
|
||||||
partial: boolean;
|
|
||||||
truncated: boolean;
|
|
||||||
nextCursor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class QuerySourceService {
|
|
||||||
private readonly logger = new Logger(QuerySourceService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(DB) private readonly db: Db,
|
|
||||||
@Inject(FederationClientService) private readonly federationClient: FederationClientService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async list<T extends object>(
|
|
||||||
options: QuerySourceListOptions<T>,
|
|
||||||
): Promise<QuerySourceListResponse<T>> {
|
|
||||||
const request = options.request ?? {};
|
|
||||||
|
|
||||||
if (options.source === 'local') {
|
|
||||||
const local = await this.runLocal(options.local);
|
|
||||||
return this.toResponse(this.tagList(local, SOURCE_LOCAL));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.source === 'all') {
|
|
||||||
return this.listAll(options.resource, request, options.local);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.source.startsWith('federated:')) {
|
|
||||||
const host = options.source.slice('federated:'.length).trim();
|
|
||||||
if (!host) {
|
|
||||||
throw new QuerySourceError({
|
|
||||||
code: 'INVALID_SOURCE',
|
|
||||||
message: 'Federated source must include a host after federated:',
|
|
||||||
source: options.source,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const peer = await this.findPeerByHost(host, options.source);
|
|
||||||
const remote = await this.federationClient.list<T>(peer.id, options.resource, request);
|
|
||||||
return this.toResponse(this.tagList(remote, peer.commonName));
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new QuerySourceError({
|
|
||||||
code: 'INVALID_SOURCE',
|
|
||||||
message: `Unsupported query source: ${options.source}`,
|
|
||||||
source: options.source,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listAll<T extends object>(
|
|
||||||
resource: string,
|
|
||||||
request: Record<string, unknown>,
|
|
||||||
local: LocalListExecutor<T>,
|
|
||||||
): Promise<QuerySourceListResponse<T>> {
|
|
||||||
const peers = await this.listActiveOutboundPeers();
|
|
||||||
|
|
||||||
const localPromise = this.runLocal(local).then((response) =>
|
|
||||||
this.tagList(response, SOURCE_LOCAL),
|
|
||||||
);
|
|
||||||
const remotePromises = peers.map(async (peer: OutboundPeer): Promise<TaggedList<T> | null> => {
|
|
||||||
try {
|
|
||||||
const response = await this.federationClient.list<T>(peer.id, resource, request);
|
|
||||||
return this.tagList(response, peer.commonName);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Federated query to peer ${peer.commonName} (${peer.id}) failed; returning partial all-source response: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const [localResult, ...remoteResults] = await Promise.all([localPromise, ...remotePromises]);
|
|
||||||
const successfulRemoteResults = remoteResults.filter(
|
|
||||||
(result: TaggedList<T> | null): result is TaggedList<T> => result !== null,
|
|
||||||
);
|
|
||||||
const allResults = [localResult, ...successfulRemoteResults];
|
|
||||||
const peerFailure = successfulRemoteResults.length !== peers.length;
|
|
||||||
|
|
||||||
return this.mergeTaggedLists(allResults, peerFailure);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async runLocal<T extends object>(
|
|
||||||
local: LocalListExecutor<T>,
|
|
||||||
): Promise<FederationListResponse<T>> {
|
|
||||||
const response = await local();
|
|
||||||
if (Array.isArray(response)) {
|
|
||||||
return { items: response };
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
private tagList<T extends object>(
|
|
||||||
response: FederationListResponse<T>,
|
|
||||||
source: string,
|
|
||||||
): TaggedList<T> {
|
|
||||||
return {
|
|
||||||
items: tagWithSource(response.items, source),
|
|
||||||
partial: response._partial === true,
|
|
||||||
truncated: response._truncated === true || response.nextCursor !== undefined,
|
|
||||||
nextCursor: response.nextCursor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private mergeTaggedLists<T extends object>(
|
|
||||||
lists: Array<TaggedList<T>>,
|
|
||||||
peerFailure: boolean,
|
|
||||||
): QuerySourceListResponse<T> {
|
|
||||||
const items = lists.flatMap((list: TaggedList<T>) => list.items);
|
|
||||||
const partial =
|
|
||||||
peerFailure ||
|
|
||||||
lists.some((list: TaggedList<T>) => list.partial || list.nextCursor !== undefined);
|
|
||||||
const truncated = lists.some((list: TaggedList<T>) => list.truncated);
|
|
||||||
|
|
||||||
const response: QuerySourceListResponse<T> = { items };
|
|
||||||
if (partial) {
|
|
||||||
response._partial = true;
|
|
||||||
}
|
|
||||||
if (truncated) {
|
|
||||||
response._truncated = true;
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
private toResponse<T extends object>(tagged: TaggedList<T>): QuerySourceListResponse<T> {
|
|
||||||
const response: QuerySourceListResponse<T> = {
|
|
||||||
items: tagged.items,
|
|
||||||
};
|
|
||||||
if (tagged.nextCursor !== undefined) {
|
|
||||||
response.nextCursor = tagged.nextCursor;
|
|
||||||
}
|
|
||||||
if (tagged.partial) {
|
|
||||||
response._partial = true;
|
|
||||||
}
|
|
||||||
if (tagged.truncated) {
|
|
||||||
response._truncated = true;
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async findPeerByHost(sourceHost: string, source: string): Promise<OutboundPeer> {
|
|
||||||
const host = normalizeHost(sourceHost);
|
|
||||||
const peers = await this.listActiveOutboundPeers();
|
|
||||||
const peer = peers.find((candidate: OutboundPeer) => {
|
|
||||||
const commonName = normalizeHost(candidate.commonName);
|
|
||||||
const endpointHosts = endpointHostKeys(candidate.endpointUrl).map((endpointHost: string) =>
|
|
||||||
normalizeHost(endpointHost),
|
|
||||||
);
|
|
||||||
return commonName === host || endpointHosts.includes(host);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!peer) {
|
|
||||||
throw new QuerySourceError({
|
|
||||||
code: 'PEER_NOT_FOUND',
|
|
||||||
message: `No active outbound federation peer matches source ${source}`,
|
|
||||||
source,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return peer;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listActiveOutboundPeers(): Promise<OutboundPeer[]> {
|
|
||||||
const rows = await this.db
|
|
||||||
.select({
|
|
||||||
id: federationPeers.id,
|
|
||||||
commonName: federationPeers.commonName,
|
|
||||||
endpointUrl: federationPeers.endpointUrl,
|
|
||||||
})
|
|
||||||
.from(federationPeers)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(federationPeers.state, 'active'),
|
|
||||||
isNotNull(federationPeers.endpointUrl),
|
|
||||||
isNotNull(federationPeers.clientKeyPem),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(federationPeers.commonName);
|
|
||||||
|
|
||||||
return rows.filter((row): row is OutboundPeer => typeof row.endpointUrl === 'string');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeHost(host: string): string {
|
|
||||||
return host.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function endpointHostKeys(endpointUrl: string): string[] {
|
|
||||||
try {
|
|
||||||
const url = new URL(endpointUrl);
|
|
||||||
return Array.from(new Set([url.host, url.hostname].filter((host: string) => host.length > 0)));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,35 +4,26 @@ import { CaService } from './ca.service.js';
|
|||||||
import { EnrollmentController } from './enrollment.controller.js';
|
import { EnrollmentController } from './enrollment.controller.js';
|
||||||
import { EnrollmentService } from './enrollment.service.js';
|
import { EnrollmentService } from './enrollment.service.js';
|
||||||
import { FederationController } from './federation.controller.js';
|
import { FederationController } from './federation.controller.js';
|
||||||
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
|
||||||
import { GrantsService } from './grants.service.js';
|
import { GrantsService } from './grants.service.js';
|
||||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
import { FederationClientService } from './client/index.js';
|
||||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
import { FederationAuthGuard } from './server/index.js';
|
||||||
import { ListController } from './server/verbs/list.controller.js';
|
|
||||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
controllers: [EnrollmentController, FederationController],
|
||||||
providers: [
|
providers: [
|
||||||
AdminGuard,
|
AdminGuard,
|
||||||
CaService,
|
CaService,
|
||||||
EnrollmentService,
|
EnrollmentService,
|
||||||
GrantsService,
|
GrantsService,
|
||||||
FederationClientService,
|
FederationClientService,
|
||||||
QuerySourceService,
|
|
||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
|
||||||
FederationListQueryService,
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CaService,
|
CaService,
|
||||||
EnrollmentService,
|
EnrollmentService,
|
||||||
GrantsService,
|
GrantsService,
|
||||||
FederationClientService,
|
FederationClientService,
|
||||||
QuerySourceService,
|
|
||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
|
||||||
FederationListQueryService,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
@@ -1,324 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for FederationScopeService (FED-M3-04).
|
|
||||||
*
|
|
||||||
* Coverage:
|
|
||||||
* - resource allowlist deny
|
|
||||||
* - excluded resource deny
|
|
||||||
* - invalid scope deny
|
|
||||||
* - invalid requested limit deny
|
|
||||||
* - native RBAC deny as subjectUserId
|
|
||||||
* - scope/native filter intersection for personal and team rows
|
|
||||||
* - native RBAC personal deny wins over scope include_personal allow/default
|
|
||||||
* - max_rows_per_query cap
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { FederationScopeService, type FederationNativeRbacEvaluator } from '../scope.service.js';
|
|
||||||
import type { FederationContext } from '../federation-context.js';
|
|
||||||
|
|
||||||
const GRANT_ID = 'grant-1';
|
|
||||||
const PEER_ID = 'peer-1';
|
|
||||||
const SUBJECT_USER_ID = 'user-1';
|
|
||||||
|
|
||||||
function makeContext(scope: Record<string, unknown>): FederationContext {
|
|
||||||
return {
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
peerId: PEER_ID,
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
scope,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeNativeRbac(
|
|
||||||
result: Awaited<ReturnType<FederationNativeRbacEvaluator['evaluateReadAccess']>>,
|
|
||||||
): FederationNativeRbacEvaluator {
|
|
||||||
return {
|
|
||||||
evaluateReadAccess: vi.fn().mockResolvedValue(result),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('FederationScopeService', () => {
|
|
||||||
let service: FederationScopeService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
service = new FederationScopeService();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows a granted resource and returns a capped query filter', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({
|
|
||||||
resources: ['tasks'],
|
|
||||||
filters: { tasks: { include_teams: ['team-1', 'team-3'], include_personal: true } },
|
|
||||||
max_rows_per_query: 50,
|
|
||||||
}),
|
|
||||||
resource: 'tasks',
|
|
||||||
requestedLimit: 500,
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
resource: 'tasks',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: ['team-1'],
|
|
||||||
limit: 50,
|
|
||||||
maxRowsPerQuery: 50,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).toHaveBeenCalledWith({
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
peerId: PEER_ID,
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
resource: 'tasks',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults absent resource filters to native RBAC personal and team visibility', async () => {
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: ['notes'], max_rows_per_query: 100 }),
|
|
||||||
resource: 'notes',
|
|
||||||
nativeRbac: makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: ['team-1', 'team-2'],
|
|
||||||
limit: 100,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('honors include_personal false even when native RBAC allows personal rows', async () => {
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({
|
|
||||||
resources: ['memory'],
|
|
||||||
filters: { memory: { include_personal: false } },
|
|
||||||
max_rows_per_query: 25,
|
|
||||||
}),
|
|
||||||
resource: 'memory',
|
|
||||||
nativeRbac: makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not leak personal rows when scope allows personal but native RBAC denies personal', async () => {
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({
|
|
||||||
resources: ['tasks'],
|
|
||||||
filters: { tasks: { include_personal: true } },
|
|
||||||
max_rows_per_query: 25,
|
|
||||||
}),
|
|
||||||
resource: 'tasks',
|
|
||||||
nativeRbac: makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: false, teamIds: ['team-1'] },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: ['team-1'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not widen native RBAC when scope includes teams the user cannot access', async () => {
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({
|
|
||||||
resources: ['tasks'],
|
|
||||||
filters: { tasks: { include_teams: ['team-2'], include_personal: false } },
|
|
||||||
max_rows_per_query: 25,
|
|
||||||
}),
|
|
||||||
resource: 'tasks',
|
|
||||||
nativeRbac: makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: ['team-1'] },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies invalid grant scope before RBAC evaluation', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: [], max_rows_per_query: 100 }),
|
|
||||||
resource: 'tasks',
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'invalid_scope',
|
|
||||||
stage: 'scope_parse',
|
|
||||||
statusCode: 400,
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
resource: 'tasks',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies unsupported resource names before RBAC evaluation', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
|
||||||
resource: 'unknown_resource',
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'invalid_resource',
|
|
||||||
stage: 'resource_allowlist',
|
|
||||||
statusCode: 403,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies resources explicitly present in excluded_resources before allowlist miss', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({
|
|
||||||
resources: ['tasks'],
|
|
||||||
excluded_resources: ['credentials'],
|
|
||||||
max_rows_per_query: 100,
|
|
||||||
}),
|
|
||||||
resource: 'credentials',
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'resource_excluded',
|
|
||||||
stage: 'resource_exclusion',
|
|
||||||
statusCode: 403,
|
|
||||||
resource: 'credentials',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies supported resources that are not granted by scope', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
|
||||||
resource: 'notes',
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'resource_not_granted',
|
|
||||||
stage: 'resource_allowlist',
|
|
||||||
statusCode: 403,
|
|
||||||
resource: 'notes',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies invalid requested row limits before RBAC evaluation', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
|
||||||
resource: 'tasks',
|
|
||||||
requestedLimit: 0,
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'invalid_limit',
|
|
||||||
stage: 'row_cap',
|
|
||||||
statusCode: 400,
|
|
||||||
details: { requestedLimit: 0 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies when native RBAC rejects subjectUserId access to the resource', async () => {
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
|
||||||
resource: 'tasks',
|
|
||||||
nativeRbac: makeNativeRbac({
|
|
||||||
allowed: false,
|
|
||||||
reason: 'read:tasks denied',
|
|
||||||
details: { permission: 'tasks:read' },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'native_rbac_denied',
|
|
||||||
stage: 'native_rbac',
|
|
||||||
statusCode: 403,
|
|
||||||
message: 'read:tasks denied',
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
peerId: PEER_ID,
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
resource: 'tasks',
|
|
||||||
details: { permission: 'tasks:read' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,22 +10,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { FederationAuthGuard } from './federation-auth.guard.js';
|
export { FederationAuthGuard } from './federation-auth.guard.js';
|
||||||
export { FederationScopeService } from './scope.service.js';
|
|
||||||
export type { FederationContext } from './federation-context.js';
|
export type { FederationContext } from './federation-context.js';
|
||||||
export type {
|
|
||||||
FederationNativeRbacAccess,
|
|
||||||
FederationNativeRbacAllowedResult,
|
|
||||||
FederationNativeRbacDeniedResult,
|
|
||||||
FederationNativeRbacEvaluator,
|
|
||||||
FederationNativeRbacRequest,
|
|
||||||
FederationNativeRbacResult,
|
|
||||||
FederationScopeAllowedResult,
|
|
||||||
FederationScopeDeniedResult,
|
|
||||||
FederationScopeDenyCode,
|
|
||||||
FederationScopeDenyDetails,
|
|
||||||
FederationScopeDenyReason,
|
|
||||||
FederationScopeDenyStage,
|
|
||||||
FederationScopeEvaluationInput,
|
|
||||||
FederationScopeEvaluationResult,
|
|
||||||
FederationScopeQueryFilter,
|
|
||||||
} from './scope.service.js';
|
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
/**
|
|
||||||
* FederationScopeService — M3 server-side scope enforcement pipeline.
|
|
||||||
*
|
|
||||||
* Pure trust-boundary service: it validates the grant scope, asks an injected
|
|
||||||
* native RBAC evaluator what the subject user can read locally, intersects that
|
|
||||||
* answer with the federation scope filters, and returns a query filter for the
|
|
||||||
* verb controllers. The service performs no DB calls directly.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
FEDERATION_RESOURCE_VALUES,
|
|
||||||
type FederationResource,
|
|
||||||
FederationScopeError,
|
|
||||||
parseFederationScope,
|
|
||||||
} from '../scope-schema.js';
|
|
||||||
import type { FederationContext } from './federation-context.js';
|
|
||||||
|
|
||||||
const federationResourceSet: ReadonlySet<string> = new Set<string>(FEDERATION_RESOURCE_VALUES);
|
|
||||||
|
|
||||||
export type FederationScopeDenyStage =
|
|
||||||
| 'scope_parse'
|
|
||||||
| 'resource_allowlist'
|
|
||||||
| 'resource_exclusion'
|
|
||||||
| 'native_rbac'
|
|
||||||
| 'row_cap';
|
|
||||||
|
|
||||||
export type FederationScopeDenyCode =
|
|
||||||
| 'invalid_scope'
|
|
||||||
| 'invalid_resource'
|
|
||||||
| 'resource_not_granted'
|
|
||||||
| 'resource_excluded'
|
|
||||||
| 'native_rbac_denied'
|
|
||||||
| 'invalid_limit';
|
|
||||||
|
|
||||||
export type FederationScopeDenyStatus = 400 | 403;
|
|
||||||
|
|
||||||
export interface FederationScopeDenyDetails {
|
|
||||||
readonly [key: string]: string | number | boolean | readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationScopeDenyReason {
|
|
||||||
readonly code: FederationScopeDenyCode;
|
|
||||||
readonly stage: FederationScopeDenyStage;
|
|
||||||
readonly statusCode: FederationScopeDenyStatus;
|
|
||||||
readonly message: string;
|
|
||||||
readonly grantId: string;
|
|
||||||
readonly peerId: string;
|
|
||||||
readonly subjectUserId: string;
|
|
||||||
readonly resource: string;
|
|
||||||
readonly details?: FederationScopeDenyDetails;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationNativeRbacRequest {
|
|
||||||
readonly grantId: string;
|
|
||||||
readonly peerId: string;
|
|
||||||
readonly subjectUserId: string;
|
|
||||||
readonly resource: FederationResource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationNativeRbacAccess {
|
|
||||||
/** Whether this user may read personal rows for this resource. */
|
|
||||||
readonly includePersonal: boolean;
|
|
||||||
|
|
||||||
/** Team IDs this user may read for this resource under native RBAC. */
|
|
||||||
readonly teamIds: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationNativeRbacAllowedResult {
|
|
||||||
readonly allowed: true;
|
|
||||||
readonly access: FederationNativeRbacAccess;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationNativeRbacDeniedResult {
|
|
||||||
readonly allowed: false;
|
|
||||||
readonly reason?: string;
|
|
||||||
readonly details?: FederationScopeDenyDetails;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FederationNativeRbacResult =
|
|
||||||
| FederationNativeRbacAllowedResult
|
|
||||||
| FederationNativeRbacDeniedResult;
|
|
||||||
|
|
||||||
export interface FederationNativeRbacEvaluator {
|
|
||||||
evaluateReadAccess(request: FederationNativeRbacRequest): Promise<FederationNativeRbacResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationScopeEvaluationInput {
|
|
||||||
readonly context: FederationContext;
|
|
||||||
readonly resource: string;
|
|
||||||
readonly requestedLimit?: number;
|
|
||||||
readonly nativeRbac: FederationNativeRbacEvaluator;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationScopeQueryFilter {
|
|
||||||
readonly resource: FederationResource;
|
|
||||||
readonly subjectUserId: string;
|
|
||||||
readonly includePersonal: boolean;
|
|
||||||
readonly teamIds: readonly string[];
|
|
||||||
readonly limit: number;
|
|
||||||
readonly maxRowsPerQuery: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationScopeAllowedResult {
|
|
||||||
readonly allowed: true;
|
|
||||||
readonly filter: FederationScopeQueryFilter;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationScopeDeniedResult {
|
|
||||||
readonly allowed: false;
|
|
||||||
readonly deny: FederationScopeDenyReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FederationScopeEvaluationResult =
|
|
||||||
| FederationScopeAllowedResult
|
|
||||||
| FederationScopeDeniedResult;
|
|
||||||
|
|
||||||
function isFederationResource(resource: string): resource is FederationResource {
|
|
||||||
return federationResourceSet.has(resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueStrings(values: readonly string[]): readonly string[] {
|
|
||||||
return Array.from(new Set<string>(values));
|
|
||||||
}
|
|
||||||
|
|
||||||
function intersectTeamIds(
|
|
||||||
nativeTeamIds: readonly string[],
|
|
||||||
scopedTeamIds: readonly string[] | undefined,
|
|
||||||
): readonly string[] {
|
|
||||||
const uniqueNativeTeamIds = uniqueStrings(nativeTeamIds);
|
|
||||||
|
|
||||||
if (scopedTeamIds === undefined) {
|
|
||||||
return uniqueNativeTeamIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nativeSet = new Set<string>(uniqueNativeTeamIds);
|
|
||||||
return uniqueStrings(scopedTeamIds).filter((teamId: string): boolean => nativeSet.has(teamId));
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDenyReason(params: {
|
|
||||||
readonly code: FederationScopeDenyCode;
|
|
||||||
readonly stage: FederationScopeDenyStage;
|
|
||||||
readonly statusCode?: FederationScopeDenyStatus;
|
|
||||||
readonly message: string;
|
|
||||||
readonly context: FederationContext;
|
|
||||||
readonly resource: string;
|
|
||||||
readonly details?: FederationScopeDenyDetails;
|
|
||||||
}): FederationScopeDeniedResult {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: params.code,
|
|
||||||
stage: params.stage,
|
|
||||||
statusCode: params.statusCode ?? 403,
|
|
||||||
message: params.message,
|
|
||||||
grantId: params.context.grantId,
|
|
||||||
peerId: params.context.peerId,
|
|
||||||
subjectUserId: params.context.subjectUserId,
|
|
||||||
resource: params.resource,
|
|
||||||
...(params.details !== undefined ? { details: params.details } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FederationScopeService {
|
|
||||||
async evaluateAccess(
|
|
||||||
input: FederationScopeEvaluationInput,
|
|
||||||
): Promise<FederationScopeEvaluationResult> {
|
|
||||||
const { context, resource, requestedLimit, nativeRbac } = input;
|
|
||||||
|
|
||||||
let scope: ReturnType<typeof parseFederationScope>;
|
|
||||||
try {
|
|
||||||
scope = parseFederationScope(context.scope);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message =
|
|
||||||
error instanceof FederationScopeError
|
|
||||||
? 'Federation grant scope is invalid'
|
|
||||||
: 'Federation grant scope could not be parsed';
|
|
||||||
const details = error instanceof Error ? { reason: error.message } : undefined;
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'invalid_scope',
|
|
||||||
stage: 'scope_parse',
|
|
||||||
statusCode: 400,
|
|
||||||
message,
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
...(details !== undefined ? { details } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isFederationResource(resource)) {
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'invalid_resource',
|
|
||||||
stage: 'resource_allowlist',
|
|
||||||
message: 'Requested federation resource is not supported',
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
details: { supportedResources: FEDERATION_RESOURCE_VALUES },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scope.excluded_resources.includes(resource)) {
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'resource_excluded',
|
|
||||||
stage: 'resource_exclusion',
|
|
||||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!scope.resources.includes(resource)) {
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'resource_not_granted',
|
|
||||||
stage: 'resource_allowlist',
|
|
||||||
message: 'Requested federation resource is not granted by scope',
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
details: { grantedResources: scope.resources },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestedLimit !== undefined && (!Number.isInteger(requestedLimit) || requestedLimit < 1)) {
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'invalid_limit',
|
|
||||||
stage: 'row_cap',
|
|
||||||
statusCode: 400,
|
|
||||||
message: 'Requested row limit must be a positive integer',
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
details: { requestedLimit },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const nativeResult = await nativeRbac.evaluateReadAccess({
|
|
||||||
grantId: context.grantId,
|
|
||||||
peerId: context.peerId,
|
|
||||||
subjectUserId: context.subjectUserId,
|
|
||||||
resource,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!nativeResult.allowed) {
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'native_rbac_denied',
|
|
||||||
stage: 'native_rbac',
|
|
||||||
message: nativeResult.reason ?? 'Subject user is not allowed to read this resource',
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
...(nativeResult.details !== undefined ? { details: nativeResult.details } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopeFilter = scope.filters?.[resource];
|
|
||||||
const includePersonal =
|
|
||||||
Boolean(scopeFilter?.include_personal ?? true) && nativeResult.access.includePersonal;
|
|
||||||
const teamIds = intersectTeamIds(nativeResult.access.teamIds, scopeFilter?.include_teams);
|
|
||||||
const limit = Math.min(requestedLimit ?? scope.max_rows_per_query, scope.max_rows_per_query);
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
resource,
|
|
||||||
subjectUserId: context.subjectUserId,
|
|
||||||
includePersonal,
|
|
||||||
teamIds,
|
|
||||||
limit,
|
|
||||||
maxRowsPerQuery: scope.max_rows_per_query,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { RequestMethod } from '@nestjs/common';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
import { FederationCapabilitiesResponseSchema, FEDERATION_VERBS } from '@mosaicstack/types';
|
|
||||||
import { FederationScopeError } from '../../../scope-schema.js';
|
|
||||||
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
|
||||||
import { CapabilitiesController } from '../capabilities.controller.js';
|
|
||||||
|
|
||||||
const VALID_SCOPE = {
|
|
||||||
resources: ['tasks', 'notes'],
|
|
||||||
excluded_resources: ['credentials'],
|
|
||||||
max_rows_per_query: 250,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const DEFAULTED_SCOPE = {
|
|
||||||
resources: ['memory'],
|
|
||||||
max_rows_per_query: 10,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function makeRequest(scope: Record<string, unknown>): FastifyRequest {
|
|
||||||
return {
|
|
||||||
federationContext: {
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
scope,
|
|
||||||
},
|
|
||||||
} as FastifyRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('CapabilitiesController', () => {
|
|
||||||
it('declares GET /api/federation/v1/capabilities', () => {
|
|
||||||
expect(Reflect.getMetadata('path', CapabilitiesController)).toBe(
|
|
||||||
'api/federation/v1/capabilities',
|
|
||||||
);
|
|
||||||
expect(Reflect.getMetadata('path', CapabilitiesController.prototype.getCapabilities)).toBe('/');
|
|
||||||
expect(Reflect.getMetadata('method', CapabilitiesController.prototype.getCapabilities)).toBe(
|
|
||||||
RequestMethod.GET,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is protected only by FederationAuthGuard', () => {
|
|
||||||
const guards = Reflect.getMetadata('__guards__', CapabilitiesController) as unknown[];
|
|
||||||
|
|
||||||
expect(guards).toEqual([FederationAuthGuard]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns resources, excluded resources, max rows, and M3 supported verbs from the active grant scope', () => {
|
|
||||||
const controller = new CapabilitiesController();
|
|
||||||
|
|
||||||
const response = controller.getCapabilities(makeRequest(VALID_SCOPE));
|
|
||||||
|
|
||||||
expect(response).toEqual({
|
|
||||||
resources: ['tasks', 'notes'],
|
|
||||||
excluded_resources: ['credentials'],
|
|
||||||
max_rows_per_query: 250,
|
|
||||||
supported_verbs: [...FEDERATION_VERBS],
|
|
||||||
});
|
|
||||||
expect(FederationCapabilitiesResponseSchema.safeParse(response).success).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies scope defaults without RBAC or resource filtering', () => {
|
|
||||||
const controller = new CapabilitiesController();
|
|
||||||
|
|
||||||
const response = controller.getCapabilities(makeRequest(DEFAULTED_SCOPE));
|
|
||||||
|
|
||||||
expect(response).toEqual({
|
|
||||||
resources: ['memory'],
|
|
||||||
excluded_resources: [],
|
|
||||||
max_rows_per_query: 10,
|
|
||||||
supported_verbs: ['list', 'get', 'capabilities'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid scope state instead of returning an invalid capabilities contract', () => {
|
|
||||||
const controller = new CapabilitiesController();
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
controller.getCapabilities(
|
|
||||||
makeRequest({
|
|
||||||
resources: [],
|
|
||||||
max_rows_per_query: 0,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).toThrow(FederationScopeError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
createPgliteDb,
|
|
||||||
insights,
|
|
||||||
missionTasks,
|
|
||||||
missions,
|
|
||||||
preferences,
|
|
||||||
projects,
|
|
||||||
runPgliteMigrations,
|
|
||||||
teams,
|
|
||||||
users,
|
|
||||||
type Db,
|
|
||||||
type DbHandle,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import type { FederationScopeQueryFilter } from '../../scope.service.js';
|
|
||||||
import { FederationListQueryService } from '../list-query.service.js';
|
|
||||||
|
|
||||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
|
||||||
resource: 'tasks',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: [],
|
|
||||||
limit: 2,
|
|
||||||
maxRowsPerQuery: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SUBJECT_USER_ID = 'fed-m3-05-subject';
|
|
||||||
const OTHER_USER_ID = 'fed-m3-05-other';
|
|
||||||
const TEAM_ID = '05000000-0000-4000-8000-000000000001';
|
|
||||||
const UNAUTHORIZED_TEAM_ID = '05000000-0000-4000-8000-000000000002';
|
|
||||||
const PERSONAL_PROJECT_ID = '05000000-0000-4000-8000-000000000101';
|
|
||||||
const TEAM_PROJECT_ID = '05000000-0000-4000-8000-000000000102';
|
|
||||||
const UNAUTHORIZED_PROJECT_ID = '05000000-0000-4000-8000-000000000103';
|
|
||||||
const PERSONAL_MISSION_ID = '05000000-0000-4000-8000-000000000201';
|
|
||||||
const TEAM_MISSION_ID = '05000000-0000-4000-8000-000000000202';
|
|
||||||
const UNAUTHORIZED_MISSION_ID = '05000000-0000-4000-8000-000000000203';
|
|
||||||
const SUBJECT_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000301';
|
|
||||||
const OTHER_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000302';
|
|
||||||
const SUBJECT_PERSONAL_NOTE_ID = '05000000-0000-4000-8000-000000000303';
|
|
||||||
const SUBJECT_UNAUTHORIZED_NOTE_ID = '05000000-0000-4000-8000-000000000304';
|
|
||||||
const INSIGHT_ONE_ID = '05000000-0000-4000-8000-000000000401';
|
|
||||||
const INSIGHT_TWO_ID = '05000000-0000-4000-8000-000000000402';
|
|
||||||
const PREFERENCE_ONE_ID = '05000000-0000-4000-8000-000000000501';
|
|
||||||
const PREFERENCE_TWO_ID = '05000000-0000-4000-8000-000000000502';
|
|
||||||
|
|
||||||
let dbHandle: DbHandle | undefined;
|
|
||||||
|
|
||||||
function makeService() {
|
|
||||||
return new FederationListQueryService({} as Db);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDbService() {
|
|
||||||
if (!dbHandle) {
|
|
||||||
throw new Error('test DB not initialized');
|
|
||||||
}
|
|
||||||
return new FederationListQueryService(dbHandle.db);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedNotesFixture() {
|
|
||||||
if (!dbHandle) {
|
|
||||||
throw new Error('test DB not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
await dbHandle.db.insert(users).values([
|
|
||||||
{
|
|
||||||
id: SUBJECT_USER_ID,
|
|
||||||
name: 'Federation Subject',
|
|
||||||
email: `${SUBJECT_USER_ID}@example.test`,
|
|
||||||
emailVerified: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: OTHER_USER_ID,
|
|
||||||
name: 'Federation Other',
|
|
||||||
email: `${OTHER_USER_ID}@example.test`,
|
|
||||||
emailVerified: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(teams).values([
|
|
||||||
{
|
|
||||||
id: TEAM_ID,
|
|
||||||
name: 'FED-M3-05 Team',
|
|
||||||
slug: 'fed-m3-05-team',
|
|
||||||
ownerId: SUBJECT_USER_ID,
|
|
||||||
managerId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: UNAUTHORIZED_TEAM_ID,
|
|
||||||
name: 'FED-M3-05 Unauthorized Team',
|
|
||||||
slug: 'fed-m3-05-unauthorized-team',
|
|
||||||
ownerId: OTHER_USER_ID,
|
|
||||||
managerId: OTHER_USER_ID,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(projects).values([
|
|
||||||
{
|
|
||||||
id: PERSONAL_PROJECT_ID,
|
|
||||||
name: 'FED-M3-05 Personal Project',
|
|
||||||
ownerId: SUBJECT_USER_ID,
|
|
||||||
ownerType: 'user',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: TEAM_PROJECT_ID,
|
|
||||||
name: 'FED-M3-05 Team Project',
|
|
||||||
teamId: TEAM_ID,
|
|
||||||
ownerType: 'team',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: UNAUTHORIZED_PROJECT_ID,
|
|
||||||
name: 'FED-M3-05 Unauthorized Project',
|
|
||||||
teamId: UNAUTHORIZED_TEAM_ID,
|
|
||||||
ownerType: 'team',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(missions).values([
|
|
||||||
{
|
|
||||||
id: PERSONAL_MISSION_ID,
|
|
||||||
name: 'FED-M3-05 Personal Mission',
|
|
||||||
projectId: PERSONAL_PROJECT_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: TEAM_MISSION_ID,
|
|
||||||
name: 'FED-M3-05 Team Mission',
|
|
||||||
projectId: TEAM_PROJECT_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: UNAUTHORIZED_MISSION_ID,
|
|
||||||
name: 'FED-M3-05 Unauthorized Mission',
|
|
||||||
projectId: UNAUTHORIZED_PROJECT_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(missionTasks).values([
|
|
||||||
{
|
|
||||||
id: SUBJECT_TEAM_NOTE_ID,
|
|
||||||
missionId: TEAM_MISSION_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
notes: 'subject note on team mission',
|
|
||||||
createdAt: new Date('2026-06-24T03:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: OTHER_TEAM_NOTE_ID,
|
|
||||||
missionId: TEAM_MISSION_ID,
|
|
||||||
userId: OTHER_USER_ID,
|
|
||||||
notes: 'other user note on team mission',
|
|
||||||
createdAt: new Date('2026-06-24T02:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
|
||||||
missionId: PERSONAL_MISSION_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
notes: 'subject note on personal mission',
|
|
||||||
createdAt: new Date('2026-06-24T01:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
|
||||||
missionId: UNAUTHORIZED_MISSION_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
notes: 'subject note outside grant-visible missions',
|
|
||||||
createdAt: new Date('2026-06-24T04:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const memoryCreatedAt = new Date('2026-06-24T05:00:00.000Z');
|
|
||||||
await dbHandle.db.insert(insights).values([
|
|
||||||
{
|
|
||||||
id: INSIGHT_ONE_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
content: 'first insight',
|
|
||||||
source: 'agent',
|
|
||||||
createdAt: memoryCreatedAt,
|
|
||||||
updatedAt: memoryCreatedAt,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: INSIGHT_TWO_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
content: 'second insight',
|
|
||||||
source: 'agent',
|
|
||||||
createdAt: memoryCreatedAt,
|
|
||||||
updatedAt: memoryCreatedAt,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(preferences).values([
|
|
||||||
{
|
|
||||||
id: PREFERENCE_ONE_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
key: 'fed-m3-05-pref-1',
|
|
||||||
value: { enabled: true },
|
|
||||||
createdAt: memoryCreatedAt,
|
|
||||||
updatedAt: memoryCreatedAt,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: PREFERENCE_TWO_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
key: 'fed-m3-05-pref-2',
|
|
||||||
value: { enabled: false },
|
|
||||||
createdAt: memoryCreatedAt,
|
|
||||||
updatedAt: memoryCreatedAt,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stubRows(
|
|
||||||
service: FederationListQueryService,
|
|
||||||
...pages: Array<Array<Record<string, unknown>>>
|
|
||||||
) {
|
|
||||||
const mock = vi.fn();
|
|
||||||
for (const page of pages) {
|
|
||||||
mock.mockResolvedValueOnce(page);
|
|
||||||
}
|
|
||||||
(
|
|
||||||
service as unknown as {
|
|
||||||
listAllRows: (
|
|
||||||
_filter: FederationScopeQueryFilter,
|
|
||||||
_rowLimit: number,
|
|
||||||
_cursor: unknown,
|
|
||||||
) => Promise<Array<Record<string, unknown>>>;
|
|
||||||
}
|
|
||||||
).listAllRows = mock;
|
|
||||||
return mock;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('FederationListQueryService', () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
dbHandle = createPgliteDb(`memory://fed-m3-05-list-${Date.now()}`);
|
|
||||||
await runPgliteMigrations(dbHandle);
|
|
||||||
await seedNotesFixture();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await dbHandle?.close();
|
|
||||||
dbHandle = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies sensitive resources in native RBAC for M3 list reads', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.evaluateReadAccess({
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
resource: 'credentials',
|
|
||||||
}),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
reason: 'credentials federation list access is not implemented in M3',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows personal memory reads without requiring team lookup', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.evaluateReadAccess({
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
resource: 'memory',
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies the scope row cap and returns an opaque next cursor when truncated', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
const listAllRows = stubRows(
|
|
||||||
service,
|
|
||||||
[
|
|
||||||
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
|
|
||||||
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
|
|
||||||
{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') },
|
|
||||||
],
|
|
||||||
[{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
|
|
||||||
);
|
|
||||||
|
|
||||||
const firstPage = await service.list({ filter: TASK_FILTER });
|
|
||||||
|
|
||||||
expect(firstPage).toEqual({
|
|
||||||
items: [
|
|
||||||
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
|
|
||||||
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
|
|
||||||
],
|
|
||||||
truncated: true,
|
|
||||||
nextCursor: expect.any(String),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(listAllRows).toHaveBeenNthCalledWith(1, TASK_FILTER, 3, undefined);
|
|
||||||
|
|
||||||
const secondPage = await service.list({ filter: TASK_FILTER, cursor: firstPage.nextCursor });
|
|
||||||
expect(secondPage).toEqual({
|
|
||||||
items: [{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
|
|
||||||
truncated: false,
|
|
||||||
});
|
|
||||||
expect(listAllRows).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
TASK_FILTER,
|
|
||||||
3,
|
|
||||||
expect.objectContaining({ id: '2' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid cursors instead of falling back to the first page', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
stubRows(service, [{ id: '1' }]);
|
|
||||||
|
|
||||||
await expect(service.list({ filter: TASK_FILTER, cursor: 'not-base64-json' })).rejects.toThrow(
|
|
||||||
'Invalid federation list cursor',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when a truncated page cannot encode a resumable cursor', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
stubRows(service, [
|
|
||||||
{ id: '2', createdAt: 'not-a-date' },
|
|
||||||
{ id: '1', createdAt: 'not-a-date' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
await expect(service.list({ filter: { ...TASK_FILTER, limit: 1 } })).rejects.toThrow(
|
|
||||||
'Federation list cursor cannot be encoded',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws on unsupported resources instead of crashing pagination', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.list({
|
|
||||||
filter: {
|
|
||||||
...TASK_FILTER,
|
|
||||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('Unsupported federation list resource');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not leak another user mission task notes through team-scoped note reads', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
|
|
||||||
const result = await service.list({
|
|
||||||
filter: {
|
|
||||||
resource: 'notes',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [TEAM_ID],
|
|
||||||
limit: 10,
|
|
||||||
maxRowsPerQuery: 10,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ids = result.items.map((item) => item['id']);
|
|
||||||
expect(ids).toEqual([SUBJECT_TEAM_NOTE_ID]);
|
|
||||||
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not return subject personal mission task notes when includePersonal is false', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
|
|
||||||
const result = await service.list({
|
|
||||||
filter: {
|
|
||||||
resource: 'notes',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [TEAM_ID],
|
|
||||||
limit: 10,
|
|
||||||
maxRowsPerQuery: 10,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.items.map((item) => item['id'])).not.toContain(SUBJECT_PERSONAL_NOTE_ID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not return subject notes from missions outside the grant-visible project set', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
|
|
||||||
const result = await service.list({
|
|
||||||
filter: {
|
|
||||||
resource: 'notes',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: [TEAM_ID],
|
|
||||||
limit: 10,
|
|
||||||
maxRowsPerQuery: 10,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ids = result.items.map((item) => item['id']);
|
|
||||||
expect(ids).toContain(SUBJECT_PERSONAL_NOTE_ID);
|
|
||||||
expect(ids).toContain(SUBJECT_TEAM_NOTE_ID);
|
|
||||||
expect(ids).not.toContain(SUBJECT_UNAUTHORIZED_NOTE_ID);
|
|
||||||
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('paginates memory deterministically across insights and preferences', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
const filter: FederationScopeQueryFilter = {
|
|
||||||
resource: 'memory',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: [],
|
|
||||||
limit: 2,
|
|
||||||
maxRowsPerQuery: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const firstPage = await service.list({ filter });
|
|
||||||
const secondPage = await service.list({ filter, cursor: firstPage.nextCursor });
|
|
||||||
const firstPageIds = firstPage.items.map((item) => item['id']);
|
|
||||||
const secondPageIds = secondPage.items.map((item) => item['id']);
|
|
||||||
const allIds = [...firstPageIds, ...secondPageIds];
|
|
||||||
|
|
||||||
expect(firstPage).toMatchObject({ truncated: true, nextCursor: expect.any(String) });
|
|
||||||
expect(firstPageIds).toEqual([INSIGHT_TWO_ID, INSIGHT_ONE_ID]);
|
|
||||||
expect(secondPageIds).toEqual([PREFERENCE_TWO_ID, PREFERENCE_ONE_ID]);
|
|
||||||
expect(new Set(allIds).size).toBe(allIds.length);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { RequestMethod } from '@nestjs/common';
|
|
||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
|
||||||
import type {
|
|
||||||
FederationScopeEvaluationResult,
|
|
||||||
FederationScopeQueryFilter,
|
|
||||||
} from '../../scope.service.js';
|
|
||||||
import { ListController } from '../list.controller.js';
|
|
||||||
import type { FederationListQueryResult } from '../list-query.service.js';
|
|
||||||
|
|
||||||
const FEDERATION_CONTEXT = {
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
scope: { resources: ['tasks'], max_rows_per_query: 25 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
|
||||||
resource: 'tasks',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: ['team-1'],
|
|
||||||
limit: 10,
|
|
||||||
maxRowsPerQuery: 25,
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeRequest(): FastifyRequest {
|
|
||||||
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function allowedScope(
|
|
||||||
filter: FederationScopeQueryFilter = TASK_FILTER,
|
|
||||||
): FederationScopeEvaluationResult {
|
|
||||||
return { allowed: true, filter };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeController(opts?: {
|
|
||||||
scopeResult?: FederationScopeEvaluationResult;
|
|
||||||
queryResult?: FederationListQueryResult;
|
|
||||||
}) {
|
|
||||||
const scope = {
|
|
||||||
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
|
|
||||||
};
|
|
||||||
const query = {
|
|
||||||
evaluateReadAccess: vi.fn(),
|
|
||||||
list: vi.fn().mockResolvedValue(
|
|
||||||
opts?.queryResult ?? {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Federated task',
|
|
||||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
truncated: false,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
controller: new ListController(scope as never, query as never),
|
|
||||||
scope,
|
|
||||||
query,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ListController', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('declares POST /api/federation/v1/list/:resource protected only by FederationAuthGuard', () => {
|
|
||||||
expect(Reflect.getMetadata('path', ListController)).toBe('api/federation/v1/list');
|
|
||||||
expect(Reflect.getMetadata('path', ListController.prototype.list)).toBe(':resource');
|
|
||||||
expect(Reflect.getMetadata('method', ListController.prototype.list)).toBe(RequestMethod.POST);
|
|
||||||
expect(Reflect.getMetadata('__guards__', ListController)).toEqual([FederationAuthGuard]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs AuthGuard context through ScopeService and returns local-source tagged rows', async () => {
|
|
||||||
const { controller, scope, query } = makeController();
|
|
||||||
|
|
||||||
const response = await controller.list('tasks', makeRequest(), { limit: 10 });
|
|
||||||
|
|
||||||
expect(scope.evaluateAccess).toHaveBeenCalledWith({
|
|
||||||
context: FEDERATION_CONTEXT,
|
|
||||||
resource: 'tasks',
|
|
||||||
requestedLimit: 10,
|
|
||||||
nativeRbac: query,
|
|
||||||
});
|
|
||||||
expect(query.list).toHaveBeenCalledWith({ filter: TASK_FILTER, cursor: undefined });
|
|
||||||
expect(response).toEqual({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Federated task',
|
|
||||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
|
||||||
_source: 'local',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves pagination metadata when row cap truncates the query layer result', async () => {
|
|
||||||
const { controller } = makeController({
|
|
||||||
queryResult: {
|
|
||||||
items: [{ id: 'task-1' }],
|
|
||||||
nextCursor: 'cursor-2',
|
|
||||||
truncated: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await controller.list('tasks', makeRequest(), { cursor: 'cursor-1' });
|
|
||||||
|
|
||||||
expect(response).toEqual({
|
|
||||||
items: [{ id: 'task-1', _source: 'local' }],
|
|
||||||
nextCursor: 'cursor-2',
|
|
||||||
_truncated: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a federation error envelope when auth guard context is missing', async () => {
|
|
||||||
const { controller, scope, query } = makeController();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.list('tasks', {} as unknown as FastifyRequest, {}),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
response: {
|
|
||||||
error: {
|
|
||||||
code: 'unauthorized',
|
|
||||||
message: 'Federation context missing',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 401,
|
|
||||||
});
|
|
||||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
|
||||||
expect(query.list).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a federation error envelope when scope evaluation denies access', async () => {
|
|
||||||
const { controller, query } = makeController({
|
|
||||||
scopeResult: {
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'resource_excluded',
|
|
||||||
stage: 'resource_exclusion',
|
|
||||||
statusCode: 403,
|
|
||||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
resource: 'credentials',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(controller.list('credentials', makeRequest(), {})).rejects.toMatchObject({
|
|
||||||
response: {
|
|
||||||
error: {
|
|
||||||
code: 'scope_violation',
|
|
||||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
expect(query.list).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects malformed request body fields before querying storage', async () => {
|
|
||||||
const { controller, scope, query } = makeController();
|
|
||||||
|
|
||||||
await expect(controller.list('tasks', makeRequest(), { cursor: 123 })).rejects.toMatchObject({
|
|
||||||
response: { error: { code: 'invalid_request' } },
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
await expect(controller.list('tasks', makeRequest(), { limit: false })).rejects.toMatchObject({
|
|
||||||
response: { error: { code: 'invalid_request' } },
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
await expect(controller.list('tasks', makeRequest(), { limit: 'abc' })).rejects.toMatchObject({
|
|
||||||
response: { error: { code: 'invalid_request' } },
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
|
||||||
expect(query.list).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation capabilities verb (FED-M3-07).
|
|
||||||
*
|
|
||||||
* Returns the read-only capability envelope for the active grant attached by
|
|
||||||
* FederationAuthGuard. This endpoint intentionally does not invoke native RBAC
|
|
||||||
* or ScopeService: an active grant is enough to ask what the grant allows.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
|
||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
import {
|
|
||||||
FEDERATION_VERBS,
|
|
||||||
type FederationCapabilitiesResponse,
|
|
||||||
type FederationVerb,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import { parseFederationScope } from '../../scope-schema.js';
|
|
||||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
|
||||||
import '../federation-context.js';
|
|
||||||
|
|
||||||
@Controller('api/federation/v1/capabilities')
|
|
||||||
@UseGuards(FederationAuthGuard)
|
|
||||||
export class CapabilitiesController {
|
|
||||||
@Get()
|
|
||||||
getCapabilities(@Req() request: FastifyRequest): FederationCapabilitiesResponse {
|
|
||||||
if (!request.federationContext) {
|
|
||||||
throw new Error('Federation context missing after auth guard');
|
|
||||||
}
|
|
||||||
|
|
||||||
const scope = parseFederationScope(request.federationContext.scope);
|
|
||||||
|
|
||||||
return {
|
|
||||||
resources: [...scope.resources],
|
|
||||||
excluded_resources: [...scope.excluded_resources],
|
|
||||||
max_rows_per_query: scope.max_rows_per_query,
|
|
||||||
supported_verbs: [...FEDERATION_VERBS] satisfies FederationVerb[],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation list query layer (FED-M3-05).
|
|
||||||
*
|
|
||||||
* Read-only DB adapter used by ListController after FederationAuthGuard and
|
|
||||||
* FederationScopeService have established the subject user, allowed resource,
|
|
||||||
* native-RBAC intersection, and row cap. Audit writes are intentionally
|
|
||||||
* deferred to M4.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
and,
|
|
||||||
desc,
|
|
||||||
eq,
|
|
||||||
inArray,
|
|
||||||
insights,
|
|
||||||
isNotNull,
|
|
||||||
lt,
|
|
||||||
missionTasks,
|
|
||||||
missions,
|
|
||||||
or,
|
|
||||||
preferences,
|
|
||||||
projects,
|
|
||||||
tasks,
|
|
||||||
teamMembers,
|
|
||||||
type Db,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import type {
|
|
||||||
FederationNativeRbacEvaluator,
|
|
||||||
FederationNativeRbacRequest,
|
|
||||||
FederationNativeRbacResult,
|
|
||||||
FederationScopeQueryFilter,
|
|
||||||
} from '../scope.service.js';
|
|
||||||
import { DB } from '../../../database/database.module.js';
|
|
||||||
|
|
||||||
export interface FederationListQueryRequest {
|
|
||||||
readonly filter: FederationScopeQueryFilter;
|
|
||||||
readonly cursor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationListQueryResult<T extends object = Record<string, unknown>> {
|
|
||||||
readonly items: T[];
|
|
||||||
readonly nextCursor?: string;
|
|
||||||
readonly truncated: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type CursorSource = 'insights' | 'preferences';
|
|
||||||
const CURSOR_SOURCE = Symbol('federationCursorSource');
|
|
||||||
|
|
||||||
type RowObject = Record<string, unknown> & { readonly [CURSOR_SOURCE]?: CursorSource };
|
|
||||||
|
|
||||||
interface KeysetCursor {
|
|
||||||
readonly createdAt: Date;
|
|
||||||
readonly id: string;
|
|
||||||
readonly source?: CursorSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeCursor(row: RowObject): string {
|
|
||||||
const createdAt = row['createdAt'];
|
|
||||||
const id = row['id'];
|
|
||||||
if (!(createdAt instanceof Date) || typeof id !== 'string') {
|
|
||||||
throw new Error('Federation list cursor cannot be encoded');
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = row[CURSOR_SOURCE];
|
|
||||||
return Buffer.from(
|
|
||||||
JSON.stringify({ createdAt: createdAt.toISOString(), id, ...(source ? { source } : {}) }),
|
|
||||||
'utf8',
|
|
||||||
).toString('base64url');
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeCursor(cursor: string | undefined): KeysetCursor | undefined {
|
|
||||||
if (cursor === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
|
|
||||||
if (typeof parsed !== 'object' || parsed === null) {
|
|
||||||
throw new Error('cursor must be an object');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { createdAt, id, source } = parsed as {
|
|
||||||
createdAt?: unknown;
|
|
||||||
id?: unknown;
|
|
||||||
source?: unknown;
|
|
||||||
};
|
|
||||||
if (typeof createdAt !== 'string' || typeof id !== 'string' || id.length === 0) {
|
|
||||||
throw new Error('cursor is missing createdAt or id');
|
|
||||||
}
|
|
||||||
if (source !== undefined && source !== 'insights' && source !== 'preferences') {
|
|
||||||
throw new Error('cursor source is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(createdAt);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
throw new Error('cursor createdAt is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { createdAt: date, id, ...(source ? { source } : {}) };
|
|
||||||
} catch {
|
|
||||||
throw new Error('Invalid federation list cursor');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function paginate<T extends RowObject>(rows: T[], limit: number): FederationListQueryResult<T> {
|
|
||||||
const page = rows.slice(0, limit);
|
|
||||||
const hasMore = rows.length > limit;
|
|
||||||
const nextCursor = hasMore ? encodeCursor(page[page.length - 1] ?? {}) : undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: page,
|
|
||||||
truncated: hasMore,
|
|
||||||
...(nextCursor !== undefined ? { nextCursor } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function markCursorSource<T extends RowObject>(row: T, source: CursorSource): T {
|
|
||||||
Object.defineProperty(row, CURSOR_SOURCE, {
|
|
||||||
value: source,
|
|
||||||
enumerable: false,
|
|
||||||
configurable: false,
|
|
||||||
});
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortRows(rows: RowObject[]): RowObject[] {
|
|
||||||
return [...rows].sort((a, b) => {
|
|
||||||
const aTime = a['createdAt'] instanceof Date ? a['createdAt'].getTime() : 0;
|
|
||||||
const bTime = b['createdAt'] instanceof Date ? b['createdAt'].getTime() : 0;
|
|
||||||
if (aTime !== bTime) {
|
|
||||||
return bTime - aTime;
|
|
||||||
}
|
|
||||||
return String(b['id'] ?? '').localeCompare(String(a['id'] ?? ''));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FederationListQueryService implements FederationNativeRbacEvaluator {
|
|
||||||
constructor(@Inject(DB) private readonly db: Db) {}
|
|
||||||
|
|
||||||
async evaluateReadAccess(
|
|
||||||
request: FederationNativeRbacRequest,
|
|
||||||
): Promise<FederationNativeRbacResult> {
|
|
||||||
if (request.resource === 'credentials' || request.resource === 'api_keys') {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: `${request.resource} federation list access is not implemented in M3`,
|
|
||||||
details: { resource: request.resource },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.resource === 'memory') {
|
|
||||||
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
|
|
||||||
return { allowed: true, access: { includePersonal: true, teamIds } };
|
|
||||||
}
|
|
||||||
|
|
||||||
async list<T extends RowObject = RowObject>(
|
|
||||||
request: FederationListQueryRequest,
|
|
||||||
): Promise<FederationListQueryResult<T>> {
|
|
||||||
const cursor = decodeCursor(request.cursor);
|
|
||||||
const rows = await this.listAllRows(request.filter, request.filter.limit + 1, cursor);
|
|
||||||
return paginate(rows as T[], request.filter.limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listAllRows(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
rowLimit: number,
|
|
||||||
cursor: KeysetCursor | undefined,
|
|
||||||
): Promise<RowObject[]> {
|
|
||||||
switch (filter.resource) {
|
|
||||||
case 'tasks':
|
|
||||||
return this.listTasks(filter, rowLimit, cursor);
|
|
||||||
case 'notes':
|
|
||||||
return this.listNotes(filter, rowLimit, cursor);
|
|
||||||
case 'memory':
|
|
||||||
return this.listMemory(filter, rowLimit, cursor);
|
|
||||||
case 'credentials':
|
|
||||||
case 'api_keys':
|
|
||||||
return [];
|
|
||||||
default:
|
|
||||||
throw new Error(`Unsupported federation list resource: ${String(filter.resource)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
|
|
||||||
const rows = await this.db
|
|
||||||
.select({ teamId: teamMembers.teamId })
|
|
||||||
.from(teamMembers)
|
|
||||||
.where(eq(teamMembers.userId, subjectUserId));
|
|
||||||
|
|
||||||
return rows.map((row) => row.teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
|
|
||||||
const clauses = [];
|
|
||||||
if (filter.includePersonal) {
|
|
||||||
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
|
|
||||||
}
|
|
||||||
if (filter.teamIds.length > 0) {
|
|
||||||
clauses.push(
|
|
||||||
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clauses.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.select({ id: projects.id })
|
|
||||||
.from(projects)
|
|
||||||
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
|
|
||||||
|
|
||||||
return rows.map((row) => row.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
|
|
||||||
if (projectIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.select({ id: missions.id })
|
|
||||||
.from(missions)
|
|
||||||
.where(inArray(missions.projectId, [...projectIds]));
|
|
||||||
|
|
||||||
return rows.map((row) => row.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listTasks(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
rowLimit: number,
|
|
||||||
cursor: KeysetCursor | undefined,
|
|
||||||
): Promise<RowObject[]> {
|
|
||||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
|
||||||
const missionIds = await this.listMissionIds(projectIds);
|
|
||||||
const clauses = [];
|
|
||||||
|
|
||||||
if (projectIds.length > 0) {
|
|
||||||
clauses.push(inArray(tasks.projectId, projectIds));
|
|
||||||
}
|
|
||||||
if (missionIds.length > 0) {
|
|
||||||
clauses.push(inArray(tasks.missionId, missionIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clauses.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopeClause = clauses.length === 1 ? clauses[0] : or(...clauses);
|
|
||||||
const cursorClause = cursor
|
|
||||||
? or(
|
|
||||||
lt(tasks.createdAt, cursor.createdAt),
|
|
||||||
and(eq(tasks.createdAt, cursor.createdAt), lt(tasks.id, cursor.id)),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.select({
|
|
||||||
id: tasks.id,
|
|
||||||
title: tasks.title,
|
|
||||||
description: tasks.description,
|
|
||||||
status: tasks.status,
|
|
||||||
priority: tasks.priority,
|
|
||||||
projectId: tasks.projectId,
|
|
||||||
missionId: tasks.missionId,
|
|
||||||
assignee: tasks.assignee,
|
|
||||||
tags: tasks.tags,
|
|
||||||
dueDate: tasks.dueDate,
|
|
||||||
metadata: tasks.metadata,
|
|
||||||
createdAt: tasks.createdAt,
|
|
||||||
updatedAt: tasks.updatedAt,
|
|
||||||
})
|
|
||||||
.from(tasks)
|
|
||||||
.where(and(scopeClause, cursorClause))
|
|
||||||
.orderBy(desc(tasks.createdAt), desc(tasks.id))
|
|
||||||
.limit(rowLimit);
|
|
||||||
|
|
||||||
return sortRows(rows as RowObject[]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listNotes(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
rowLimit: number,
|
|
||||||
cursor: KeysetCursor | undefined,
|
|
||||||
): Promise<RowObject[]> {
|
|
||||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
|
||||||
const missionIds = await this.listMissionIds(projectIds);
|
|
||||||
|
|
||||||
if (missionIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// mission_tasks rows are user-scoped even when the mission belongs to a team.
|
|
||||||
// Team visibility can narrow the mission set, but it must never widen the
|
|
||||||
// query to other users' mission task notes.
|
|
||||||
const scopeClause = and(
|
|
||||||
eq(missionTasks.userId, filter.subjectUserId),
|
|
||||||
inArray(missionTasks.missionId, missionIds),
|
|
||||||
);
|
|
||||||
const cursorClause = cursor
|
|
||||||
? or(
|
|
||||||
lt(missionTasks.createdAt, cursor.createdAt),
|
|
||||||
and(eq(missionTasks.createdAt, cursor.createdAt), lt(missionTasks.id, cursor.id)),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.select({
|
|
||||||
id: missionTasks.id,
|
|
||||||
missionId: missionTasks.missionId,
|
|
||||||
taskId: missionTasks.taskId,
|
|
||||||
status: missionTasks.status,
|
|
||||||
content: missionTasks.notes,
|
|
||||||
createdAt: missionTasks.createdAt,
|
|
||||||
updatedAt: missionTasks.updatedAt,
|
|
||||||
})
|
|
||||||
.from(missionTasks)
|
|
||||||
.where(and(scopeClause, cursorClause, isNotNull(missionTasks.notes)))
|
|
||||||
.orderBy(desc(missionTasks.createdAt), desc(missionTasks.id))
|
|
||||||
.limit(rowLimit);
|
|
||||||
|
|
||||||
return sortRows(rows.filter((row) => row.content !== '') as RowObject[]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listMemory(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
rowLimit: number,
|
|
||||||
cursor: KeysetCursor | undefined,
|
|
||||||
): Promise<RowObject[]> {
|
|
||||||
if (!filter.includePersonal) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (cursor && cursor.source === undefined) {
|
|
||||||
throw new Error('Invalid federation list cursor');
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows: RowObject[] = [];
|
|
||||||
|
|
||||||
// Memory spans two physical tables. To keep pagination deterministic and
|
|
||||||
// resumable without a SQL UNION, M3 emits a fixed block order: all insights
|
|
||||||
// first, then preferences. The opaque cursor records which table produced
|
|
||||||
// the boundary row, so the next page never re-applies one table's keyset to
|
|
||||||
// the other table (which could duplicate/skip rows at equal timestamps).
|
|
||||||
if (cursor?.source !== 'preferences') {
|
|
||||||
const insightCursorClause = cursor
|
|
||||||
? or(
|
|
||||||
lt(insights.createdAt, cursor.createdAt),
|
|
||||||
and(eq(insights.createdAt, cursor.createdAt), lt(insights.id, cursor.id)),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
const insightRows = await this.db
|
|
||||||
.select({
|
|
||||||
id: insights.id,
|
|
||||||
kind: insights.source,
|
|
||||||
content: insights.content,
|
|
||||||
category: insights.category,
|
|
||||||
relevanceScore: insights.relevanceScore,
|
|
||||||
metadata: insights.metadata,
|
|
||||||
createdAt: insights.createdAt,
|
|
||||||
updatedAt: insights.updatedAt,
|
|
||||||
})
|
|
||||||
.from(insights)
|
|
||||||
.where(and(eq(insights.userId, filter.subjectUserId), insightCursorClause))
|
|
||||||
.orderBy(desc(insights.createdAt), desc(insights.id))
|
|
||||||
.limit(rowLimit);
|
|
||||||
|
|
||||||
rows.push(...(insightRows as RowObject[]).map((row) => markCursorSource(row, 'insights')));
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = rowLimit - rows.length;
|
|
||||||
if (remaining <= 0) {
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferenceCursorClause =
|
|
||||||
cursor?.source === 'preferences'
|
|
||||||
? or(
|
|
||||||
lt(preferences.createdAt, cursor.createdAt),
|
|
||||||
and(eq(preferences.createdAt, cursor.createdAt), lt(preferences.id, cursor.id)),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
const preferenceRows = await this.db
|
|
||||||
.select({
|
|
||||||
id: preferences.id,
|
|
||||||
kind: preferences.category,
|
|
||||||
key: preferences.key,
|
|
||||||
value: preferences.value,
|
|
||||||
source: preferences.source,
|
|
||||||
mutable: preferences.mutable,
|
|
||||||
createdAt: preferences.createdAt,
|
|
||||||
updatedAt: preferences.updatedAt,
|
|
||||||
})
|
|
||||||
.from(preferences)
|
|
||||||
.where(and(eq(preferences.userId, filter.subjectUserId), preferenceCursorClause))
|
|
||||||
.orderBy(desc(preferences.createdAt), desc(preferences.id))
|
|
||||||
.limit(remaining);
|
|
||||||
|
|
||||||
rows.push(
|
|
||||||
...(preferenceRows as RowObject[]).map((row) => markCursorSource(row, 'preferences')),
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation list verb (FED-M3-05).
|
|
||||||
*
|
|
||||||
* POST /api/federation/v1/list/:resource
|
|
||||||
*
|
|
||||||
* Pipeline: FederationAuthGuard attaches the active grant context, then
|
|
||||||
* FederationScopeService enforces grant scope + native RBAC intersection, then
|
|
||||||
* the read-only query layer returns capped rows tagged with `_source`. Read
|
|
||||||
* audit-log writes are deferred to M4; this controller does not persist request
|
|
||||||
* or response bodies.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
Req,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
import {
|
|
||||||
FederationInvalidRequestError,
|
|
||||||
FederationScopeViolationError,
|
|
||||||
FederationUnauthorizedError,
|
|
||||||
SOURCE_LOCAL,
|
|
||||||
tagWithSource,
|
|
||||||
type FederationListResponse,
|
|
||||||
type SourceTag,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
|
||||||
import '../federation-context.js';
|
|
||||||
import { FederationScopeService } from '../scope.service.js';
|
|
||||||
import { FederationListQueryService } from './list-query.service.js';
|
|
||||||
|
|
||||||
interface FederationListRequestBody {
|
|
||||||
readonly limit?: unknown;
|
|
||||||
readonly cursor?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FederatedRow = Record<string, unknown> & SourceTag;
|
|
||||||
|
|
||||||
function parseLimit(body: FederationListRequestBody | undefined): number | undefined {
|
|
||||||
if (body?.limit === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed =
|
|
||||||
typeof body.limit === 'number'
|
|
||||||
? body.limit
|
|
||||||
: typeof body.limit === 'string' && body.limit.trim().length > 0
|
|
||||||
? Number(body.limit)
|
|
||||||
: Number.NaN;
|
|
||||||
|
|
||||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationInvalidRequestError(
|
|
||||||
'Federation list limit must be a positive integer',
|
|
||||||
).toEnvelope(),
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCursor(body: FederationListRequestBody | undefined): string | undefined {
|
|
||||||
if (body?.cursor === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (typeof body.cursor === 'string') {
|
|
||||||
return body.cursor;
|
|
||||||
}
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationInvalidRequestError('Federation list cursor must be a string').toEnvelope(),
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('api/federation/v1/list')
|
|
||||||
@UseGuards(FederationAuthGuard)
|
|
||||||
export class ListController {
|
|
||||||
constructor(
|
|
||||||
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
|
|
||||||
@Inject(FederationListQueryService) private readonly query: FederationListQueryService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Post(':resource')
|
|
||||||
async list(
|
|
||||||
@Param('resource') resource: string,
|
|
||||||
@Req() request: FastifyRequest,
|
|
||||||
@Body() body?: FederationListRequestBody,
|
|
||||||
): Promise<FederationListResponse<FederatedRow>> {
|
|
||||||
if (!request.federationContext) {
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestedLimit = parseLimit(body);
|
|
||||||
const cursor = parseCursor(body);
|
|
||||||
const scopeResult = await this.scope.evaluateAccess({
|
|
||||||
context: request.federationContext,
|
|
||||||
resource,
|
|
||||||
requestedLimit,
|
|
||||||
nativeRbac: this.query,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!scopeResult.allowed) {
|
|
||||||
const ErrorClass =
|
|
||||||
scopeResult.deny.statusCode === 400
|
|
||||||
? FederationInvalidRequestError
|
|
||||||
: FederationScopeViolationError;
|
|
||||||
throw new HttpException(
|
|
||||||
new ErrorClass(scopeResult.deny.message, scopeResult.deny).toEnvelope(),
|
|
||||||
scopeResult.deny.statusCode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: Awaited<ReturnType<FederationListQueryService['list']>>;
|
|
||||||
try {
|
|
||||||
result = await this.query.list({ filter: scopeResult.filter, cursor });
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof Error && error.message === 'Invalid federation list cursor') {
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationInvalidRequestError('Federation list cursor is invalid').toEnvelope(),
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response: FederationListResponse<FederatedRow> = {
|
|
||||||
items: tagWithSource(result.items, SOURCE_LOCAL),
|
|
||||||
};
|
|
||||||
if (result.nextCursor !== undefined) {
|
|
||||||
response.nextCursor = result.nextCursor;
|
|
||||||
}
|
|
||||||
if (result.truncated) {
|
|
||||||
response._truncated = true;
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { Logger } from '@nestjs/common';
|
|||||||
import type { QueueHandle } from '@mosaicstack/queue';
|
import type { QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { LogService } from '@mosaicstack/log';
|
import type { LogService } from '@mosaicstack/log';
|
||||||
import { SessionGCService } from './session-gc.service.js';
|
import { SessionGCService } from './session-gc.service.js';
|
||||||
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
|
||||||
|
|
||||||
type MockRedis = {
|
type MockRedis = {
|
||||||
scan: ReturnType<typeof vi.fn>;
|
scan: ReturnType<typeof vi.fn>;
|
||||||
@@ -13,12 +12,7 @@ type MockRedis = {
|
|||||||
describe('SessionGCService', () => {
|
describe('SessionGCService', () => {
|
||||||
let service: SessionGCService;
|
let service: SessionGCService;
|
||||||
let mockRedis: MockRedis;
|
let mockRedis: MockRedis;
|
||||||
let mockLogService: {
|
let mockLogService: { logs: { promoteToWarm: ReturnType<typeof vi.fn> } };
|
||||||
logs: {
|
|
||||||
promoteSessionToWarm: ReturnType<typeof vi.fn>;
|
|
||||||
promoteToWarm: ReturnType<typeof vi.fn>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: build a scan mock that returns all provided keys in a single
|
* Helper: build a scan mock that returns all provided keys in a single
|
||||||
@@ -36,7 +30,6 @@ describe('SessionGCService', () => {
|
|||||||
|
|
||||||
mockLogService = {
|
mockLogService = {
|
||||||
logs: {
|
logs: {
|
||||||
promoteSessionToWarm: vi.fn().mockResolvedValue(0),
|
|
||||||
promoteToWarm: vi.fn().mockResolvedValue(0),
|
promoteToWarm: vi.fn().mockResolvedValue(0),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -66,76 +59,54 @@ describe('SessionGCService', () => {
|
|||||||
expect(result.cleaned.valkeyKeys).toBeUndefined();
|
expect(result.cleaned.valkeyKeys).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('escapes glob metacharacters in a session identifier', async () => {
|
|
||||||
await service.collect('abc*?[tenant]\\escape');
|
|
||||||
|
|
||||||
expect(mockRedis.scan).toHaveBeenCalledWith(
|
|
||||||
'0',
|
|
||||||
'MATCH',
|
|
||||||
'mosaic:session:abc\\*\\?\\[tenant\\]\\\\escape:*',
|
|
||||||
'COUNT',
|
|
||||||
100,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves a valid durable approval after session GC', async () => {
|
|
||||||
const entries = new Map<string, string>();
|
|
||||||
const redis = {
|
|
||||||
scan: vi.fn().mockResolvedValue(['0', ['mosaic:session:owned:state']]),
|
|
||||||
get: vi.fn(async (key: string) => entries.get(key) ?? null),
|
|
||||||
set: vi.fn(async (key: string, value: string) => entries.set(key, value)),
|
|
||||||
del: vi.fn(async (...keys: string[]) => {
|
|
||||||
let deleted = 0;
|
|
||||||
for (const key of keys) deleted += Number(entries.delete(key));
|
|
||||||
return deleted;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const authorization = new CommandAuthorizationService(
|
|
||||||
{
|
|
||||||
select: () => ({
|
|
||||||
from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }),
|
|
||||||
}),
|
|
||||||
} as never,
|
|
||||||
redis,
|
|
||||||
);
|
|
||||||
const command = {
|
|
||||||
name: 'gc',
|
|
||||||
description: 'System-wide garbage collection',
|
|
||||||
aliases: [],
|
|
||||||
scope: 'admin',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
} as never;
|
|
||||||
const payload = { command: 'gc', conversationId: 'owned' };
|
|
||||||
const approval = await authorization.createApproval(command, payload, 'admin-1');
|
|
||||||
const approvalKey = `interaction:command-approval:${approval!.approvalId}`;
|
|
||||||
const gc = new SessionGCService(redis as never, mockLogService as unknown as LogService);
|
|
||||||
|
|
||||||
await gc.collect('owned');
|
|
||||||
|
|
||||||
expect(entries.has(approvalKey)).toBe(true);
|
|
||||||
await expect(
|
|
||||||
authorization.authorize(command, payload, 'admin-1', approval!.approvalId),
|
|
||||||
).resolves.toEqual({ allowed: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('collect() returns sessionId in result', async () => {
|
it('collect() returns sessionId in result', async () => {
|
||||||
const result = await service.collect('test-session-id');
|
const result = await service.collect('test-session-id');
|
||||||
expect(result.sessionId).toBe('test-session-id');
|
expect(result.sessionId).toBe('test-session-id');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('collect() demotes logs only for the requested session', async () => {
|
it('fullCollect() deletes all session keys', async () => {
|
||||||
await service.collect('owned-session');
|
mockRedis.scan = makeScanMock(['mosaic:session:abc:system', 'mosaic:session:xyz:foo']);
|
||||||
|
const result = await service.fullCollect();
|
||||||
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
|
expect(mockRedis.del).toHaveBeenCalled();
|
||||||
'owned-session',
|
expect(result.valkeyKeys).toBe(2);
|
||||||
expect.any(Date),
|
|
||||||
);
|
|
||||||
expect(mockLogService.logs.promoteToWarm).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not expose automatic global GC entry points', () => {
|
it('fullCollect() with no keys returns 0 valkeyKeys', async () => {
|
||||||
expect('fullCollect' in service).toBe(false);
|
mockRedis.scan = makeScanMock([]);
|
||||||
expect('sweepOrphans' in service).toBe(false);
|
const result = await service.fullCollect();
|
||||||
|
expect(result.valkeyKeys).toBe(0);
|
||||||
|
expect(mockRedis.del).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fullCollect() returns duration', async () => {
|
||||||
|
const result = await service.fullCollect();
|
||||||
|
expect(result.duration).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweepOrphans() extracts unique session IDs and collects them', async () => {
|
||||||
|
// First scan call returns the global session list; subsequent calls return
|
||||||
|
// per-session keys during collect().
|
||||||
|
mockRedis.scan = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
'0',
|
||||||
|
['mosaic:session:abc:system', 'mosaic:session:abc:messages', 'mosaic:session:xyz:system'],
|
||||||
|
])
|
||||||
|
// collect('abc') scan
|
||||||
|
.mockResolvedValueOnce(['0', ['mosaic:session:abc:system', 'mosaic:session:abc:messages']])
|
||||||
|
// collect('xyz') scan
|
||||||
|
.mockResolvedValueOnce(['0', ['mosaic:session:xyz:system']]);
|
||||||
|
mockRedis.del.mockResolvedValue(1);
|
||||||
|
|
||||||
|
const result = await service.sweepOrphans();
|
||||||
|
expect(result.orphanedSessions).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(result.duration).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweepOrphans() returns empty when no session keys', async () => {
|
||||||
|
mockRedis.scan = makeScanMock([]);
|
||||||
|
const result = await service.sweepOrphans();
|
||||||
|
expect(result.orphanedSessions).toBe(0);
|
||||||
|
expect(result.totalCleaned).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||||
import type { QueueHandle } from '@mosaicstack/queue';
|
import type { QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { LogService } from '@mosaicstack/log';
|
import type { LogService } from '@mosaicstack/log';
|
||||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||||
@@ -13,18 +13,49 @@ export interface GCResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Escape Redis glob metacharacters so a session identifier is always literal. */
|
export interface GCSweepResult {
|
||||||
function escapeRedisGlobLiteral(value: string): string {
|
orphanedSessions: number;
|
||||||
return value.replace(/[\\*?\[\]]/g, '\\$&');
|
totalCleaned: GCResult[];
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FullGCResult {
|
||||||
|
valkeyKeys: number;
|
||||||
|
logsDemoted: number;
|
||||||
|
jobsPurged: number;
|
||||||
|
tempFilesRemoved: number;
|
||||||
|
duration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionGCService {
|
export class SessionGCService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(SessionGCService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
// Fire-and-forget: run full GC asynchronously so it does not block the
|
||||||
|
// NestJS bootstrap chain. Cold-start GC typically takes 100–500 ms
|
||||||
|
// depending on Valkey key count; deferring it removes that latency from
|
||||||
|
// the TTFB of the first HTTP request.
|
||||||
|
this.fullCollect()
|
||||||
|
.then((result) => {
|
||||||
|
this.logger.log(
|
||||||
|
`Full GC complete: ${result.valkeyKeys} Valkey keys, ` +
|
||||||
|
`${result.logsDemoted} logs demoted, ` +
|
||||||
|
`${result.jobsPurged} jobs purged, ` +
|
||||||
|
`${result.tempFilesRemoved} temp dirs removed ` +
|
||||||
|
`(${result.duration}ms)`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
this.logger.error('Cold-start GC failed', err instanceof Error ? err.stack : String(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
|
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
|
||||||
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
||||||
@@ -48,20 +79,86 @@ export class SessionGCService {
|
|||||||
const result: GCResult = { sessionId, cleaned: {} };
|
const result: GCResult = { sessionId, cleaned: {} };
|
||||||
|
|
||||||
// 1. Valkey: delete all session-scoped keys
|
// 1. Valkey: delete all session-scoped keys
|
||||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
const pattern = `mosaic:session:${sessionId}:*`;
|
||||||
const valkeyKeys = await this.scanKeys(pattern);
|
const valkeyKeys = await this.scanKeys(pattern);
|
||||||
if (valkeyKeys.length > 0) {
|
if (valkeyKeys.length > 0) {
|
||||||
await this.redis.del(...valkeyKeys);
|
await this.redis.del(...valkeyKeys);
|
||||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. PG: demote hot-tier agent logs for this session only.
|
// 2. PG: demote hot-tier agent_logs for this session to warm
|
||||||
const cutoff = new Date();
|
const cutoff = new Date(); // demote all hot logs for this session
|
||||||
const logsDemoted = await this.logService.logs.promoteSessionToWarm(sessionId, cutoff);
|
const logsDemoted = await this.logService.logs.promoteToWarm(cutoff);
|
||||||
if (logsDemoted > 0) {
|
if (logsDemoted > 0) {
|
||||||
result.cleaned.logsDemoted = logsDemoted;
|
result.cleaned.logsDemoted = logsDemoted;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweep GC — find orphaned artifacts from dead sessions.
|
||||||
|
* System-wide operation: only call from admin-authorized paths or internal
|
||||||
|
* scheduled jobs. Individual session cleanup is handled by collect().
|
||||||
|
*/
|
||||||
|
async sweepOrphans(): Promise<GCSweepResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
const cleaned: GCResult[] = [];
|
||||||
|
|
||||||
|
// 1. Find all session-scoped Valkey keys (non-blocking SCAN)
|
||||||
|
const allSessionKeys = await this.scanKeys('mosaic:session:*');
|
||||||
|
|
||||||
|
// Extract unique session IDs from keys
|
||||||
|
const sessionIds = new Set<string>();
|
||||||
|
for (const key of allSessionKeys) {
|
||||||
|
const match = key.match(/^mosaic:session:([^:]+):/);
|
||||||
|
if (match) sessionIds.add(match[1]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. For each session ID, collect stale keys
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
const gcResult = await this.collect(sessionId);
|
||||||
|
if (Object.keys(gcResult.cleaned).length > 0) {
|
||||||
|
cleaned.push(gcResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
orphanedSessions: cleaned.length,
|
||||||
|
totalCleaned: cleaned,
|
||||||
|
duration: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full GC — aggressive collection for cold start.
|
||||||
|
* Assumes no sessions survived the restart.
|
||||||
|
*/
|
||||||
|
async fullCollect(): Promise<FullGCResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN)
|
||||||
|
const sessionKeys = await this.scanKeys('mosaic:session:*');
|
||||||
|
if (sessionKeys.length > 0) {
|
||||||
|
await this.redis.del(...sessionKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. NOTE: channel keys are NOT collected on cold start
|
||||||
|
// (discord/telegram plugins may reconnect and resume)
|
||||||
|
|
||||||
|
// 3. PG: demote stale hot-tier logs older than 24h to warm
|
||||||
|
const hotCutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
const logsDemoted = await this.logService.logs.promoteToWarm(hotCutoff);
|
||||||
|
|
||||||
|
// 4. No summarization job purge API available yet
|
||||||
|
const jobsPurged = 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
valkeyKeys: sessionKeys.length,
|
||||||
|
logsDemoted,
|
||||||
|
jobsPurged,
|
||||||
|
tempFilesRemoved: 0,
|
||||||
|
duration: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { HealthController } from './health.controller.js';
|
|
||||||
|
|
||||||
describe('HealthController', (): void => {
|
|
||||||
it('exposes liveness and readiness without configuration details', (): void => {
|
|
||||||
const controller = new HealthController();
|
|
||||||
|
|
||||||
expect(controller.check()).toEqual({ status: 'ok' });
|
|
||||||
expect(controller.ready()).toEqual({ status: 'ready' });
|
|
||||||
expect(JSON.stringify(controller.ready())).not.toContain('credential');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,10 +6,4 @@ export class HealthController {
|
|||||||
check(): { status: string } {
|
check(): { status: string } {
|
||||||
return { status: 'ok' };
|
return { status: 'ok' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Readiness intentionally exposes no configuration, provider, or credential details. */
|
|
||||||
@Get('ready')
|
|
||||||
ready(): { status: string } {
|
|
||||||
return { status: 'ready' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import {
|
|||||||
type OnModuleDestroy,
|
type OnModuleDestroy,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { SummarizationService } from './summarization.service.js';
|
import { SummarizationService } from './summarization.service.js';
|
||||||
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
import {
|
import {
|
||||||
QueueService,
|
QueueService,
|
||||||
QUEUE_GC,
|
|
||||||
QUEUE_SUMMARIZATION,
|
QUEUE_SUMMARIZATION,
|
||||||
|
QUEUE_GC,
|
||||||
QUEUE_TIER_MANAGEMENT,
|
QUEUE_TIER_MANAGEMENT,
|
||||||
} from '../queue/queue.service.js';
|
} from '../queue/queue.service.js';
|
||||||
import type { Worker } from 'bullmq';
|
import type { Worker } from 'bullmq';
|
||||||
@@ -22,12 +23,14 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||||
|
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||||
@Inject(QueueService) private readonly queueService: QueueService,
|
@Inject(QueueService) private readonly queueService: QueueService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||||
|
const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am
|
||||||
|
|
||||||
// M6-003: Summarization repeatable job
|
// M6-003: Summarization repeatable job
|
||||||
await this.queueService.addRepeatableJob(
|
await this.queueService.addRepeatableJob(
|
||||||
@@ -53,12 +56,15 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
this.registeredWorkers.push(tierWorker);
|
this.registeredWorkers.push(tierWorker);
|
||||||
|
|
||||||
// Retire any repeatable global GC schedule created by older deployments.
|
// M6-004: GC repeatable job
|
||||||
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
await this.queueService.addRepeatableJob(QUEUE_GC, 'session-gc', {}, gcSchedule);
|
||||||
await this.queueService.removeRepeatableJobs(QUEUE_GC, 'session-gc');
|
const gcWorker = this.queueService.registerWorker(QUEUE_GC, async () => {
|
||||||
|
await this.sessionGC.sweepOrphans();
|
||||||
|
});
|
||||||
|
this.registeredWorkers.push(gcWorker);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}"`,
|
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ import { Logger } 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 '@mosaicstack/auth';
|
||||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
import {
|
import type { McpService } from './mcp.service.js';
|
||||||
createMcpActorContext,
|
|
||||||
deriveMcpToolScopesForUser,
|
|
||||||
type McpService,
|
|
||||||
} from './mcp.service.js';
|
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,25 +67,14 @@ async function handleMcpRequest(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authUser = result.user as {
|
const userId = result.user.id;
|
||||||
id: string;
|
|
||||||
role?: string | null;
|
|
||||||
tenantId?: string | null;
|
|
||||||
organizationId?: string | null;
|
|
||||||
};
|
|
||||||
const actor = createMcpActorContext({
|
|
||||||
userId: authUser.id,
|
|
||||||
role: authUser.role,
|
|
||||||
tenantId: authUser.tenantId ?? authUser.organizationId ?? undefined,
|
|
||||||
scopes: deriveMcpToolScopesForUser({ role: authUser.role }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Session routing ─────────────────────────────────────────────────────
|
// ─── Session routing ─────────────────────────────────────────────────────
|
||||||
const sessionId = req.raw.headers['mcp-session-id'];
|
const sessionId = req.raw.headers['mcp-session-id'];
|
||||||
|
|
||||||
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
||||||
// Existing session request
|
// Existing session request
|
||||||
const transport = mcpService.getSession(sessionId, actor);
|
const transport = mcpService.getSession(sessionId);
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
logger.warn(`MCP session not found: ${sessionId}`);
|
logger.warn(`MCP session not found: ${sessionId}`);
|
||||||
reply.raw.writeHead(404, { 'Content-Type': 'application/json' });
|
reply.raw.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
@@ -127,10 +112,8 @@ async function handleMcpRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create new session and handle this initializing request
|
// Create new session and handle this initializing request
|
||||||
const { transport } = mcpService.createSession(actor);
|
const { transport } = mcpService.createSession(userId);
|
||||||
logger.log(
|
logger.log(`New MCP session created for user ${userId}`);
|
||||||
`New MCP session created for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
await transport.handleRequest(req.raw, reply.raw, body);
|
await transport.handleRequest(req.raw, reply.raw, body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,461 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
||||||
import type { Brain } from '@mosaicstack/brain';
|
|
||||||
import type { Memory } from '@mosaicstack/memory';
|
|
||||||
import type { EmbeddingService } from '../memory/embedding.service.js';
|
|
||||||
import type { CoordService } from '../coord/coord.service.js';
|
|
||||||
import {
|
|
||||||
assertMcpToolAuthorized,
|
|
||||||
createMcpActorContext,
|
|
||||||
deriveMcpToolScopesForUser,
|
|
||||||
MCP_TOOL_SCOPES,
|
|
||||||
McpService,
|
|
||||||
type McpToolName,
|
|
||||||
} from './mcp.service.js';
|
|
||||||
|
|
||||||
type ToolResult = { content: Array<{ type: 'text'; text: string }> };
|
|
||||||
type ToolHandler = (params: Record<string, unknown>) => Promise<ToolResult>;
|
|
||||||
|
|
||||||
interface CapturedTool {
|
|
||||||
inputSchema: z.ZodType;
|
|
||||||
handler: ToolHandler;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCapturingServer(): { server: McpServer; tools: Map<string, CapturedTool> } {
|
|
||||||
const tools = new Map<string, CapturedTool>();
|
|
||||||
const server = {
|
|
||||||
registerTool(name: string, config: { inputSchema: z.ZodType }, handler: ToolHandler): void {
|
|
||||||
tools.set(name, { inputSchema: config.inputSchema, handler });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return { server: server as unknown as McpServer, tools };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeService(opts?: {
|
|
||||||
projects?: Array<Record<string, unknown> & { id: string; ownerId?: string | null }>;
|
|
||||||
missions?: Array<
|
|
||||||
Record<string, unknown> & { id: string; projectId?: string | null; userId?: string | null }
|
|
||||||
>;
|
|
||||||
tasks?: Array<
|
|
||||||
Record<string, unknown> & {
|
|
||||||
id: string;
|
|
||||||
projectId?: string | null;
|
|
||||||
missionId?: string | null;
|
|
||||||
status?: string;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
}) {
|
|
||||||
const projects = opts?.projects ?? [];
|
|
||||||
const missions = opts?.missions ?? [];
|
|
||||||
const tasks = opts?.tasks ?? [];
|
|
||||||
const brain = {
|
|
||||||
projects: {
|
|
||||||
findAll: vi.fn(async () => projects),
|
|
||||||
findById: vi.fn(async (id: string) => projects.find((project) => project.id === id) ?? null),
|
|
||||||
},
|
|
||||||
tasks: {
|
|
||||||
findAll: vi.fn(async () => tasks),
|
|
||||||
findById: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null),
|
|
||||||
findByProject: vi.fn(async (projectId: string) =>
|
|
||||||
tasks.filter((task) => task.projectId === projectId),
|
|
||||||
),
|
|
||||||
findByMission: vi.fn(async (missionId: string) =>
|
|
||||||
tasks.filter((task) => task.missionId === missionId),
|
|
||||||
),
|
|
||||||
findByStatus: vi.fn(async (status: string) => tasks.filter((task) => task.status === status)),
|
|
||||||
create: vi.fn(async (task: Record<string, unknown>) => ({ id: 'task-1', ...task })),
|
|
||||||
update: vi.fn(async (id: string, updates: Record<string, unknown>) => ({ id, ...updates })),
|
|
||||||
},
|
|
||||||
missions: {
|
|
||||||
findAll: vi.fn(async () => missions),
|
|
||||||
findById: vi.fn(async (id: string) => missions.find((mission) => mission.id === id) ?? null),
|
|
||||||
findByProject: vi.fn(async (projectId: string) =>
|
|
||||||
missions.filter((mission) => mission.projectId === projectId),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
conversations: {
|
|
||||||
findAll: vi.fn(async (userId: string) => [{ id: 'conversation-1', userId }]),
|
|
||||||
},
|
|
||||||
} as unknown as Brain;
|
|
||||||
|
|
||||||
const memory = {
|
|
||||||
insights: {
|
|
||||||
searchByEmbedding: vi.fn(async (userId: string) => [{ id: 'insight-1', userId }]),
|
|
||||||
create: vi.fn(async (insight: Record<string, unknown>) => ({ id: 'insight-2', ...insight })),
|
|
||||||
},
|
|
||||||
preferences: {
|
|
||||||
findByUser: vi.fn(async (userId: string) => [{ key: 'theme', userId }]),
|
|
||||||
findByUserAndCategory: vi.fn(async (userId: string, category: string) => [
|
|
||||||
{ key: 'theme', userId, category },
|
|
||||||
]),
|
|
||||||
upsert: vi.fn(async (preference: Record<string, unknown>) => ({
|
|
||||||
id: 'pref-1',
|
|
||||||
...preference,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
} as unknown as Memory;
|
|
||||||
|
|
||||||
const embeddings = {
|
|
||||||
available: true,
|
|
||||||
embed: vi.fn(async () => [0.1, 0.2, 0.3]),
|
|
||||||
} as unknown as EmbeddingService;
|
|
||||||
|
|
||||||
const coord = {
|
|
||||||
getMissionStatus: vi.fn(async () => null),
|
|
||||||
listTasks: vi.fn(async () => []),
|
|
||||||
getTaskStatus: vi.fn(async () => null),
|
|
||||||
} as unknown as CoordService;
|
|
||||||
|
|
||||||
return {
|
|
||||||
service: new McpService(brain, memory, embeddings, coord),
|
|
||||||
brain: brain as unknown as {
|
|
||||||
conversations: { findAll: ReturnType<typeof vi.fn> };
|
|
||||||
tasks: {
|
|
||||||
create: ReturnType<typeof vi.fn>;
|
|
||||||
update: ReturnType<typeof vi.fn>;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
memory: memory as unknown as {
|
|
||||||
insights: { searchByEmbedding: ReturnType<typeof vi.fn> };
|
|
||||||
},
|
|
||||||
coord: coord as unknown as {
|
|
||||||
listTasks: ReturnType<typeof vi.fn>;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTool(tools: Map<string, CapturedTool>, name: McpToolName): CapturedTool {
|
|
||||||
const tool = tools.get(name);
|
|
||||||
if (!tool) throw new Error(`Missing captured tool ${name}`);
|
|
||||||
return tool;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeMemberActor(userId = 'authenticated-user') {
|
|
||||||
return createMcpActorContext({
|
|
||||||
userId,
|
|
||||||
role: 'member',
|
|
||||||
scopes: deriveMcpToolScopesForUser({ role: 'member' }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeAdminActor(userId = 'admin-user', tenantId?: string) {
|
|
||||||
return createMcpActorContext({
|
|
||||||
userId,
|
|
||||||
tenantId,
|
|
||||||
role: 'admin',
|
|
||||||
scopes: deriveMcpToolScopesForUser({ role: 'admin' }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function makePlatformAdminActor(userId = 'platform-admin-user') {
|
|
||||||
return createMcpActorContext({
|
|
||||||
userId,
|
|
||||||
role: 'platform-admin',
|
|
||||||
scopes: deriveMcpToolScopesForUser({ role: 'platform-admin' }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('MCP actor identity and tool scope enforcement', () => {
|
|
||||||
it('derives immutable actor, tenant, channel, correlation, and explicit tool scopes server-side', () => {
|
|
||||||
const actor = createMcpActorContext({
|
|
||||||
userId: ' user-authenticated ',
|
|
||||||
role: 'member',
|
|
||||||
scopes: deriveMcpToolScopesForUser({ role: 'member' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(actor.userId).toBe('user-authenticated');
|
|
||||||
expect(actor.tenantId).toBe('user:user-authenticated');
|
|
||||||
expect(actor.role).toBe('member');
|
|
||||||
expect(actor.channel).toBe('mcp');
|
|
||||||
expect(actor.correlationId).toMatch(/[0-9a-f-]{36}/i);
|
|
||||||
expect(actor.scopes.has(MCP_TOOL_SCOPES.memory_search)).toBe(true);
|
|
||||||
expect(actor.scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false);
|
|
||||||
expect(
|
|
||||||
deriveMcpToolScopesForUser({ role: 'admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
|
|
||||||
const actor = createMcpActorContext({ userId: 'user-authenticated' });
|
|
||||||
|
|
||||||
expect(actor.scopes.size).toBe(0);
|
|
||||||
expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow(
|
|
||||||
'MCP tool scope denied: memory:insight:read',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed when a tool caller supplies actor or tenant identity fields', () => {
|
|
||||||
const actor = createMcpActorContext({ userId: 'user-authenticated' });
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
assertMcpToolAuthorized(actor, 'memory_search', {
|
|
||||||
userId: 'victim-user',
|
|
||||||
query: 'private data',
|
|
||||||
}),
|
|
||||||
).toThrow('MCP caller-controlled identity field is forbidden: userId');
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
assertMcpToolAuthorized(actor, 'coord_list_tasks', {
|
|
||||||
tenantId: 'victim-tenant',
|
|
||||||
projectPath: '/tmp/project',
|
|
||||||
}),
|
|
||||||
).toThrow('MCP caller-controlled identity field is forbidden: tenantId');
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
assertMcpToolAuthorized(actor, 'brain_create_task', {
|
|
||||||
title: 'forged org',
|
|
||||||
organizationId: 'victim-org',
|
|
||||||
}),
|
|
||||||
).toThrow('MCP caller-controlled identity field is forbidden: organizationId');
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
assertMcpToolAuthorized(actor, 'brain_update_task', {
|
|
||||||
title: 'forged team',
|
|
||||||
teamId: 'victim-team',
|
|
||||||
}),
|
|
||||||
).toThrow('MCP caller-controlled identity field is forbidden: teamId');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed when the server-derived actor lacks the required per-tool scope', () => {
|
|
||||||
const actor = createMcpActorContext({ userId: 'user-authenticated', scopes: [] });
|
|
||||||
|
|
||||||
expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow(
|
|
||||||
'MCP tool scope denied: memory:insight:read',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes caller-controlled userId from memory schemas and never queries victim memory', async () => {
|
|
||||||
const { service, memory } = makeService();
|
|
||||||
const { server, tools } = makeCapturingServer();
|
|
||||||
const actor = makeMemberActor('authenticated-user');
|
|
||||||
|
|
||||||
service.registerTools(server, actor);
|
|
||||||
const tool = getTool(tools, 'memory_search');
|
|
||||||
|
|
||||||
expect(tool.inputSchema.safeParse({ userId: 'victim-user', query: 'anything' }).success).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
await expect(tool.handler({ userId: 'victim-user', query: 'anything' })).rejects.toThrow(
|
|
||||||
'MCP caller-controlled identity field is forbidden: userId',
|
|
||||||
);
|
|
||||||
expect(memory.insights.searchByEmbedding).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
await tool.handler({ query: 'only my notes' });
|
|
||||||
expect(memory.insights.searchByEmbedding).toHaveBeenCalledWith(
|
|
||||||
'authenticated-user',
|
|
||||||
[0.1, 0.2, 0.3],
|
|
||||||
5,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('binds conversation listing to the authenticated actor instead of a caller-supplied userId', async () => {
|
|
||||||
const { service, brain } = makeService();
|
|
||||||
const { server, tools } = makeCapturingServer();
|
|
||||||
const actor = makeMemberActor('authenticated-user');
|
|
||||||
|
|
||||||
service.registerTools(server, actor);
|
|
||||||
const tool = getTool(tools, 'brain_list_conversations');
|
|
||||||
|
|
||||||
expect(tool.inputSchema.safeParse({ userId: 'victim-user' }).success).toBe(false);
|
|
||||||
await expect(tool.handler({ userId: 'victim-user' })).rejects.toThrow(
|
|
||||||
'MCP caller-controlled identity field is forbidden: userId',
|
|
||||||
);
|
|
||||||
expect(brain.conversations.findAll).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
await tool.handler({});
|
|
||||||
expect(brain.conversations.findAll).toHaveBeenCalledWith('authenticated-user');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('scopes brain project, mission, and task reads to the authenticated actor', async () => {
|
|
||||||
const { service } = makeService({
|
|
||||||
projects: [
|
|
||||||
{ id: 'project-owned', ownerId: 'authenticated-user', name: 'owned' },
|
|
||||||
{ id: 'project-victim', ownerId: 'victim-user', name: 'victim' },
|
|
||||||
],
|
|
||||||
missions: [
|
|
||||||
{ id: 'mission-owned', projectId: 'project-owned' },
|
|
||||||
{ id: 'mission-victim', userId: 'victim-user', projectId: 'project-victim' },
|
|
||||||
],
|
|
||||||
tasks: [
|
|
||||||
{ id: 'task-owned-project', projectId: 'project-owned', status: 'not-started' },
|
|
||||||
{ id: 'task-owned-mission', missionId: 'mission-owned', status: 'not-started' },
|
|
||||||
{ id: 'task-victim-project', projectId: 'project-victim', status: 'not-started' },
|
|
||||||
{ id: 'task-unowned', status: 'not-started' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const { server, tools } = makeCapturingServer();
|
|
||||||
const actor = makeMemberActor('authenticated-user');
|
|
||||||
|
|
||||||
service.registerTools(server, actor);
|
|
||||||
|
|
||||||
const projects = JSON.parse(
|
|
||||||
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
|
|
||||||
);
|
|
||||||
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']);
|
|
||||||
|
|
||||||
const missions = JSON.parse(
|
|
||||||
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
|
|
||||||
);
|
|
||||||
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']);
|
|
||||||
|
|
||||||
const tasks = JSON.parse(
|
|
||||||
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
|
|
||||||
);
|
|
||||||
expect(tasks.map((task: { id: string }) => task.id)).toEqual([
|
|
||||||
'task-owned-project',
|
|
||||||
'task-owned-mission',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
|
|
||||||
const { service } = makeService({
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
id: 'project-tenant-a',
|
|
||||||
ownerId: 'other-user-a',
|
|
||||||
teamId: 'tenant-a',
|
|
||||||
name: 'same tenant',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'project-tenant-b',
|
|
||||||
ownerId: 'other-user-b',
|
|
||||||
teamId: 'tenant-b',
|
|
||||||
name: 'other tenant',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
missions: [
|
|
||||||
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
|
|
||||||
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
|
|
||||||
],
|
|
||||||
tasks: [
|
|
||||||
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
|
|
||||||
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const { server, tools } = makeCapturingServer();
|
|
||||||
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
|
|
||||||
|
|
||||||
service.registerTools(server, actor);
|
|
||||||
|
|
||||||
const projects = JSON.parse(
|
|
||||||
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
|
|
||||||
);
|
|
||||||
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']);
|
|
||||||
|
|
||||||
const missions = JSON.parse(
|
|
||||||
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
|
|
||||||
);
|
|
||||||
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']);
|
|
||||||
|
|
||||||
const tasks = JSON.parse(
|
|
||||||
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
|
|
||||||
);
|
|
||||||
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
|
|
||||||
const { service, brain } = makeService({
|
|
||||||
projects: [
|
|
||||||
{ id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
|
|
||||||
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
|
|
||||||
],
|
|
||||||
missions: [
|
|
||||||
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
|
|
||||||
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
|
|
||||||
],
|
|
||||||
tasks: [
|
|
||||||
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
|
|
||||||
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const { server, tools } = makeCapturingServer();
|
|
||||||
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
|
|
||||||
|
|
||||||
service.registerTools(server, actor);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
getTool(tools, 'brain_create_task').handler({
|
|
||||||
title: 'unscoped tenant write',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('MCP task scope denied');
|
|
||||||
expect(brain.tasks.create).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
getTool(tools, 'brain_create_task').handler({
|
|
||||||
title: 'cross-tenant write',
|
|
||||||
projectId: 'project-tenant-b',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('MCP task project scope denied');
|
|
||||||
expect(brain.tasks.create).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
getTool(tools, 'brain_update_task').handler({
|
|
||||||
id: 'task-tenant-a',
|
|
||||||
projectId: 'project-tenant-b',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('MCP task project scope denied');
|
|
||||||
expect(brain.tasks.update).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
const updateResult = await getTool(tools, 'brain_update_task').handler({
|
|
||||||
id: 'task-tenant-b',
|
|
||||||
title: 'cross-tenant update',
|
|
||||||
});
|
|
||||||
expect(updateResult.content[0]!.text).toBe('Task not found: task-tenant-b');
|
|
||||||
expect(brain.tasks.update).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
await getTool(tools, 'brain_create_task').handler({
|
|
||||||
title: 'same-tenant write',
|
|
||||||
projectId: 'project-tenant-a',
|
|
||||||
});
|
|
||||||
expect(brain.tasks.create).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ projectId: 'project-tenant-a', title: 'same-tenant write' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps admin-only coordination tools on server-derived paths', async () => {
|
|
||||||
const { service, coord } = makeService();
|
|
||||||
const { server, tools } = makeCapturingServer();
|
|
||||||
const member = makeMemberActor('authenticated-user');
|
|
||||||
const tenantAdmin = makeAdminActor('admin-user');
|
|
||||||
const platformAdmin = makePlatformAdminActor('platform-admin-user');
|
|
||||||
|
|
||||||
service.registerTools(server, member);
|
|
||||||
const memberTool = getTool(tools, 'coord_list_tasks');
|
|
||||||
expect(memberTool.inputSchema.safeParse({ projectPath: '/tmp/victim' }).success).toBe(false);
|
|
||||||
await expect(memberTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
|
|
||||||
|
|
||||||
tools.clear();
|
|
||||||
service.registerTools(server, tenantAdmin);
|
|
||||||
const tenantAdminTool = getTool(tools, 'coord_list_tasks');
|
|
||||||
await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
|
|
||||||
|
|
||||||
tools.clear();
|
|
||||||
service.registerTools(server, platformAdmin);
|
|
||||||
const platformAdminTool = getTool(tools, 'coord_list_tasks');
|
|
||||||
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
|
|
||||||
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not attach a guessed or stale-scope MCP session to another authenticated context', async () => {
|
|
||||||
const { service } = makeService();
|
|
||||||
const owner = makeMemberActor('owner-user');
|
|
||||||
const attacker = makeMemberActor('attacker-user');
|
|
||||||
const admin = makeAdminActor('admin-user');
|
|
||||||
const downgradedAdmin = makeMemberActor('admin-user');
|
|
||||||
|
|
||||||
const { sessionId, transport } = service.createSession(owner);
|
|
||||||
const staleSession = service.createSession(admin);
|
|
||||||
|
|
||||||
expect(service.getSession(sessionId, owner)).toBe(transport);
|
|
||||||
expect(service.getSession(sessionId, attacker)).toBeNull();
|
|
||||||
expect(service.getSession(sessionId, owner)).toBe(transport);
|
|
||||||
expect(service.getSession(staleSession.sessionId, downgradedAdmin)).toBeNull();
|
|
||||||
expect(service.getSession(staleSession.sessionId, admin)).toBe(staleSession.transport);
|
|
||||||
|
|
||||||
await service.onModuleDestroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,216 +10,11 @@ import { MEMORY } from '../memory/memory.tokens.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';
|
||||||
|
|
||||||
export const MCP_CALLER_IDENTITY_FIELDS = [
|
|
||||||
'actorId',
|
|
||||||
'actor',
|
|
||||||
'authenticatedUserId',
|
|
||||||
'channel',
|
|
||||||
'organizationId',
|
|
||||||
'ownerId',
|
|
||||||
'sessionUserId',
|
|
||||||
'teamId',
|
|
||||||
'tenant',
|
|
||||||
'tenantId',
|
|
||||||
'user',
|
|
||||||
'userId',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type McpCallerIdentityField = (typeof MCP_CALLER_IDENTITY_FIELDS)[number];
|
|
||||||
|
|
||||||
export const MCP_TOOL_SCOPES = {
|
|
||||||
brain_list_projects: 'brain:project:read',
|
|
||||||
brain_get_project: 'brain:project:read',
|
|
||||||
brain_list_tasks: 'brain:task:read',
|
|
||||||
brain_create_task: 'brain:task:write',
|
|
||||||
brain_update_task: 'brain:task:write',
|
|
||||||
brain_list_missions: 'brain:mission:read',
|
|
||||||
brain_list_conversations: 'brain:conversation:read',
|
|
||||||
memory_search: 'memory:insight:read',
|
|
||||||
memory_get_preferences: 'memory:preference:read',
|
|
||||||
memory_save_preference: 'memory:preference:write',
|
|
||||||
memory_save_insight: 'memory:insight:write',
|
|
||||||
coord_mission_status: 'coord:read',
|
|
||||||
coord_list_tasks: 'coord:read',
|
|
||||||
coord_task_detail: 'coord:read',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type McpToolName = keyof typeof MCP_TOOL_SCOPES;
|
|
||||||
export type McpToolScope = (typeof MCP_TOOL_SCOPES)[McpToolName];
|
|
||||||
|
|
||||||
export interface McpActorContext {
|
|
||||||
userId: string;
|
|
||||||
tenantId: string;
|
|
||||||
role: string;
|
|
||||||
channel: 'mcp';
|
|
||||||
correlationId: string;
|
|
||||||
scopes: ReadonlySet<McpToolScope>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionEntry {
|
interface SessionEntry {
|
||||||
server: McpServer;
|
server: McpServer;
|
||||||
transport: StreamableHTTPServerTransport;
|
transport: StreamableHTTPServerTransport;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
actor: McpActorContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GLOBAL_ADMIN_MCP_SCOPES = new Set<McpToolScope>(Object.values(MCP_TOOL_SCOPES));
|
|
||||||
const TENANT_ADMIN_MCP_SCOPES = new Set<McpToolScope>([
|
|
||||||
MCP_TOOL_SCOPES.brain_list_projects,
|
|
||||||
MCP_TOOL_SCOPES.brain_get_project,
|
|
||||||
MCP_TOOL_SCOPES.brain_list_tasks,
|
|
||||||
MCP_TOOL_SCOPES.brain_create_task,
|
|
||||||
MCP_TOOL_SCOPES.brain_update_task,
|
|
||||||
MCP_TOOL_SCOPES.brain_list_missions,
|
|
||||||
MCP_TOOL_SCOPES.brain_list_conversations,
|
|
||||||
MCP_TOOL_SCOPES.memory_search,
|
|
||||||
MCP_TOOL_SCOPES.memory_get_preferences,
|
|
||||||
MCP_TOOL_SCOPES.memory_save_preference,
|
|
||||||
MCP_TOOL_SCOPES.memory_save_insight,
|
|
||||||
]);
|
|
||||||
const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
|
|
||||||
MCP_TOOL_SCOPES.brain_list_projects,
|
|
||||||
MCP_TOOL_SCOPES.brain_get_project,
|
|
||||||
MCP_TOOL_SCOPES.brain_list_tasks,
|
|
||||||
MCP_TOOL_SCOPES.brain_list_missions,
|
|
||||||
MCP_TOOL_SCOPES.brain_list_conversations,
|
|
||||||
MCP_TOOL_SCOPES.memory_search,
|
|
||||||
MCP_TOOL_SCOPES.memory_get_preferences,
|
|
||||||
MCP_TOOL_SCOPES.memory_save_preference,
|
|
||||||
MCP_TOOL_SCOPES.memory_save_insight,
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function deriveMcpToolScopesForUser(input: {
|
|
||||||
role?: string | null;
|
|
||||||
}): ReadonlySet<McpToolScope> {
|
|
||||||
if (input.role === 'platform-admin' || input.role === 'super-admin') {
|
|
||||||
return new Set(GLOBAL_ADMIN_MCP_SCOPES);
|
|
||||||
}
|
|
||||||
if (input.role === 'admin') {
|
|
||||||
return new Set(TENANT_ADMIN_MCP_SCOPES);
|
|
||||||
}
|
|
||||||
return new Set(MEMBER_MCP_SCOPES);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMcpActorContext(input: {
|
|
||||||
userId: string;
|
userId: string;
|
||||||
tenantId?: string;
|
|
||||||
role?: string | null;
|
|
||||||
scopes?: Iterable<McpToolScope>;
|
|
||||||
correlationId?: string;
|
|
||||||
}): McpActorContext {
|
|
||||||
const userId = input.userId.trim();
|
|
||||||
if (userId.length === 0) {
|
|
||||||
throw new Error('MCP authenticated user is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
userId,
|
|
||||||
tenantId: input.tenantId?.trim() || `user:${userId}`,
|
|
||||||
role: input.role ?? 'member',
|
|
||||||
channel: 'mcp',
|
|
||||||
correlationId: input.correlationId ?? randomUUID(),
|
|
||||||
scopes: new Set(input.scopes ?? []),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertNoCallerControlledIdentity(params: unknown): void {
|
|
||||||
if (params === null || typeof params !== 'object') return;
|
|
||||||
|
|
||||||
const keys = new Set(Object.keys(params));
|
|
||||||
const forbidden = MCP_CALLER_IDENTITY_FIELDS.find((field: McpCallerIdentityField) =>
|
|
||||||
keys.has(field),
|
|
||||||
);
|
|
||||||
if (forbidden) {
|
|
||||||
throw new Error(`MCP caller-controlled identity field is forbidden: ${forbidden}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertMcpToolAuthorized(
|
|
||||||
actor: McpActorContext,
|
|
||||||
toolName: McpToolName,
|
|
||||||
params: unknown,
|
|
||||||
): void {
|
|
||||||
assertNoCallerControlledIdentity(params);
|
|
||||||
const requiredScope = MCP_TOOL_SCOPES[toolName];
|
|
||||||
if (!actor.scopes.has(requiredScope)) {
|
|
||||||
throw new Error(`MCP tool scope denied: ${requiredScope}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function strictObject<T extends z.ZodRawShape>(shape: T): z.ZodObject<T> {
|
|
||||||
return z.object(shape).strict();
|
|
||||||
}
|
|
||||||
|
|
||||||
type TenantScopedLike = {
|
|
||||||
tenantId?: string | null;
|
|
||||||
organizationId?: string | null;
|
|
||||||
teamId?: string | null;
|
|
||||||
};
|
|
||||||
type ProjectLike = TenantScopedLike & { id: string; ownerId?: string | null };
|
|
||||||
type MissionLike = TenantScopedLike & {
|
|
||||||
id: string;
|
|
||||||
projectId?: string | null;
|
|
||||||
userId?: string | null;
|
|
||||||
};
|
|
||||||
type TaskLike = TenantScopedLike & {
|
|
||||||
projectId?: string | null;
|
|
||||||
missionId?: string | null;
|
|
||||||
userId?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isGlobalAdminActor(actor: McpActorContext): boolean {
|
|
||||||
return actor.role === 'platform-admin' || actor.role === 'super-admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTenantAdminActor(actor: McpActorContext): boolean {
|
|
||||||
return actor.role === 'admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean {
|
|
||||||
return (
|
|
||||||
record.tenantId === actor.tenantId ||
|
|
||||||
record.organizationId === actor.tenantId ||
|
|
||||||
record.teamId === actor.tenantId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterProjectsForActor<T extends ProjectLike>(actor: McpActorContext, projects: T[]): T[] {
|
|
||||||
if (isGlobalAdminActor(actor)) return projects;
|
|
||||||
return projects.filter(
|
|
||||||
(project) =>
|
|
||||||
project.ownerId === actor.userId ||
|
|
||||||
(isTenantAdminActor(actor) && matchesTenant(actor, project)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterMissionsByDirectActorScope<T extends MissionLike>(
|
|
||||||
actor: McpActorContext,
|
|
||||||
missions: T[],
|
|
||||||
): T[] {
|
|
||||||
if (isGlobalAdminActor(actor)) return missions;
|
|
||||||
return missions.filter(
|
|
||||||
(mission) =>
|
|
||||||
mission.userId === actor.userId ||
|
|
||||||
(isTenantAdminActor(actor) && matchesTenant(actor, mission)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scopesEqual(left: ReadonlySet<McpToolScope>, right: ReadonlySet<McpToolScope>): boolean {
|
|
||||||
if (left.size !== right.size) return false;
|
|
||||||
for (const scope of left) {
|
|
||||||
if (!right.has(scope)) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameActorAuthorization(stored: McpActorContext, current: McpActorContext): boolean {
|
|
||||||
return (
|
|
||||||
stored.userId === current.userId &&
|
|
||||||
stored.tenantId === current.tenantId &&
|
|
||||||
stored.role === current.role &&
|
|
||||||
scopesEqual(stored.scopes, current.scopes)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -238,18 +33,13 @@ export class McpService implements OnModuleDestroy {
|
|||||||
* Creates a new MCP session with its own server + transport pair.
|
* Creates a new MCP session with its own server + transport pair.
|
||||||
* Returns the transport for use by the controller.
|
* Returns the transport for use by the controller.
|
||||||
*/
|
*/
|
||||||
createSession(actor: McpActorContext): {
|
createSession(userId: string): { sessionId: string; transport: StreamableHTTPServerTransport } {
|
||||||
sessionId: string;
|
|
||||||
transport: StreamableHTTPServerTransport;
|
|
||||||
} {
|
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
|
|
||||||
const transport = new StreamableHTTPServerTransport({
|
const transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: () => sessionId,
|
sessionIdGenerator: () => sessionId,
|
||||||
onsessioninitialized: (id) => {
|
onsessioninitialized: (id) => {
|
||||||
this.logger.log(
|
this.logger.log(`MCP session initialized: ${id} for user ${userId}`);
|
||||||
`MCP session initialized: ${id} for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -258,7 +48,7 @@ export class McpService implements OnModuleDestroy {
|
|||||||
{ capabilities: { tools: {} } },
|
{ capabilities: { tools: {} } },
|
||||||
);
|
);
|
||||||
|
|
||||||
this.registerTools(server, actor);
|
this.registerTools(server, userId);
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
this.logger.log(`MCP session closed: ${sessionId}`);
|
this.logger.log(`MCP session closed: ${sessionId}`);
|
||||||
@@ -271,126 +61,31 @@ export class McpService implements OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), actor });
|
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), userId });
|
||||||
return { sessionId, transport };
|
return { sessionId, transport };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the transport for an existing session only when it belongs to the
|
* Returns the transport for an existing session, or null if not found.
|
||||||
* currently authenticated MCP actor. Guessed or cross-tenant session IDs grant
|
|
||||||
* no authority.
|
|
||||||
*/
|
*/
|
||||||
getSession(sessionId: string, actor: McpActorContext): StreamableHTTPServerTransport | null {
|
getSession(sessionId: string): StreamableHTTPServerTransport | null {
|
||||||
const entry = this.sessions.get(sessionId);
|
return this.sessions.get(sessionId)?.transport ?? null;
|
||||||
if (!entry) return null;
|
|
||||||
if (!sameActorAuthorization(entry.actor, actor)) {
|
|
||||||
this.logger.warn(
|
|
||||||
`MCP session actor or scope mismatch: session=${sessionId} actor=${actor.userId} tenant=${actor.tenantId} role=${actor.role}`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return entry.transport;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise<boolean> {
|
|
||||||
if (isGlobalAdminActor(actor)) return true;
|
|
||||||
const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined;
|
|
||||||
return project ? filterProjectsForActor(actor, [project]).length === 1 : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async filterMissionsForActor<T extends MissionLike>(
|
|
||||||
actor: McpActorContext,
|
|
||||||
missions: T[],
|
|
||||||
): Promise<T[]> {
|
|
||||||
if (isGlobalAdminActor(actor)) return missions;
|
|
||||||
|
|
||||||
const projects = (await this.brain.projects.findAll()) as ProjectLike[];
|
|
||||||
const projectIds = new Set(
|
|
||||||
filterProjectsForActor(actor, projects).map((project) => project.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
return missions.filter(
|
|
||||||
(mission) =>
|
|
||||||
filterMissionsByDirectActorScope(actor, [mission]).length === 1 ||
|
|
||||||
(typeof mission.projectId === 'string' && projectIds.has(mission.projectId)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise<boolean> {
|
|
||||||
if (isGlobalAdminActor(actor)) return true;
|
|
||||||
const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined;
|
|
||||||
if (!mission) return false;
|
|
||||||
return (await this.filterMissionsForActor(actor, [mission])).length === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertTaskReferencesAuthorized(
|
|
||||||
actor: McpActorContext,
|
|
||||||
refs: { projectId?: string | null; missionId?: string | null },
|
|
||||||
): Promise<void> {
|
|
||||||
if (refs.projectId && !(await this.isProjectAuthorized(actor, refs.projectId))) {
|
|
||||||
throw new Error('MCP task project scope denied');
|
|
||||||
}
|
|
||||||
if (refs.missionId && !(await this.isMissionAuthorized(actor, refs.missionId))) {
|
|
||||||
throw new Error('MCP task mission scope denied');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertTaskCreateScopeAuthorized(
|
|
||||||
actor: McpActorContext,
|
|
||||||
refs: { projectId?: string | null; missionId?: string | null },
|
|
||||||
): Promise<void> {
|
|
||||||
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
|
|
||||||
throw new Error('MCP task scope denied');
|
|
||||||
}
|
|
||||||
await this.assertTaskReferencesAuthorized(actor, refs);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async filterTasksForActor<T extends TaskLike>(
|
|
||||||
actor: McpActorContext,
|
|
||||||
tasks: T[],
|
|
||||||
): Promise<T[]> {
|
|
||||||
if (isGlobalAdminActor(actor)) return tasks;
|
|
||||||
|
|
||||||
const [projects, missions] = await Promise.all([
|
|
||||||
this.brain.projects.findAll(),
|
|
||||||
this.brain.missions.findAll(),
|
|
||||||
]);
|
|
||||||
const projectIds = new Set(
|
|
||||||
filterProjectsForActor(actor, projects as ProjectLike[]).map((project) => project.id),
|
|
||||||
);
|
|
||||||
const missionIds = new Set(
|
|
||||||
(await this.filterMissionsForActor(actor, missions as MissionLike[])).map(
|
|
||||||
(mission) => mission.id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return tasks.filter(
|
|
||||||
(task) =>
|
|
||||||
task.userId === actor.userId ||
|
|
||||||
(isTenantAdminActor(actor) && matchesTenant(actor, task)) ||
|
|
||||||
(typeof task.projectId === 'string' && projectIds.has(task.projectId)) ||
|
|
||||||
(typeof task.missionId === 'string' && missionIds.has(task.missionId)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers all platform tools on the given McpServer instance.
|
* Registers all platform tools on the given McpServer instance.
|
||||||
*/
|
*/
|
||||||
registerTools(server: McpServer, actor: McpActorContext): void {
|
private registerTools(server: McpServer, _userId: string): void {
|
||||||
// ─── Brain: Project tools ────────────────────────────────────────────
|
// ─── Brain: Project tools ────────────────────────────────────────────
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
'brain_list_projects',
|
'brain_list_projects',
|
||||||
{
|
{
|
||||||
description: 'List all projects in the brain.',
|
description: 'List all projects in the brain.',
|
||||||
inputSchema: strictObject({}),
|
inputSchema: z.object({}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async () => {
|
||||||
assertMcpToolAuthorized(actor, 'brain_list_projects', params);
|
const projects = await this.brain.projects.findAll();
|
||||||
const projects = filterProjectsForActor(
|
|
||||||
actor,
|
|
||||||
(await this.brain.projects.findAll()) as ProjectLike[],
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
|
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
|
||||||
};
|
};
|
||||||
@@ -401,21 +96,17 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'brain_get_project',
|
'brain_get_project',
|
||||||
{
|
{
|
||||||
description: 'Get a project by ID.',
|
description: 'Get a project by ID.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
id: z.string().describe('Project ID (UUID)'),
|
id: z.string().describe('Project ID (UUID)'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async ({ id, ...params }) => {
|
async ({ id }) => {
|
||||||
assertMcpToolAuthorized(actor, 'brain_get_project', params);
|
const project = await this.brain.projects.findById(id);
|
||||||
const project = (await this.brain.projects.findById(id)) as ProjectLike | undefined;
|
|
||||||
const authorizedProject = project ? filterProjectsForActor(actor, [project])[0] : undefined;
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text' as const,
|
type: 'text' as const,
|
||||||
text: authorizedProject
|
text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`,
|
||||||
? JSON.stringify(authorizedProject, null, 2)
|
|
||||||
: `Project not found: ${id}`,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -428,23 +119,20 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'brain_list_tasks',
|
'brain_list_tasks',
|
||||||
{
|
{
|
||||||
description: 'List tasks, optionally filtered by project, mission, or status.',
|
description: 'List tasks, optionally filtered by project, mission, or status.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
projectId: z.string().optional().describe('Filter by project ID'),
|
projectId: z.string().optional().describe('Filter by project ID'),
|
||||||
missionId: z.string().optional().describe('Filter by mission ID'),
|
missionId: z.string().optional().describe('Filter by mission ID'),
|
||||||
status: z.string().optional().describe('Filter by status'),
|
status: z.string().optional().describe('Filter by status'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ projectId, missionId, status }) => {
|
||||||
assertMcpToolAuthorized(actor, 'brain_list_tasks', params);
|
|
||||||
const { projectId, missionId, status } = params;
|
|
||||||
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||||
let tasks;
|
let tasks;
|
||||||
if (projectId) tasks = await this.brain.tasks.findByProject(projectId);
|
if (projectId) tasks = await this.brain.tasks.findByProject(projectId);
|
||||||
else if (missionId) tasks = await this.brain.tasks.findByMission(missionId);
|
else if (missionId) tasks = await this.brain.tasks.findByMission(missionId);
|
||||||
else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus);
|
else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus);
|
||||||
else tasks = await this.brain.tasks.findAll();
|
else tasks = await this.brain.tasks.findAll();
|
||||||
const scopedTasks = await this.filterTasksForActor(actor, tasks as TaskLike[]);
|
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(scopedTasks, null, 2) }] };
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -452,7 +140,7 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'brain_create_task',
|
'brain_create_task',
|
||||||
{
|
{
|
||||||
description: 'Create a new task in the brain.',
|
description: 'Create a new task in the brain.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
title: z.string().describe('Task title'),
|
title: z.string().describe('Task title'),
|
||||||
description: z.string().optional().describe('Task description'),
|
description: z.string().optional().describe('Task description'),
|
||||||
projectId: z.string().optional().describe('Project ID'),
|
projectId: z.string().optional().describe('Project ID'),
|
||||||
@@ -461,8 +149,6 @@ export class McpService implements OnModuleDestroy {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async (params) => {
|
||||||
assertMcpToolAuthorized(actor, 'brain_create_task', params);
|
|
||||||
await this.assertTaskCreateScopeAuthorized(actor, params);
|
|
||||||
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||||
const task = await this.brain.tasks.create({
|
const task = await this.brain.tasks.create({
|
||||||
...params,
|
...params,
|
||||||
@@ -476,7 +162,7 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'brain_update_task',
|
'brain_update_task',
|
||||||
{
|
{
|
||||||
description: 'Update an existing task.',
|
description: 'Update an existing task.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
id: z.string().describe('Task ID'),
|
id: z.string().describe('Task ID'),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
@@ -485,17 +171,9 @@ export class McpService implements OnModuleDestroy {
|
|||||||
.optional()
|
.optional()
|
||||||
.describe('not-started, in-progress, blocked, done, cancelled'),
|
.describe('not-started, in-progress, blocked, done, cancelled'),
|
||||||
priority: z.string().optional(),
|
priority: z.string().optional(),
|
||||||
projectId: z.string().optional().describe('Project ID'),
|
|
||||||
missionId: z.string().optional().describe('Mission ID'),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async ({ id, ...updates }) => {
|
async ({ id, ...updates }) => {
|
||||||
assertMcpToolAuthorized(actor, 'brain_update_task', updates);
|
|
||||||
const existing = (await this.brain.tasks.findById(id)) as TaskLike | undefined;
|
|
||||||
if (!existing || (await this.filterTasksForActor(actor, [existing])).length === 0) {
|
|
||||||
return { content: [{ type: 'text' as const, text: `Task not found: ${id}` }] };
|
|
||||||
}
|
|
||||||
await this.assertTaskReferencesAuthorized(actor, updates);
|
|
||||||
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||||
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||||
const task = await this.brain.tasks.update(id, {
|
const task = await this.brain.tasks.update(id, {
|
||||||
@@ -520,19 +198,14 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'brain_list_missions',
|
'brain_list_missions',
|
||||||
{
|
{
|
||||||
description: 'List all missions, optionally filtered by project.',
|
description: 'List all missions, optionally filtered by project.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
projectId: z.string().optional().describe('Filter by project ID'),
|
projectId: z.string().optional().describe('Filter by project ID'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ projectId }) => {
|
||||||
assertMcpToolAuthorized(actor, 'brain_list_missions', params);
|
const missions = projectId
|
||||||
const { projectId } = params;
|
? await this.brain.missions.findByProject(projectId)
|
||||||
const missions = await this.filterMissionsForActor(
|
: await this.brain.missions.findAll();
|
||||||
actor,
|
|
||||||
(projectId
|
|
||||||
? await this.brain.missions.findByProject(projectId)
|
|
||||||
: await this.brain.missions.findAll()) as MissionLike[],
|
|
||||||
);
|
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -540,12 +213,13 @@ export class McpService implements OnModuleDestroy {
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
'brain_list_conversations',
|
'brain_list_conversations',
|
||||||
{
|
{
|
||||||
description: 'List conversations for the authenticated MCP actor.',
|
description: 'List conversations for a user.',
|
||||||
inputSchema: strictObject({}),
|
inputSchema: z.object({
|
||||||
|
userId: z.string().describe('User ID'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ userId }) => {
|
||||||
assertMcpToolAuthorized(actor, 'brain_list_conversations', params);
|
const conversations = await this.brain.conversations.findAll(userId);
|
||||||
const conversations = await this.brain.conversations.findAll(actor.userId);
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
|
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
|
||||||
};
|
};
|
||||||
@@ -558,15 +232,14 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'memory_search',
|
'memory_search',
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
'Search stored insights and knowledge for the authenticated MCP actor using natural language.',
|
'Search across stored insights and knowledge using natural language. Returns semantically similar results.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
|
userId: z.string().describe('User ID to search memory for'),
|
||||||
query: z.string().describe('Natural language search query'),
|
query: z.string().describe('Natural language search query'),
|
||||||
limit: z.number().optional().describe('Max results (default 5)'),
|
limit: z.number().optional().describe('Max results (default 5)'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ userId, query, limit }) => {
|
||||||
assertMcpToolAuthorized(actor, 'memory_search', params);
|
|
||||||
const { query, limit } = params;
|
|
||||||
if (!this.embeddings.available) {
|
if (!this.embeddings.available) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -578,11 +251,7 @@ export class McpService implements OnModuleDestroy {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const embedding = await this.embeddings.embed(query);
|
const embedding = await this.embeddings.embed(query);
|
||||||
const results = await this.memory.insights.searchByEmbedding(
|
const results = await this.memory.insights.searchByEmbedding(userId, embedding, limit ?? 5);
|
||||||
actor.userId,
|
|
||||||
embedding,
|
|
||||||
limit ?? 5,
|
|
||||||
);
|
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -590,21 +259,20 @@ export class McpService implements OnModuleDestroy {
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
'memory_get_preferences',
|
'memory_get_preferences',
|
||||||
{
|
{
|
||||||
description: 'Retrieve stored preferences for the authenticated MCP actor.',
|
description: 'Retrieve stored preferences for a user.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
|
userId: z.string().describe('User ID'),
|
||||||
category: z
|
category: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Filter by category: communication, coding, workflow, appearance, general'),
|
.describe('Filter by category: communication, coding, workflow, appearance, general'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ userId, category }) => {
|
||||||
assertMcpToolAuthorized(actor, 'memory_get_preferences', params);
|
|
||||||
const { category } = params;
|
|
||||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||||
const prefs = category
|
const prefs = category
|
||||||
? await this.memory.preferences.findByUserAndCategory(actor.userId, category as Cat)
|
? await this.memory.preferences.findByUserAndCategory(userId, category as Cat)
|
||||||
: await this.memory.preferences.findByUser(actor.userId);
|
: await this.memory.preferences.findByUser(userId);
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -613,8 +281,9 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'memory_save_preference',
|
'memory_save_preference',
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
'Store a learned preference for the authenticated MCP actor (e.g., "prefers tables over paragraphs").',
|
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
|
userId: z.string().describe('User ID'),
|
||||||
key: z.string().describe('Preference key'),
|
key: z.string().describe('Preference key'),
|
||||||
value: z.string().describe('Preference value (JSON string)'),
|
value: z.string().describe('Preference value (JSON string)'),
|
||||||
category: z
|
category: z
|
||||||
@@ -623,9 +292,7 @@ export class McpService implements OnModuleDestroy {
|
|||||||
.describe('Category: communication, coding, workflow, appearance, general'),
|
.describe('Category: communication, coding, workflow, appearance, general'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ userId, key, value, category }) => {
|
||||||
assertMcpToolAuthorized(actor, 'memory_save_preference', params);
|
|
||||||
const { key, value, category } = params;
|
|
||||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||||
let parsedValue: unknown;
|
let parsedValue: unknown;
|
||||||
try {
|
try {
|
||||||
@@ -634,7 +301,7 @@ export class McpService implements OnModuleDestroy {
|
|||||||
parsedValue = value;
|
parsedValue = value;
|
||||||
}
|
}
|
||||||
const pref = await this.memory.preferences.upsert({
|
const pref = await this.memory.preferences.upsert({
|
||||||
userId: actor.userId,
|
userId,
|
||||||
key,
|
key,
|
||||||
value: parsedValue,
|
value: parsedValue,
|
||||||
category: (category as Cat) ?? 'general',
|
category: (category as Cat) ?? 'general',
|
||||||
@@ -648,8 +315,9 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'memory_save_insight',
|
'memory_save_insight',
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
'Store a learned insight, decision, or knowledge for the authenticated MCP actor.',
|
'Store a learned insight, decision, or knowledge extracted from the current interaction.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
|
userId: z.string().describe('User ID'),
|
||||||
content: z.string().describe('The insight or knowledge to store'),
|
content: z.string().describe('The insight or knowledge to store'),
|
||||||
category: z
|
category: z
|
||||||
.string()
|
.string()
|
||||||
@@ -657,13 +325,11 @@ export class McpService implements OnModuleDestroy {
|
|||||||
.describe('Category: decision, learning, preference, fact, pattern, general'),
|
.describe('Category: decision, learning, preference, fact, pattern, general'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ userId, content, category }) => {
|
||||||
assertMcpToolAuthorized(actor, 'memory_save_insight', params);
|
|
||||||
const { content, category } = params;
|
|
||||||
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
||||||
const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null;
|
const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null;
|
||||||
const insight = await this.memory.insights.create({
|
const insight = await this.memory.insights.create({
|
||||||
userId: actor.userId,
|
userId,
|
||||||
content,
|
content,
|
||||||
embedding,
|
embedding,
|
||||||
source: 'agent',
|
source: 'agent',
|
||||||
@@ -680,11 +346,16 @@ export class McpService implements OnModuleDestroy {
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
'Get the current orchestration mission status including milestones, tasks, and active session.',
|
'Get the current orchestration mission status including milestones, tasks, and active session.',
|
||||||
inputSchema: strictObject({}),
|
inputSchema: z.object({
|
||||||
|
projectPath: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Project path. Defaults to gateway working directory.'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ projectPath }) => {
|
||||||
assertMcpToolAuthorized(actor, 'coord_mission_status', params);
|
const resolvedPath = projectPath ?? process.cwd();
|
||||||
const status = await this.coordService.getMissionStatus(process.cwd());
|
const status = await this.coordService.getMissionStatus(resolvedPath);
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@@ -700,11 +371,16 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'coord_list_tasks',
|
'coord_list_tasks',
|
||||||
{
|
{
|
||||||
description: 'List all tasks from the orchestration TASKS.md file.',
|
description: 'List all tasks from the orchestration TASKS.md file.',
|
||||||
inputSchema: strictObject({}),
|
inputSchema: z.object({
|
||||||
|
projectPath: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Project path. Defaults to gateway working directory.'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ projectPath }) => {
|
||||||
assertMcpToolAuthorized(actor, 'coord_list_tasks', params);
|
const resolvedPath = projectPath ?? process.cwd();
|
||||||
const tasks = await this.coordService.listTasks(process.cwd());
|
const tasks = await this.coordService.listTasks(resolvedPath);
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -713,14 +389,17 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'coord_task_detail',
|
'coord_task_detail',
|
||||||
{
|
{
|
||||||
description: 'Get detailed status for a specific orchestration task.',
|
description: 'Get detailed status for a specific orchestration task.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
taskId: z.string().describe('Task ID (e.g. P2-005)'),
|
taskId: z.string().describe('Task ID (e.g. P2-005)'),
|
||||||
|
projectPath: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Project path. Defaults to gateway working directory.'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ taskId, projectPath }) => {
|
||||||
assertMcpToolAuthorized(actor, 'coord_task_detail', params);
|
const resolvedPath = projectPath ?? process.cwd();
|
||||||
const { taskId } = params;
|
const detail = await this.coordService.getTaskStatus(resolvedPath, taskId);
|
||||||
const detail = await this.coordService.getTaskStatus(process.cwd(), taskId);
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,443 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
createDiscordIngressEnvelope,
|
|
||||||
verifyDiscordIngressEnvelope,
|
|
||||||
DiscordPlugin,
|
|
||||||
type DiscordIngressPayload,
|
|
||||||
parseDiscordInteractionBindings,
|
|
||||||
resolveDiscordInteractionActorId,
|
|
||||||
resolveDiscordInteractionBinding,
|
|
||||||
} from '@mosaicstack/discord-plugin';
|
|
||||||
import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js';
|
|
||||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
|
||||||
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
|
||||||
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
|
||||||
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
|
||||||
|
|
||||||
const SERVICE_TOKEN = 'test-service-token';
|
|
||||||
const ENV_KEYS = [
|
|
||||||
'DISCORD_SERVICE_TOKEN',
|
|
||||||
'DISCORD_SERVICE_USER_ID',
|
|
||||||
'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 savedEnv = new Map<string, string | undefined>();
|
|
||||||
|
|
||||||
function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
|
|
||||||
for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]);
|
|
||||||
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
|
|
||||||
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
|
|
||||||
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord';
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
||||||
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001';
|
|
||||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
|
||||||
process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001';
|
|
||||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
|
||||||
{
|
|
||||||
instanceId: 'Nova',
|
|
||||||
guildId: 'guild-001',
|
|
||||||
channelId: 'channel-001',
|
|
||||||
pairedUsers: {
|
|
||||||
'user-001': {
|
|
||||||
role: role === 'admin' ? 'admin' : 'operator',
|
|
||||||
mosaicUserId: 'mosaic-admin-001',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach((): void => {
|
|
||||||
for (const key of ENV_KEYS) {
|
|
||||||
const value = savedEnv.get(key);
|
|
||||||
if (value === undefined) delete process.env[key];
|
|
||||||
else process.env[key] = value;
|
|
||||||
}
|
|
||||||
savedEnv.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
function commandAuthorization(role: 'admin' | 'member'): CommandAuthorizationService {
|
|
||||||
const entries = new Map<string, string>();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function discordGateway(role: 'admin' | 'member'): {
|
|
||||||
gateway: ChatGateway;
|
|
||||||
client: { data: { discordService: boolean }; emit: ReturnType<typeof vi.fn> };
|
|
||||||
consumedActions: Array<{ actorId: string; correlationId: string }>;
|
|
||||||
durable: { getSnapshot: ReturnType<typeof vi.fn> };
|
|
||||||
audit: { record: ReturnType<typeof vi.fn> };
|
|
||||||
} {
|
|
||||||
const authorization = commandAuthorization(role);
|
|
||||||
const consumedActions: Array<{ actorId: string; correlationId: string }> = [];
|
|
||||||
const durable = {
|
|
||||||
getSnapshot: vi.fn().mockResolvedValue({
|
|
||||||
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const audit = { record: vi.fn().mockResolvedValue(undefined) };
|
|
||||||
const runtimeRegistry = new RuntimeProviderService(
|
|
||||||
{
|
|
||||||
require: () => ({
|
|
||||||
capabilities: async () => ({ supported: ['session.terminate'] }),
|
|
||||||
terminate: async () => undefined,
|
|
||||||
}),
|
|
||||||
} as never,
|
|
||||||
{ record: async () => undefined } as never,
|
|
||||||
{
|
|
||||||
consume: async (approvalId, action) => {
|
|
||||||
consumedActions.push({ actorId: action.actorId, correlationId: action.correlationId });
|
|
||||||
return authorization.consumeRuntimeTerminationApproval(approvalId, action);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
gateway: new ChatGateway(
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
authorization,
|
|
||||||
runtimeRegistry,
|
|
||||||
durable as never,
|
|
||||||
audit as never,
|
|
||||||
),
|
|
||||||
client: { data: { discordService: true }, emit: vi.fn() },
|
|
||||||
consumedActions,
|
|
||||||
durable,
|
|
||||||
audit,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function ingressEnvelope(
|
|
||||||
content: string,
|
|
||||||
messageId: string,
|
|
||||||
overrides: Partial<DiscordIngressPayload> = {},
|
|
||||||
): ReturnType<typeof createDiscordIngressEnvelope> {
|
|
||||||
return createDiscordIngressEnvelope(
|
|
||||||
createPayload({ content, messageId, ...overrides }),
|
|
||||||
SERVICE_TOKEN,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
|
|
||||||
return {
|
|
||||||
correlationId: 'correlation-001',
|
|
||||||
messageId: 'discord-message-001',
|
|
||||||
guildId: 'guild-001',
|
|
||||||
channelId: 'channel-001',
|
|
||||||
userId: 'user-001',
|
|
||||||
conversationId: 'discord-channel-001',
|
|
||||||
content: 'hello Tess',
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Discord ingress security', () => {
|
|
||||||
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
|
|
||||||
const [binding] = parseDiscordInteractionBindings(
|
|
||||||
JSON.stringify([
|
|
||||||
{
|
|
||||||
instanceId: 'Nova',
|
|
||||||
guildId: 'guild-001',
|
|
||||||
channelId: 'channel-001',
|
|
||||||
pairedUsers: { 'user-001': 'admin' },
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
resolveDiscordInteractionBinding([binding!], 'guild-001', 'channel-001', 'user-001', 'send'),
|
|
||||||
).toEqual(binding);
|
|
||||||
expect(resolveDiscordInteractionActorId(binding!, 'user-001')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('binds a differently named configured interaction instance without code changes', () => {
|
|
||||||
const binding = resolveDiscordInteractionBinding(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
instanceId: 'Nova',
|
|
||||||
guildId: 'guild-001',
|
|
||||||
channelId: 'channel-001',
|
|
||||||
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'guild-001',
|
|
||||||
'channel-001',
|
|
||||||
'user-001',
|
|
||||||
'send',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(binding?.instanceId).toBe('Nova');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts only the configured Discord service identity', () => {
|
|
||||||
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
|
|
||||||
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
|
|
||||||
expect(validateDiscordServiceToken(undefined, SERVICE_TOKEN)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects unauthenticated or tampered service envelopes', () => {
|
|
||||||
const envelope = createDiscordIngressEnvelope(createPayload(), SERVICE_TOKEN);
|
|
||||||
|
|
||||||
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)).toEqual(createPayload());
|
|
||||||
expect(verifyDiscordIngressEnvelope(envelope, 'wrong-service-token')).toBeNull();
|
|
||||||
expect(
|
|
||||||
verifyDiscordIngressEnvelope(
|
|
||||||
{ ...envelope, payload: { ...envelope.payload, content: 'forged command' } },
|
|
||||||
SERVICE_TOKEN,
|
|
||||||
),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['guild', { guildId: 'unlisted-guild' }],
|
|
||||||
['channel', { channelId: 'unlisted-channel' }],
|
|
||||||
['user', { userId: 'unlisted-user' }],
|
|
||||||
])(
|
|
||||||
'rejects an unallowlisted Discord %s',
|
|
||||||
(_kind: string, overrides: Partial<DiscordIngressPayload>) => {
|
|
||||||
const envelope = createDiscordIngressEnvelope(createPayload(overrides), SERVICE_TOKEN);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
|
|
||||||
guildIds: ['guild-001'],
|
|
||||||
channelIds: ['channel-001'],
|
|
||||||
userIds: ['user-001'],
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('retains Discord message and correlation IDs after authenticated allowlisted validation', () => {
|
|
||||||
const payload = createPayload({
|
|
||||||
correlationId: 'correlation-trace-123',
|
|
||||||
messageId: 'discord-snowflake-987',
|
|
||||||
});
|
|
||||||
const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
|
|
||||||
guildIds: ['guild-001'],
|
|
||||||
channelIds: ['channel-001'],
|
|
||||||
userIds: ['user-001'],
|
|
||||||
}),
|
|
||||||
).toEqual(payload);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a replayed Discord message ID while retaining bounded replay state', () => {
|
|
||||||
const replayProtector = new DiscordReplayProtector(60_000, 2);
|
|
||||||
|
|
||||||
expect(replayProtector.claim('discord-message-001')).toBe(true);
|
|
||||||
expect(replayProtector.claim('discord-message-001')).toBe(false);
|
|
||||||
expect(replayProtector.claim('discord-message-002')).toBe(true);
|
|
||||||
expect(replayProtector.claim('discord-message-003')).toBe(true);
|
|
||||||
expect(replayProtector.size).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('consumes the exact target once when approval and stop are separate Discord messages', async () => {
|
|
||||||
configureDiscordEnv();
|
|
||||||
const { gateway, client, consumedActions } = discordGateway('admin');
|
|
||||||
await gateway.handleDiscordApproval(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('/approve', 'approve-message', {
|
|
||||||
correlationId: 'approval-ingress-correlation',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
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,
|
|
||||||
ingressEnvelope(`/stop ${approval.approvalId}`, 'stop-message', {
|
|
||||||
correlationId: 'stop-ingress-correlation',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
|
|
||||||
correlationId: 'stop-ingress-correlation',
|
|
||||||
success: true,
|
|
||||||
});
|
|
||||||
expect(consumedActions).toEqual([
|
|
||||||
{
|
|
||||||
actorId: 'mosaic-admin-001',
|
|
||||||
correlationId: expect.stringMatching(/^discord-action:v1:/),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('audits a Discord mint-side authorization denial', async () => {
|
|
||||||
configureDiscordEnv();
|
|
||||||
const { gateway, client, audit } = discordGateway('member');
|
|
||||||
|
|
||||||
await gateway.handleDiscordApproval(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('/approve', 'denied-approve'),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
|
||||||
correlationId: 'correlation-001',
|
|
||||||
success: false,
|
|
||||||
approvalId: undefined,
|
|
||||||
expiresAt: undefined,
|
|
||||||
});
|
|
||||||
expect(audit.record).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
outcome: 'denied',
|
|
||||||
operation: 'session.terminate',
|
|
||||||
errorCode: 'policy_denied',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[
|
|
||||||
'binding',
|
|
||||||
() => {
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Other';
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'durable session',
|
|
||||||
(durable: { getSnapshot: ReturnType<typeof vi.fn> }) => {
|
|
||||||
durable.getSnapshot.mockResolvedValueOnce({
|
|
||||||
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
],
|
|
||||||
])(
|
|
||||||
'rejects approval when the %s targets a different runtime agent',
|
|
||||||
async (_source, configure) => {
|
|
||||||
configureDiscordEnv();
|
|
||||||
const { gateway, client, durable } = discordGateway('admin');
|
|
||||||
configure(durable);
|
|
||||||
|
|
||||||
await gateway.handleDiscordApproval(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('/approve', 'mismatched-agent-approve'),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
|
||||||
correlationId: 'correlation-001',
|
|
||||||
success: false,
|
|
||||||
approvalId: undefined,
|
|
||||||
expiresAt: undefined,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('rejects unpaired and non-admin Discord users for approval and stop', async () => {
|
|
||||||
configureDiscordEnv();
|
|
||||||
const { gateway, client } = discordGateway('member');
|
|
||||||
await gateway.handleDiscordApproval(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('/approve', 'member-approve'),
|
|
||||||
);
|
|
||||||
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
|
||||||
correlationId: 'correlation-001',
|
|
||||||
success: false,
|
|
||||||
approvalId: undefined,
|
|
||||||
expiresAt: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([]);
|
|
||||||
await gateway.handleDiscordStop(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('/stop forged', 'unpaired-stop'),
|
|
||||||
);
|
|
||||||
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects replaying a Discord-created termination approval', async () => {
|
|
||||||
configureDiscordEnv();
|
|
||||||
const { gateway, client } = discordGateway('admin');
|
|
||||||
await gateway.handleDiscordApproval(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope('/approve', 'replay-approve', {
|
|
||||||
correlationId: 'replay-approval-correlation',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const approval = client.emit.mock.calls.find(
|
|
||||||
([event]) => event === 'discord:approval',
|
|
||||||
)?.[1] as {
|
|
||||||
approvalId: string;
|
|
||||||
};
|
|
||||||
await gateway.handleDiscordStop(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-one', {
|
|
||||||
correlationId: 'replay-stop-correlation-one',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await gateway.handleDiscordStop(
|
|
||||||
client as never,
|
|
||||||
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-two', {
|
|
||||||
correlationId: 'replay-stop-correlation-two',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const stopResults = client.emit.mock.calls.filter(([event]) => event === 'discord:stop');
|
|
||||||
expect(stopResults.map(([, result]) => (result as { success: boolean }).success)).toEqual([
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a thread message through its allowed bound parent channel', () => {
|
|
||||||
const emitted = vi.fn();
|
|
||||||
const plugin = new DiscordPlugin({
|
|
||||||
token: 'unused',
|
|
||||||
gatewayUrl: 'http://unused',
|
|
||||||
serviceToken: SERVICE_TOKEN,
|
|
||||||
allowedGuildIds: ['guild-001'],
|
|
||||||
allowedChannelIds: ['channel-001'],
|
|
||||||
allowedUserIds: ['user-001'],
|
|
||||||
interactionBindings: [
|
|
||||||
{
|
|
||||||
instanceId: 'Nova',
|
|
||||||
guildId: 'guild-001',
|
|
||||||
channelId: 'channel-001',
|
|
||||||
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const internals = plugin as unknown as {
|
|
||||||
client: { user: { id: string } };
|
|
||||||
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
|
|
||||||
handleDiscordMessage(message: unknown): void;
|
|
||||||
};
|
|
||||||
internals.client = { user: { id: 'bot-001' } };
|
|
||||||
internals.socket = { connected: true, emit: emitted };
|
|
||||||
internals.handleDiscordMessage({
|
|
||||||
id: 'thread-message',
|
|
||||||
guildId: 'guild-001',
|
|
||||||
channelId: 'thread-001',
|
|
||||||
author: { id: 'user-001', bot: false },
|
|
||||||
mentions: { has: () => true },
|
|
||||||
content: '<@bot-001> hello from thread',
|
|
||||||
channel: { parentId: 'channel-001' },
|
|
||||||
attachments: new Map(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const [, envelope] = emitted.mock.calls[0] as [
|
|
||||||
string,
|
|
||||||
ReturnType<typeof createDiscordIngressEnvelope>,
|
|
||||||
];
|
|
||||||
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)?.channelId).toBe('channel-001');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/**
|
|
||||||
* Bounded replay cache for Discord's globally unique native message IDs.
|
|
||||||
* Durable ingress idempotency is added with Tess's canonical inbox/outbox work.
|
|
||||||
*/
|
|
||||||
export class DiscordReplayProtector {
|
|
||||||
private readonly claimedAt = new Map<string, number>();
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly ttlMs = 15 * 60 * 1000,
|
|
||||||
private readonly maxEntries = 10_000,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
get size(): number {
|
|
||||||
return this.claimedAt.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Claims an ID exactly once within its bounded retention window. */
|
|
||||||
claim(messageId: string, now = Date.now()): boolean {
|
|
||||||
this.prune(now);
|
|
||||||
if (this.claimedAt.has(messageId)) return false;
|
|
||||||
|
|
||||||
this.claimedAt.set(messageId, now);
|
|
||||||
this.evictOverflow();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private prune(now: number): void {
|
|
||||||
for (const [messageId, claimedAt] of this.claimedAt) {
|
|
||||||
if (now - claimedAt >= this.ttlMs) this.claimedAt.delete(messageId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private evictOverflow(): void {
|
|
||||||
while (this.claimedAt.size > this.maxEntries) {
|
|
||||||
const oldestMessageId = this.claimedAt.keys().next().value;
|
|
||||||
if (oldestMessageId === undefined) return;
|
|
||||||
this.claimedAt.delete(oldestMessageId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type OnModuleDestroy,
|
type OnModuleDestroy,
|
||||||
type OnModuleInit,
|
type OnModuleInit,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DiscordPlugin, parseDiscordInteractionBindings } from '@mosaicstack/discord-plugin';
|
import { DiscordPlugin } from '@mosaicstack/discord-plugin';
|
||||||
import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
|
import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
|
||||||
import { PluginService } from './plugin.service.js';
|
import { PluginService } from './plugin.service.js';
|
||||||
import type { IChannelPlugin } from './plugin.interface.js';
|
import type { IChannelPlugin } from './plugin.interface.js';
|
||||||
@@ -50,44 +50,19 @@ class TelegramChannelPluginAdapter implements IChannelPlugin {
|
|||||||
|
|
||||||
const DEFAULT_GATEWAY_URL = 'http://localhost:14242';
|
const DEFAULT_GATEWAY_URL = 'http://localhost:14242';
|
||||||
|
|
||||||
function requiredDiscordAllowlist(name: string): string[] {
|
|
||||||
const value = process.env[name]
|
|
||||||
?.split(',')
|
|
||||||
.map((id: string): string => id.trim())
|
|
||||||
.filter((id: string): boolean => id.length > 0);
|
|
||||||
if (!value || value.length === 0) {
|
|
||||||
throw new Error(`${name} is required when DISCORD_BOT_TOKEN is configured`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPluginRegistry(): IChannelPlugin[] {
|
function createPluginRegistry(): IChannelPlugin[] {
|
||||||
const plugins: IChannelPlugin[] = [];
|
const plugins: IChannelPlugin[] = [];
|
||||||
const discordToken = process.env['DISCORD_BOT_TOKEN'];
|
const discordToken = process.env['DISCORD_BOT_TOKEN'];
|
||||||
const discordGuildId = process.env['DISCORD_GUILD_ID'];
|
const discordGuildId = process.env['DISCORD_GUILD_ID'];
|
||||||
const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
|
const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
|
||||||
const discordServiceToken = process.env['DISCORD_SERVICE_TOKEN'];
|
|
||||||
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
|
|
||||||
|
|
||||||
if (discordToken) {
|
if (discordToken) {
|
||||||
if (!discordServiceToken || !discordServiceUserId) {
|
|
||||||
throw new Error(
|
|
||||||
'DISCORD_SERVICE_TOKEN and DISCORD_SERVICE_USER_ID are required when DISCORD_BOT_TOKEN is configured',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
plugins.push(
|
plugins.push(
|
||||||
new DiscordChannelPluginAdapter(
|
new DiscordChannelPluginAdapter(
|
||||||
new DiscordPlugin({
|
new DiscordPlugin({
|
||||||
token: discordToken,
|
token: discordToken,
|
||||||
guildId: discordGuildId,
|
guildId: discordGuildId,
|
||||||
gatewayUrl: discordGatewayUrl,
|
gatewayUrl: discordGatewayUrl,
|
||||||
serviceToken: discordServiceToken,
|
|
||||||
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
|
|
||||||
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
|
|
||||||
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
|
|
||||||
interactionBindings: parseDiscordInteractionBindings(
|
|
||||||
process.env['DISCORD_INTERACTION_BINDINGS'],
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
|
||||||
|
|
||||||
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
|
||||||
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>
|
||||||
const SESSION_SYSTEM_KEY = (sessionId: string, scope: ActorTenantScope) =>
|
`mosaic:session:${sessionId}:system:fragments`;
|
||||||
`mosaic:session:${scopedSessionId(sessionId, scope)}:system`;
|
|
||||||
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string, scope: ActorTenantScope) =>
|
|
||||||
`mosaic:session:${scopedSessionId(sessionId, scope)}:system:fragments`;
|
|
||||||
const SYSTEM_OVERRIDE_TTL_SECONDS = 604800; // 7 days
|
const SYSTEM_OVERRIDE_TTL_SECONDS = 604800; // 7 days
|
||||||
|
|
||||||
interface OverrideFragment {
|
interface OverrideFragment {
|
||||||
@@ -24,9 +20,9 @@ export class SystemOverrideService {
|
|||||||
this.handle = createQueue();
|
this.handle = createQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
async set(sessionId: string, override: string): Promise<void> {
|
||||||
// Load existing fragments
|
// Load existing fragments
|
||||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId));
|
||||||
const fragments: OverrideFragment[] = existing
|
const fragments: OverrideFragment[] = existing
|
||||||
? (JSON.parse(existing) as OverrideFragment[])
|
? (JSON.parse(existing) as OverrideFragment[])
|
||||||
: [];
|
: [];
|
||||||
@@ -41,11 +37,11 @@ export class SystemOverrideService {
|
|||||||
// Store both: fragments array and condensed result
|
// Store both: fragments array and condensed result
|
||||||
const pipeline = this.handle.redis.pipeline();
|
const pipeline = this.handle.redis.pipeline();
|
||||||
pipeline.setex(
|
pipeline.setex(
|
||||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
||||||
SYSTEM_OVERRIDE_TTL_SECONDS,
|
SYSTEM_OVERRIDE_TTL_SECONDS,
|
||||||
JSON.stringify(fragments),
|
JSON.stringify(fragments),
|
||||||
);
|
);
|
||||||
pipeline.setex(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
|
pipeline.setex(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
|
||||||
await pipeline.exec();
|
await pipeline.exec();
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
@@ -53,21 +49,21 @@ export class SystemOverrideService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
async get(sessionId: string): Promise<string | null> {
|
||||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async renew(sessionId: string): Promise<void> {
|
||||||
const pipeline = this.handle.redis.pipeline();
|
const pipeline = this.handle.redis.pipeline();
|
||||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
await pipeline.exec();
|
await pipeline.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async clear(sessionId: string): Promise<void> {
|
||||||
await this.handle.redis.del(
|
await this.handle.redis.del(
|
||||||
SESSION_SYSTEM_KEY(sessionId, scope),
|
SESSION_SYSTEM_KEY(sessionId),
|
||||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
||||||
);
|
);
|
||||||
this.logger.debug(`Cleared system override for session ${sessionId}`);
|
this.logger.debug(`Cleared system override for session ${sessionId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,23 +162,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove every existing repeatable schedule for a job name. This supports
|
|
||||||
* safe retirement of previously registered system-wide jobs.
|
|
||||||
*/
|
|
||||||
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
|
||||||
const queue = this.getQueue(queueName);
|
|
||||||
const jobs = await queue.getRepeatableJobs();
|
|
||||||
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
|
||||||
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
|
||||||
if (matchingJobs.length > 0) {
|
|
||||||
this.logger.log(
|
|
||||||
`Removed ${matchingJobs.length} repeatable "${jobName}" job(s) from "${queueName}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return matchingJobs.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a Worker for the given queue name with error handling and
|
* Register a Worker for the given queue name with error handling and
|
||||||
* exponential backoff.
|
* exponential backoff.
|
||||||
|
|||||||
@@ -67,11 +67,10 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c
|
|||||||
|
|
||||||
## Workstreams
|
## Workstreams
|
||||||
|
|
||||||
| # | ID | Name | Status | Manifest | Notes |
|
| # | ID | Name | Status | Manifest | Notes |
|
||||||
| --- | ---- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
| --- | --- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
||||||
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
||||||
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
|
| W2+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
||||||
| W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
|
||||||
|
|
||||||
### Likely Additional Workstreams (Not Yet Declared)
|
### Likely Additional Workstreams (Not Yet Declared)
|
||||||
|
|
||||||
|
|||||||
96
docs/PRD.md
96
docs/PRD.md
@@ -79,102 +79,6 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tess Interaction Agent Workstream (TESS)
|
|
||||||
|
|
||||||
### Problem and Objective
|
|
||||||
|
|
||||||
Jason needs one durable, operator-facing Mosaic agent outside Hermes that is reachable through a dedicated Discord channel and CLI, can attach to and operate the Mosaic fleet and transitional Hermes agents, and preserves context across restarts and compaction. Mos remains the coding/general fleet orchestrator; Tess is the complementary human interaction, visibility, control, and migration agent.
|
|
||||||
|
|
||||||
The objective is to ship **Tess** (from _tessera_, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
#### In Scope
|
|
||||||
|
|
||||||
1. `TESS-ARP-001`: A runtime-neutral `AgentRuntimeProvider` contract supporting `listSessions`, `streamSession`, `sendMessage`, `terminate`, `getSessionTree`, `attach`, health, capability discovery, and normalized events/errors.
|
|
||||||
2. `TESS-PI-001`: A long-running Pi-native Tess agent profile/service pinned to GPT-5.6 Sol with high reasoning, explicit tool policy, lifecycle hooks, durable checkpoints, and restart recovery.
|
|
||||||
3. `TESS-DSC-001`: Dedicated Discord channel binding to Tess through the Mosaic gateway, with allowlists/RBAC, thread/reply policy, streaming, attachments, approvals, and correlation IDs.
|
|
||||||
4. `TESS-CLI-001`: `mosaic tess` CLI commands for chat, status, session listing, attach/detach, send/steer/stop, provider health, and recovery.
|
|
||||||
5. `TESS-FLT-001`: Fleet plugin capabilities for roster/status/heartbeat inspection, message delivery, session hierarchy, safe attach, and controlled restart/recovery.
|
|
||||||
6. `TESS-MOS-001`: Explicit Mos coordination boundary and tools: hand off orchestration requests, observe mission/task state, receive results, and never silently compete for orchestration authority.
|
|
||||||
7. `TESS-HRM-001`: Transitional Hermes adapter for profiles/agents, sessions, streaming/messages, Kanban, skills, memory, tools, cron, and health, using capability negotiation and fail-closed unsupported operations.
|
|
||||||
8. `TESS-MEM-001`: Unified memory/retrieval plugin with scoped search/recent/capture/stats, startup context injection, provenance, redaction, namespace isolation, and flat-file/project truth precedence.
|
|
||||||
9. `TESS-STA-001`: Durable agent state, inbox, handoff, compaction-recovery, and resume reconstruction.
|
|
||||||
10. `TESS-PLG-001`: Plugin/tool catalog covering runtime bootstrap, repository/PR workflow, fleet diagnostics, incident-safe read operations, Discord interaction, and extensible MCP/skill discovery.
|
|
||||||
11. `TESS-TRN-001`: Replaceable transport providers: tmux/fleet now, Matrix/native Mosaic transport later, with no Discord/CLI business logic coupled to transport details.
|
|
||||||
12. `TESS-SEC-001`: RBAC, per-operation authorization, explicit approval for destructive/privileged/customer-visible actions, audit events, secret/PII redaction, tenant isolation, and bounded command execution.
|
|
||||||
13. `TESS-SEC-002`: Command execution SHALL enforce declared scope/role server-side; admin/system and destructive operations SHALL require policy-bound durable approval.
|
|
||||||
14. `TESS-SEC-003`: Every session list/read/attach/send/terminate operation SHALL enforce server-derived owner and tenant scope; guessed or client-supplied IDs SHALL grant no authority.
|
|
||||||
15. `TESS-SEC-004`: MCP tools SHALL derive actor/tenant from authenticated context and SHALL NOT accept caller-controlled identity fields.
|
|
||||||
16. `TESS-SEC-005`: Discord plugin ingress SHALL authenticate service identity, enforce guild/channel/user allowlists, propagate correlation/message IDs, and reject replay.
|
|
||||||
17. `TESS-SEC-006`: Secret/PII classification and redaction SHALL occur before persistence and before channel egress, including tool metadata and authentication flows.
|
|
||||||
18. `TESS-SEC-007`: Approvals SHALL be one-time, expiring, actor/tenant-bound, and cryptographically bound to the exact structured action digest.
|
|
||||||
19. `TESS-SEC-008`: Ingress, provider sends, tool side effects, and responses SHALL use durable inbox/outbox/checkpoints and idempotency records for restart-safe replay.
|
|
||||||
20. `TESS-SEC-009`: Garbage collection and retention SHALL be session/tenant scoped unless executed as a separately authorized and audited system-wide job.
|
|
||||||
21. `TESS-OBS-001`: Structured logs, traces, health/readiness, provider latency/errors, session lifecycle, tool audit, and actionable recovery diagnostics.
|
|
||||||
22. `TESS-MIG-001`: Capability inventory and staged Hermes-to-Mosaic migration matrix with coexistence, cutover, rollback, and deprecation gates.
|
|
||||||
|
|
||||||
#### Out of Scope
|
|
||||||
|
|
||||||
1. Replacing Mos as coding/general fleet orchestrator.
|
|
||||||
2. Making Hermes the Mosaic core or coupling Mosaic domain logic to Hermes schemas.
|
|
||||||
3. Migrating every historical chat verbatim; only policy-compliant indexed summaries and user-selected sessions are migrated.
|
|
||||||
4. Unrestricted shell execution from Discord.
|
|
||||||
5. Full web UI parity in the first Tess operational milestone; gateway contracts must remain web-consumable.
|
|
||||||
6. Replacing tmux before Matrix/native transport reaches operational parity.
|
|
||||||
|
|
||||||
### Stakeholder and User Requirements
|
|
||||||
|
|
||||||
- Jason must be able to converse with the same Tess session from Discord and CLI.
|
|
||||||
- Jason must be able to see what is running, stale, blocked, or unhealthy without attaching manually to every session.
|
|
||||||
- Jason must be able to attach to Tess and authorized fleet sessions through supported CLI controls.
|
|
||||||
- Tess must collaborate with Mos and the fleet while preserving a single clear orchestration authority.
|
|
||||||
- The system must migrate useful Hermes/OpenClaw capabilities intentionally, with evidence, instead of copying implementations wholesale.
|
|
||||||
|
|
||||||
### Non-Functional Requirements
|
|
||||||
|
|
||||||
1. **Security:** default-deny provider/tool capabilities, least privilege, no secrets in logs/prompts/commits, Discord user/channel authorization, and auditable approvals.
|
|
||||||
2. **Reliability:** durable inbox/checkpoints; idempotent message handling; reconnect with bounded backoff; no message loss or duplicate execution across gateway restart.
|
|
||||||
3. **Performance:** first acknowledgement within 2 seconds when connected; streamed agent output begins within 5 seconds excluding model/provider delay; status reads return within 2 seconds under nominal local conditions.
|
|
||||||
4. **Observability:** every ingress message and resulting provider/tool operation carries a correlation ID across Discord, gateway, Tess, provider, and audit events.
|
|
||||||
5. **Maintainability:** channel, runtime, transport, memory, and external-agent integrations remain adapter-based with contract tests.
|
|
||||||
6. **Privacy:** only scoped context enters external runtimes; persisted messages/memories follow retention and redaction policy.
|
|
||||||
7. **Portability:** Tess runs through Pi/Mosaic contracts and does not require Hermes to start or serve native Mosaic operations.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
1. `AC-TESS-01`: A dedicated Discord channel and `mosaic tess chat` connect to one durable Tess session and stream responses bidirectionally.
|
|
||||||
2. `AC-TESS-02`: `mosaic tess status|sessions|tree|attach|send|stop` operate against authorized provider capabilities with stable typed outputs and actionable errors.
|
|
||||||
3. `AC-TESS-03`: Tess runs GPT-5.6 Sol at high reasoning and its effective runtime/model/tool policy is visible through status without exposing credentials.
|
|
||||||
4. `AC-TESS-04`: Tess can inspect and message the Mosaic fleet, hand orchestration work to Mos, and demonstrate that Tess does not independently claim Mos-owned orchestration work.
|
|
||||||
5. `AC-TESS-05`: Hermes adapter demonstrates session listing, streaming/message delivery, hierarchy mapping, and at least one approved capability in each of Kanban, skills, memory, tools, and cron—or reports unsupported capabilities fail-closed.
|
|
||||||
6. `AC-TESS-06`: Restart/compaction test preserves session identity, pending inbox, last durable checkpoint, and a resumable handoff without duplicate side effects.
|
|
||||||
7. `AC-TESS-07`: Unauthorized Discord users/channels, cross-tenant access, unsafe tool calls, forged approvals, and sensitive-output cases are denied and audited.
|
|
||||||
8. `AC-TESS-08`: tmux/fleet and Matrix/native transport implementations pass the same provider contract suite; Matrix may remain non-default until readiness gates pass.
|
|
||||||
9. `AC-TESS-09`: Baseline quality gates, unit/integration/contract tests, Discord+CLI E2E, restart/recovery tests, independent code review, and security review are green.
|
|
||||||
10. `AC-TESS-10`: Migration matrix documents every audited Hermes/OpenClaw capability as native, adapted, deferred, or rejected, with cutover and rollback evidence.
|
|
||||||
11. `AC-TESS-11`: User, admin, developer, API/OpenAPI, operations/recovery, and plugin-authoring documentation is current and linked from the sitemap.
|
|
||||||
|
|
||||||
### Constraints, Dependencies, Risks, and Assumptions
|
|
||||||
|
|
||||||
- Dependency: Mosaic gateway remains the single API surface; Pi is the native runtime; Valkey/PostgreSQL provide canonical durable state where required.
|
|
||||||
- Dependency: Discord bot credentials and dedicated channel ID are deployment secrets provisioned outside source control.
|
|
||||||
- Risk: Tess could drift into a second orchestrator. Mitigation: explicit role policy, Mos handoff contract, authority checks, and E2E boundary tests.
|
|
||||||
- Risk: broad Hermes compatibility can freeze legacy semantics into Mosaic. Mitigation: Mosaic-owned normalized contracts and capability negotiation.
|
|
||||||
- Risk: Discord creates a privileged remote-control surface. Mitigation: pairing/allowlists, RBAC, approvals, rate limits, audit, and safe tool classes.
|
|
||||||
- Risk: transcript ingestion can violate privacy or overload memory. Mitigation: scoped opt-in import, redacted summaries, provenance, retention, and deduplication.
|
|
||||||
- Risk: current root filesystem has limited headroom. Mitigation: isolated worktrees, no duplicated dependency installation unless required, and cleanup only after active-lane verification.
|
|
||||||
- `ASSUMPTION:` The public name is **Tess**, because the user requested a name and the tessera/Mosaic relationship is distinctive; config must permit later display-name changes without renaming APIs or storage keys.
|
|
||||||
- `ASSUMPTION:` The dedicated Discord channel ID and final guild policy will be supplied/provisioned during deployment, so implementation uses explicit configuration and fail-fast startup validation.
|
|
||||||
- `ASSUMPTION:` tmux/fleet is the production transport for the first operational milestone; Matrix/native transport is implemented behind the same contract and promoted only after parity/reliability verification.
|
|
||||||
- `ASSUMPTION:` Project/task truth remains in canonical Mosaic/project stores; semantic memory systems are retrieval/mirror layers, not hidden authorities.
|
|
||||||
|
|
||||||
### Testing and Delivery Intent
|
|
||||||
|
|
||||||
Delivery uses five gated milestones: runtime contracts/security; Pi service/state; Discord/CLI; fleet/Hermes/plugin suite; migration/Matrix/recovery/qualification. Every source-code task requires tests, independent review, a PR to `main`, terminal-green CI, and issue/task closure. Production activation additionally requires a clean-host Pi launch, dedicated Discord channel smoke test, CLI attach test, restart/recovery drill, and rollback procedure.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### High-Level System Diagram
|
### High-Level System Diagram
|
||||||
|
|||||||
@@ -14,10 +14,9 @@
|
|||||||
|
|
||||||
## Workstream Rollup
|
## Workstream Rollup
|
||||||
|
|
||||||
| id | status | workstream | progress | tasks file | notes |
|
| id | status | workstream | progress | tasks file | notes |
|
||||||
| --- | ----------------- | ---------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
| --- | ----------------- | ------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
||||||
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
||||||
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
|
|
||||||
|
|
||||||
## Cross-Cutting Tracking
|
## Cross-Cutting Tracking
|
||||||
|
|
||||||
@@ -46,48 +45,3 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
|||||||
- Status: PR open, awaiting maintainer merge ratification (fleet-governing change).
|
- Status: PR open, awaiting maintainer merge ratification (fleet-governing change).
|
||||||
- Cut always-injected contract AGENTS+TOOLS+RUNTIME 8,827→4,122 tok (−53%); all 12 hard gates intact.
|
- Cut always-injected contract AGENTS+TOOLS+RUNTIME 8,827→4,122 tok (−53%); all 12 hard gates intact.
|
||||||
- Validation: deterministic gate-checklist PASS; headless A/B thin 7/9 vs monolith 5/9. Detail: scratchpads/contract-thin-core.md.
|
- Validation: deterministic gate-checklist PASS; headless A/B thin 7/9 vs monolith 5/9. Detail: scratchpads/contract-thin-core.md.
|
||||||
|
|
||||||
## P5 — Overlay composer + cross-harness (#604) — feat/p5-overlay-composer
|
|
||||||
|
|
||||||
- Status: MERGED to main (#605). R7 (compose-contract) + R8 (cross-harness) + R9 (composer test).
|
|
||||||
- `composeContract({harness, mosaicHome})` pure fn + `.local` overlay deltas-by-value; `mosaic compose-contract <harness>` command; AGENTS bare-launch nudge; composer spec (per-tier anchor + Tier-3 byte-equality). Detail: scratchpads/p5-overlay-composer.md.
|
|
||||||
|
|
||||||
## P6 — Docs, compliance matrix, alpha tag (#606) — feat/p6-docs-compliance-alpha
|
|
||||||
|
|
||||||
- Status: in-repo deliverables done (CONTRIBUTING.md + harness×gate compliance matrix + check-resident-budget.sh + CI wiring + ALPHA-DOD.md). Remaining: alpha tag v0.0.39-alpha (Lead, post-merge). aiguide reconcile merged (#8). Detail: scratchpads/p6-docs-compliance-alpha.md.
|
|
||||||
|
|
||||||
## F3-m3 — mosaic update re-seeds framework + relaunches agents (#609) — feat/f3-m3-update-reseed
|
|
||||||
|
|
||||||
- Status: implemented + tested. Closes R13: `mosaic update` now re-seeds the framework (data-safe MOSAIC_SYNC_ONLY) after the CLI install so shipped launcher/runtime changes activate; `--relaunch` restarts rostered agents; `--no-reseed` opts out. Detail: scratchpads/f3-m3-update-reseed.md.
|
|
||||||
|
|
||||||
## Fleet-polish bundle — boot-survival symmetry (#611) — feat/fleet-polish-bundle
|
|
||||||
|
|
||||||
- Status: MERGED to main. disable-on-remove (boot-resurrection bug, TDD) + add-enable + init-R5 hard guarantee. 4 new + 147 existing fleet tests green. Detail: scratchpads/fleet-polish-bundle.md.
|
|
||||||
|
|
||||||
## Fleet enhancer role + two-agent floor (#614) — feat/fleet-enhancer-floor
|
|
||||||
|
|
||||||
- Status: MERGED to main. enhancer added to 4 presets; init guarantees 1 orchestrator + >=1 enhancer; remove protects the sole enhancer; enhancer role doc. 155 fleet tests green. Detail: scratchpads/fleet-enhancer-floor.md.
|
|
||||||
|
|
||||||
## F4 — Orchestrator chat connector + Matrix (#616) — feat/f4-matrix-connector
|
|
||||||
|
|
||||||
- Status: Phase 1 MERGED (#617: connector interface send/subscribe/health + registry + roster schema + design). Phase 2a (#618): Matrix CS-API client + factory. 20 connector tests green; no fleet.ts changes. Remaining Phase 2: init/configure connector-selection UX + roster wiring, systemd launch wiring, Conduit deploy guide. Detail: scratchpads/f4-matrix-connector.md.
|
|
||||||
|
|
||||||
## Fleet onboarding-injection — comms cheat-sheet + peer roster (#620) — feat/fleet-comms-onboarding
|
|
||||||
|
|
||||||
- Status: implemented + tested. Injects # Fleet Comms (peer roster + cross-host agent-send commands + FLIP-reply + --verify) into each spawned fleet agent via composeContract; optional per-agent host/ssh/socket roster fields (socket: named → -L, unset → default socket no -L). 10 + 2 tests green. Detail: scratchpads/fleet-comms-onboarding.md.
|
|
||||||
|
|
||||||
## Fleet stand-up fixes — model_hint→--model + socket-default trap (#626) — feat/fleet-standup-fixes
|
|
||||||
|
|
||||||
- Status: implemented + tested. FIX1 model_hint→MOSAIC_AGENT_MODEL→--model. FIX2 absent socket = default tmux socket (no -L) across parse/spawn/systemd-unit/observe (socketArgs helper, bare-empty shellEnvValue, conditional -L). 158 fleet tests green; shipped presets unaffected (explicit socket_name). Detail: scratchpads/fleet-standup-fixes.md.
|
|
||||||
|
|
||||||
## north-star doctrine consolidation — doc PR — feat/north-star-doctrine
|
|
||||||
|
|
||||||
- Status: applied Mos's consolidated merge-map to docs/fleet/north-star.md (budget governance + control plane/central register + 200k cap + delegation + unified-identity Fleet + role-based naming + tmux security + drift re-captures). Doctrine only; #622/#623/#625/#628 out-of-scope. Conflict checklist green. Detail: scratchpads/north-star-doctrine.md.
|
|
||||||
|
|
||||||
## #631 — re-seed preserves user fleet data (CRITICAL) — fix/631-reseed-preserves-fleet-data
|
|
||||||
|
|
||||||
- Status: implemented + tested. PRIMARY: install.sh PRESERVE_PATHS += fleet/\*.yaml + fleet/agents + fleet/run (glob-aware cp-fallback); TS parity. SECONDARY: refreshActiveFleetUnits propagates unit fixes to ~/.config/systemd/user on mosaic update. bash F6 + TS + unit tests green. Detail: scratchpads/631-reseed-preserves-fleet.md.
|
|
||||||
|
|
||||||
## #633 — comms-block emitter + FLEET-LAUNCH runbook — feat/633-comms-block-runbook
|
|
||||||
|
|
||||||
- Status: implemented + tested (TDD). `mosaic fleet comms-block <role> [--host]` wraps resolveCommsBlock → readFleetCommsBlock; fails loud (stderr + exit 1) on unknown role / missing roster instead of silent empty. docs/fleet/FLEET-LAUNCH.md runbook: worker path + orchestrator .env fold (MOSAIC_AGENT_COMMAND; line-41 [-z] short-circuits line-44 yolo hardcode) + 3 launch gotchas + #632 preserve note + North-Star 4-field arc (harness ✅/model ✅ roster-native today; yolo + command/channels = PATH B #636). 177 fleet+comms tests green (6 new resolveCommsBlock cases). PATH A of the A→B→webUI arc. Detail: scratchpads/633-comms-block-runbook.md.
|
|
||||||
|
|||||||
@@ -232,10 +232,6 @@ The following sections document how each supported channel maps its native messa
|
|||||||
|
|
||||||
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
|
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
|
||||||
|
|
||||||
### Discord service ingress security
|
|
||||||
|
|
||||||
The Discord adapter is an authenticated gateway service, not an anonymous Socket.IO client. It presents `DISCORD_SERVICE_TOKEN` during its `/chat` connection and signs each inbound envelope using HMAC-SHA-256. The envelope contains the Discord native message ID and a generated correlation ID. Gateway verifies the service credential, signature, and configured guild/channel/user allowlists before agent dispatch, then rejects duplicate native message IDs inside its bounded replay window. All three allowlists are default-deny and required when the Discord plugin is enabled. The service credential is injected at runtime and is never logged or included in protocol payloads.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Telegram
|
### Telegram
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
# Constitution Alpha — Definition-of-Done checklist + release notes
|
|
||||||
|
|
||||||
Drafted for the `v0.0.39-alpha` tag (Lead cuts after P5 #605 → P6 #607 → aiguide #8 merge).
|
|
||||||
Maps every DoD §8 acceptance criterion to its merged evidence. Legend:
|
|
||||||
**✅ merged on main** · **⏳ review-ready PR (pending merge)** · **🔲 Lead action**.
|
|
||||||
|
|
||||||
## DoD §8 green-checklist
|
|
||||||
|
|
||||||
| # | Acceptance criterion (DESIGN §8) | Status | Evidence / PR |
|
|
||||||
| --- | ------------------------------------------------------------------------------------------------------ | ------ | ----------------- |
|
|
||||||
| 1 | MIT `LICENSE` (root + framework) + `"license":"MIT"` in package.json | ✅ | P0 #570 |
|
|
||||||
| 2 | Three credential-path sites + hook URL fast-failed (no private paths in `*.sh`/hooks) | ✅ | P0 #570 |
|
|
||||||
| 3 | `verify-sanitized.sh` (two-class, `*.sh`+`*.md`, self-tested) wired **blocking** in CI | ✅ | P1 #572 |
|
|
||||||
| 4 | Operator data purged from the full set (guides / tools / init-generator) | ✅ | P2 #572 |
|
|
||||||
| 5 | `rails/`→`tools/` in **both** template families | ✅ | P2 #572 |
|
|
||||||
| 6 | `jarvis-loop.json` deleted; `defaults/SOUL.md` → **neutral sanitized persona** (Q10 decision) | ✅ | P2 #572 |
|
|
||||||
| 7 | `CONSTITUTION.md` extracted (gates one place, capability-verb, §1.4 split, no false "already loaded") | ✅ | P3 #575 / #577 |
|
|
||||||
| 8 | `AGENTS.md`/`STANDARDS.md` out of `PRESERVE_PATHS` + seed-semantics → overwrite in **both** installers | ✅ | P4 #590 |
|
|
||||||
| 9 | Snapshot + v2→v3 migration moving user edits to `.local`/`.bak`; `FRAMEWORK_VERSION=3` | ✅ | P4 #590 / #593 |
|
|
||||||
| 10 | `mosaic-init --non-interactive` fail-closed persona | ✅ | P4 #590 |
|
|
||||||
| 11 | **5-fixture migration matrix** green against **both** installers asserting **injected bytes** | ✅ | P4 #590 / #593 |
|
|
||||||
| 12 | `compose-contract` built + composer unit test (per-tier anchor + Tier-3 byte-equality) | ⏳ | P5 #605 |
|
|
||||||
| 13 | Resident line-count ceiling enforced (framework-owned resident files) | ⏳ | P6 #607 |
|
|
||||||
| 14 | `CONTRIBUTING.md` + harness×gate compliance matrix | ⏳ | P6 #607 |
|
|
||||||
| 15 | `aiguide` reconciled with the Constitution | ⏳ | aiguide #8 |
|
|
||||||
| 16 | Each phase PR CI-green; alpha tag pushed + Gitea release published | 🔲 | Lead (post-merge) |
|
|
||||||
|
|
||||||
**Note on #6:** the DoD's literal "delete `defaults/SOUL.md`" was superseded by the resolved
|
|
||||||
**Q10** decision — ship a _neutral, operator-agnostic_ example persona instead of deleting it. Main
|
|
||||||
carries the sanitized 2.6 KB neutral SOUL.md ("Mosaic agent", no operator identity); the sanitization
|
|
||||||
gate confirms it is PII-clean. Criterion met in spirit (no operator persona leaks) via the better option.
|
|
||||||
|
|
||||||
**Gate to flip 12–14 → ✅:** merge P5 #605 → P6 #607 (rebase auto-drops the dup format fix
|
|
||||||
`adc7df2`/`9f6da92`) → aiguide #8, with `ci.yml` terminal-green on the merged head.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Release notes — `v0.0.39-alpha` (Mosaic Framework Constitution, alpha)
|
|
||||||
|
|
||||||
### Mosaic Framework Constitution — Alpha
|
|
||||||
|
|
||||||
This release makes the Mosaic framework a **safe-to-open-source, fork-and-customize agent
|
|
||||||
operating layer**. It separates the non-negotiable law from operator identity, makes
|
|
||||||
customization survive upgrades, and wires the guarantees into CI.
|
|
||||||
|
|
||||||
**Highlights**
|
|
||||||
|
|
||||||
- **Constitution (L0).** The hard gates now live in one place — `CONSTITUTION.md` — authored in
|
|
||||||
capability verbs, with a thin `AGENTS.md` dispatcher that references the law instead of restating
|
|
||||||
it. Governance model in `constitution/LAYER-MODEL.md`.
|
|
||||||
- **Public & sanitized.** MIT-licensed; all operator identity, private paths, and credential sites
|
|
||||||
removed from shipped files. A self-tested `verify-sanitized.sh` gate (two rule classes) runs
|
|
||||||
**blocking** in CI so re-contamination can't merge.
|
|
||||||
- **Upgrade-safe customization.** Framework-owned files overwrite cleanly on upgrade while
|
|
||||||
`SOUL.md`/`USER.md`/`*.local.md`/`credentials` are preserved. The v2→v3 migration snapshots first
|
|
||||||
and moves any user-edited `AGENTS.md`/`STANDARDS.md` to `.pre-constitution.bak`/`.local.md` —
|
|
||||||
never silently lost. Verified by a 5-fixture matrix across **both** installers.
|
|
||||||
- **Operator overlays.** `mosaic compose-contract <harness>` merges your `*.local.md` deltas into
|
|
||||||
the contract per harness, so customization reaches the model as one pre-merged blob.
|
|
||||||
- **Cross-harness.** Single L0 source referenced (never restated) by Claude / Codex / OpenCode / Pi;
|
|
||||||
tiered injection with a byte-equal Tier-3 fallback read.
|
|
||||||
- **Guardrails in CI.** Resident line-count ceiling over framework-owned resident files; composer
|
|
||||||
unit test; sanitization gate — all blocking.
|
|
||||||
- **Docs.** `CONTRIBUTING.md` with the layer model, dual-installer parity rule, and a harness×gate
|
|
||||||
**compliance matrix** (the Codex/OpenCode/Pi hook-parity gap is tracked for v2).
|
|
||||||
|
|
||||||
**Known limitations (accepted, documented in `CONTRIBUTING.md` §9)**
|
|
||||||
|
|
||||||
- Bare launches that bypass `mosaic` get base contracts only (no `*.local` overlays) and are not
|
|
||||||
drift-checked by `mosaic doctor` — mitigated by the unconditional Tier-3 self-load + a nudge.
|
|
||||||
- Codex/OpenCode/Pi mechanical hook parity, `policy/*.md` composition, and live-launch cross-harness
|
|
||||||
verification are **v2**.
|
|
||||||
|
|
||||||
**Phase lineage:** P0 #570 · P1+P2 #572 · P3 #575/#577 · P4 #590/#593 · P5 #605 · P6 #607 ·
|
|
||||||
aiguide #8 (umbrella #542).
|
|
||||||
@@ -91,22 +91,22 @@ Goal: Two federated gateways exchange real data over mTLS. Inbound requests pass
|
|||||||
>
|
>
|
||||||
> **Tracking issue:** #462.
|
> **Tracking issue:** #462.
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------------ | --------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------------ | ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| FED-M3-01 | done | `packages/types/src/federation/` — request/response DTOs for `list`, `get`, `capabilities` verbs. Wire-format zod schemas + inferred TS types. Includes `FederationRequest`, `FederationListResponse<T>`, `FederationGetResponse<T>`, `FederationCapabilitiesResponse`, error envelope, `_source` tag. | #462 | sonnet | feat/federation-m3-types | — | 4K | Reusable from gateway server + client + harness. Pure types — no I/O, no NestJS. |
|
| FED-M3-01 | not-started | `packages/types/src/federation/` — request/response DTOs for `list`, `get`, `capabilities` verbs. Wire-format zod schemas + inferred TS types. Includes `FederationRequest`, `FederationListResponse<T>`, `FederationGetResponse<T>`, `FederationCapabilitiesResponse`, error envelope, `_source` tag. | #462 | sonnet | feat/federation-m3-types | — | 4K | Reusable from gateway server + client + harness. Pure types — no I/O, no NestJS. |
|
||||||
| FED-M3-02 | done | `tools/federation-harness/` scaffold: `docker-compose.two-gateways.yml` (Server A + Server B + step-CA), `seed.ts` (provisions grants, peers, sample tasks/notes/credentials per scope variant), `harness.ts` helper (boots stack, returns typed clients). README documents harness use. | #462 | sonnet | feat/federation-m3-harness | DEPLOY-04 (soft) | 8K | Falls back to local docker-compose if `mos-test-1/-2` not yet redeployed (DEPLOY chain blocked on IMG-FIX). Permanent test infra used by M3+. |
|
| FED-M3-02 | not-started | `tools/federation-harness/` scaffold: `docker-compose.two-gateways.yml` (Server A + Server B + step-CA), `seed.ts` (provisions grants, peers, sample tasks/notes/credentials per scope variant), `harness.ts` helper (boots stack, returns typed clients). README documents harness use. | #462 | sonnet | feat/federation-m3-harness | DEPLOY-04 (soft) | 8K | Falls back to local docker-compose if `mos-test-1/-2` not yet redeployed (DEPLOY chain blocked on IMG-FIX). Permanent test infra used by M3+. |
|
||||||
| FED-M3-03 | done | `apps/gateway/src/federation/server/federation-auth.guard.ts` (NestJS guard). Validates inbound client cert from Fastify TLS context, extracts `grantId` + `subjectUserId` from custom OIDs, loads grant from DB, asserts `status='active'`, attaches `FederationContext` to request. | #462 | sonnet | feat/federation-m3-auth-guard | M3-01 | 8K | Reuses OID parsing logic mirrored from `ca.service.ts` post-issuance verification. 401 on malformed/missing OIDs; 403 on revoked/expired/missing grant. |
|
| FED-M3-03 | not-started | `apps/gateway/src/federation/server/federation-auth.guard.ts` (NestJS guard). Validates inbound client cert from Fastify TLS context, extracts `grantId` + `subjectUserId` from custom OIDs, loads grant from DB, asserts `status='active'`, attaches `FederationContext` to request. | #462 | sonnet | feat/federation-m3-auth-guard | M3-01 | 8K | Reuses OID parsing logic mirrored from `ca.service.ts` post-issuance verification. 401 on malformed/missing OIDs; 403 on revoked/expired/missing grant. |
|
||||||
| FED-M3-04 | in-progress | `apps/gateway/src/federation/server/scope.service.ts`. Pipeline: (1) resource allowlist + excluded check, (2) native RBAC eval as `subjectUserId`, (3) scope filter intersection (`include_teams`, `include_personal`), (4) `max_rows_per_query` cap. Pure service — DB calls injected. | #462 | sonnet | feat/federation-m3-scope-service | M3-01 | 10K | Hardest correctness target in M3. Reuses `parseFederationScope` (M2-03). Returns either `{ allowed: true, filter }` or structured deny reason for audit. |
|
| FED-M3-04 | not-started | `apps/gateway/src/federation/server/scope.service.ts`. Pipeline: (1) resource allowlist + excluded check, (2) native RBAC eval as `subjectUserId`, (3) scope filter intersection (`include_teams`, `include_personal`), (4) `max_rows_per_query` cap. Pure service — DB calls injected. | #462 | sonnet | feat/federation-m3-scope-service | M3-01 | 10K | Hardest correctness target in M3. Reuses `parseFederationScope` (M2-03). Returns either `{ allowed: true, filter }` or structured deny reason for audit. |
|
||||||
| FED-M3-05 | in-progress | `apps/gateway/src/federation/server/verbs/list.controller.ts`. Wires AuthGuard → ScopeService → tasks/notes/memory query layer; applies row cap; tags rows with `_source`. Resource selector via path param. | #462 | sonnet | feat/federation-m3-verb-list | M3-03, M3-04 | 6K | Routes: `POST /api/federation/v1/list/:resource`. No body persistence. Audit write deferred to M4. |
|
| FED-M3-05 | not-started | `apps/gateway/src/federation/server/verbs/list.controller.ts`. Wires AuthGuard → ScopeService → tasks/notes/memory query layer; applies row cap; tags rows with `_source`. Resource selector via path param. | #462 | sonnet | feat/federation-m3-verb-list | M3-03, M3-04 | 6K | Routes: `POST /api/federation/v1/list/:resource`. No body persistence. Audit write deferred to M4. |
|
||||||
| FED-M3-06 | not-started | `apps/gateway/src/federation/server/verbs/get.controller.ts`. Single-resource fetch by id; same pipeline as list. 404 on not-found, 403 on RBAC/scope deny — both audited the same way. | #462 | sonnet | feat/federation-m3-verb-get | M3-03, M3-04 | 6K | `POST /api/federation/v1/get/:resource/:id`. Mirrors list controller patterns. |
|
| FED-M3-06 | not-started | `apps/gateway/src/federation/server/verbs/get.controller.ts`. Single-resource fetch by id; same pipeline as list. 404 on not-found, 403 on RBAC/scope deny — both audited the same way. | #462 | sonnet | feat/federation-m3-verb-get | M3-03, M3-04 | 6K | `POST /api/federation/v1/get/:resource/:id`. Mirrors list controller patterns. |
|
||||||
| FED-M3-07 | done | `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`. Read-only enumeration: returns `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope. Always allowed for an active grant — no RBAC eval. | #462 | sonnet | feat/federation-m3-verb-capabilities | M3-03 | 4K | `GET /api/federation/v1/capabilities`. Smallest verb; useful sanity check that mTLS + auth guard work end-to-end. |
|
| FED-M3-07 | not-started | `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`. Read-only enumeration: returns `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope. Always allowed for an active grant — no RBAC eval. | #462 | sonnet | feat/federation-m3-verb-capabilities | M3-03 | 4K | `GET /api/federation/v1/capabilities`. Smallest verb; useful sanity check that mTLS + auth guard work end-to-end. |
|
||||||
| FED-M3-08 | done | `apps/gateway/src/federation/client/federation-client.service.ts`. Outbound mTLS dialer: picks `(certPem, sealed clientKey)` from `federation_peers`, unwraps key, builds undici Agent with mTLS, calls peer verb, parses typed response, wraps non-2xx into `FederationClientError`. | #462 | sonnet | feat/federation-m3-client | M3-01 | 8K | Independent of server stream — can land in parallel with M3-03/04. Cert/key cached per-peer; flushed by future M5/M6 logic. |
|
| FED-M3-08 | not-started | `apps/gateway/src/federation/client/federation-client.service.ts`. Outbound mTLS dialer: picks `(certPem, sealed clientKey)` from `federation_peers`, unwraps key, builds undici Agent with mTLS, calls peer verb, parses typed response, wraps non-2xx into `FederationClientError`. | #462 | sonnet | feat/federation-m3-client | M3-01 | 8K | Independent of server stream — can land in parallel with M3-03/04. Cert/key cached per-peer; flushed by future M5/M6 logic. |
|
||||||
| FED-M3-09 | done | `apps/gateway/src/federation/client/query-source.service.ts`. Accepts `source: "local" \| "federated:<host>" \| "all"` from gateway query layer; for `"all"` fans out to local + each peer in parallel; merges results; tags every row with `_source`. | #462 | sonnet | feat/federation-m3-query-source | M3-08 | 8K | Per-peer failure surfaces as `_partial: true` in response, not hard failure (sets up M5 offline UX). M5 adds caching + circuit breaker on top. |
|
| FED-M3-09 | not-started | `apps/gateway/src/federation/client/query-source.service.ts`. Accepts `source: "local" \| "federated:<host>" \| "all"` from gateway query layer; for `"all"` fans out to local + each peer in parallel; merges results; tags every row with `_source`. | #462 | sonnet | feat/federation-m3-query-source | M3-08 | 8K | Per-peer failure surfaces as `_partial: true` in response, not hard failure (sets up M5 offline UX). M5 adds caching + circuit breaker on top. |
|
||||||
| FED-M3-10 | not-started | Integration tests for MILESTONES.md M3 acceptance #6 (malformed OIDs → 401; valid cert + revoked grant → 403) and #7 (`max_rows_per_query` cap). Real PG, mocked TLS context (Fastify req shim). | #462 | sonnet | feat/federation-m3-integration | M3-05, M3-06 | 8K | Vitest profile gated by `FEDERATED_INTEGRATION=1`. Single-gateway suite; no harness required. |
|
| FED-M3-10 | not-started | Integration tests for MILESTONES.md M3 acceptance #6 (malformed OIDs → 401; valid cert + revoked grant → 403) and #7 (`max_rows_per_query` cap). Real PG, mocked TLS context (Fastify req shim). | #462 | sonnet | feat/federation-m3-integration | M3-05, M3-06 | 8K | Vitest profile gated by `FEDERATED_INTEGRATION=1`. Single-gateway suite; no harness required. |
|
||||||
| FED-M3-11 | not-started | E2E tests for MILESTONES.md M3 acceptance #1, #2, #3, #4, #5, #8, #9, #10 (8 cases). Uses harness from M3-02; two real gateways, real Step-CA, real mTLS. Each test asserts both happy-path response and audit/no-persist invariants. | #462 | sonnet | feat/federation-m3-e2e | M3-02, M3-04, M3-05, M3-06, M3-09 | 12K | Largest single task. Each acceptance gets its own `it(...)` for clear failure attribution. |
|
| FED-M3-11 | not-started | E2E tests for MILESTONES.md M3 acceptance #1, #2, #3, #4, #5, #8, #9, #10 (8 cases). Uses harness from M3-02; two real gateways, real Step-CA, real mTLS. Each test asserts both happy-path response and audit/no-persist invariants. | #462 | sonnet | feat/federation-m3-e2e | M3-02, M3-09 | 12K | Largest single task. Each acceptance gets its own `it(...)` for clear failure attribution. |
|
||||||
| FED-M3-12 | not-started | Independent security review (sonnet, not author of M3-03/04/05/06/07/08/09): focus on cert-SAN spoofing, OID extraction edge cases, scope-bypass via filter manipulation, RBAC-bypass via subjectUser swap, response leakage when scope deny. | #462 | sonnet | feat/federation-m3-security-review | M3-11 | 10K | Two review rounds budgeted. PRD requires explicit test for every 401/403 path — review verifies coverage. |
|
| FED-M3-12 | not-started | Independent security review (sonnet, not author of M3-03/04/05/06/07/08/09): focus on cert-SAN spoofing, OID extraction edge cases, scope-bypass via filter manipulation, RBAC-bypass via subjectUser swap, response leakage when scope deny. | #462 | sonnet | feat/federation-m3-security-review | M3-11 | 10K | Two review rounds budgeted. PRD requires explicit test for every 401/403 path — review verifies coverage. |
|
||||||
| FED-M3-13 | not-started | Docs update: `docs/federation/SETUP.md` mTLS handshake section, new `docs/federation/HARNESS.md` for federation-harness usage, OID reference table in SETUP.md, scope enforcement pipeline diagram. Runbook still M7-deferred. | #462 | haiku | feat/federation-m3-docs | M3-12 | 5K | One ASCII diagram for the auth-guard → scope → RBAC pipeline; helps future reviewers reason about denial paths. |
|
| FED-M3-13 | not-started | Docs update: `docs/federation/SETUP.md` mTLS handshake section, new `docs/federation/HARNESS.md` for federation-harness usage, OID reference table in SETUP.md, scope enforcement pipeline diagram. Runbook still M7-deferred. | #462 | haiku | feat/federation-m3-docs | M3-12 | 5K | One ASCII diagram for the auth-guard → scope → RBAC pipeline; helps future reviewers reason about denial paths. |
|
||||||
| FED-M3-14 | not-started | PR aggregate close, CI green, merge to main, close #462. Release tag `fed-v0.3.0-m3`. Update mission manifest M3 row → done; M4 row → in-progress when work begins. | #462 | sonnet | chore/federation-m3-close | M3-13 | 3K | Same close pattern as M1-12 / M2-13. |
|
| FED-M3-14 | not-started | PR aggregate close, CI green, merge to main, close #462. Release tag `fed-v0.3.0-m3`. Update mission manifest M3 row → done; M4 row → in-progress when work begins. | #462 | sonnet | chore/federation-m3-close | M3-13 | 3K | Same close pattern as M1-12 / M2-13. |
|
||||||
|
|
||||||
**M3 estimate:** ~100K tokens (vs MILESTONES.md 40K — same per-task breakdown pattern as M1/M2: tests, review, and docs split out from implementation cost). Largest milestone in the federation mission.
|
**M3 estimate:** ~100K tokens (vs MILESTONES.md 40K — same per-task breakdown pattern as M1/M2: tests, review, and docs split out from implementation cost). Largest milestone in the federation mission.
|
||||||
|
|
||||||
@@ -118,10 +118,6 @@ Goal: Two federated gateways exchange real data over mTLS. Inbound requests pass
|
|||||||
|
|
||||||
**Test bed fallback:** If `mos-test-1.woltje.com` / `mos-test-2.woltje.com` are still blocked on `FED-M2-DEPLOY-IMG-FIX` when M3-11 is ready to run, the harness's local `docker-compose.two-gateways.yml` is a sufficient stand-in. Production-host validation moves to M7 acceptance suite (PRD AC-12).
|
**Test bed fallback:** If `mos-test-1.woltje.com` / `mos-test-2.woltje.com` are still blocked on `FED-M2-DEPLOY-IMG-FIX` when M3-11 is ready to run, the harness's local `docker-compose.two-gateways.yml` is a sufficient stand-in. Production-host validation moves to M7 acceptance suite (PRD AC-12).
|
||||||
|
|
||||||
**Backlog sync — 2026-06-24 (orchestrator):** Status reconciled against `origin/main` (release 0.0.48). Landed on main: **FED-M3-01** (DTOs, PR #506), **FED-M3-02** (harness scaffold, PR #505), **FED-M3-03** (mTLS auth-guard, PR #509 — CRIT-1/2 + HIGH-1..4 remediated in-PR), **FED-M3-08** (outbound mTLS client, PR #508). With M3-01/03/08 merged, three cards became dependency-clear and were dispatched to the idle coder lane: **FED-M3-04** scope.service → coder0 (`feat/federation-m3-scope-service`); **FED-M3-09** query-source + **FED-M3-07** capabilities verb → coder1 (`feat/federation-m3-query-source` first). Reviewer warmed for the M3 trust-boundary PRs. Remaining blocked-by-DAG: M3-05/06 (await M3-04), M3-10 (await M3-05/06), M3-11 (await M3-09), M3-12→14 (tail). Deploy chain (DEPLOY-IMG-FIX → 03/04) still independent of M3 code — harness local docker-compose fallback covers M3-11.
|
|
||||||
|
|
||||||
**Backlog sync #2 — 2026-06-24 (orchestrator):** **FED-M3-09** (query-source) merged via PR #673 and **FED-M3-07** (capabilities) merged via PR #674 — both squash-merged on independent agent review-of-record + green CI (formal Gitea approve unavailable under the shared service account; merge is not gated by the self-approve guard). **FED-M3-05** (list verb) dispatched to coder1 (based on the M3-04 branch, rebase onto main once #672 lands). **FED-M3-04** (scope.service, PR #672) is in review-changes (one include_personal no-leak test outstanding). **DAG fix:** corrected `FED-M3-11` depends_on from `M3-02, M3-09` → `M3-02, M3-04, M3-05, M3-06, M3-09` — the E2E acceptance cases (#1–#5, #8–#10) exercise list/get over mTLS, so the server verbs + scope service are hard prerequisites; the original edge set omitted them and caused a premature M3-11 dispatch. Note: M3 read-path invariant for M3-11 is **no-persist + existing enrollment audit only** — read-verb audit-log writes are deferred to M4 (see M3-05/06 notes), so M3-11 must not assert read-audit-log entries.
|
|
||||||
|
|
||||||
## Milestone 4 — search + audit + rate limit (FED-M4)
|
## Milestone 4 — search + audit + rate limit (FED-M4)
|
||||||
|
|
||||||
_Deferred. Issue #463._
|
_Deferred. Issue #463._
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
# Fleet Launch Runbook
|
|
||||||
|
|
||||||
How every Mosaic fleet agent — workers **and** the orchestrator — is launched, and how to
|
|
||||||
configure each one. The guiding principle: **one roster-driven launcher**. There is no bespoke
|
|
||||||
per-agent launch script; the roster plus per-agent `.env` files are the single source of launch
|
|
||||||
config.
|
|
||||||
|
|
||||||
## The launch chain
|
|
||||||
|
|
||||||
| Layer | File | Responsibility |
|
|
||||||
| ---------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| systemd unit | `mosaic-agent@<role>.service` | One templated unit per role; `ExecStart` runs the session launcher with the instance name `%i`. Defaults `MOSAIC_AGENT_RUNTIME=pi`, `MOSAIC_AGENT_NAME=%i`. |
|
|
||||||
| session launcher | `tools/fleet/start-agent-session.sh <role>` | Builds the launch command, opens the tmux pane, wires the heartbeat. |
|
|
||||||
| launch command | `mosaic yolo <runtime>` (or a per-agent override) | Replaces the pane's foreground process with the runtime, fully seeded. |
|
|
||||||
| seeding | `mosaic`'s `composeContract()` | Injects the Constitution/USER/TOOLS/runtime contract, `*.local` overlays, **and** the Fleet-Comms cheat-sheet — all via `--append-system-prompt`. |
|
|
||||||
|
|
||||||
Per-agent overrides live in `fleet/agents/<role>.env`, generated from `roster.yaml` by
|
|
||||||
`generateAgentEnv` (`packages/mosaic/src/commands/fleet.ts`) and consumed by the launcher.
|
|
||||||
|
|
||||||
## Worker launch path (default)
|
|
||||||
|
|
||||||
1. `roster.yaml` carries each agent's `runtime` and optional `model_hint`.
|
|
||||||
2. `generateAgentEnv` emits `fleet/agents/<role>.env` with `MOSAIC_AGENT_NAME`,
|
|
||||||
`MOSAIC_AGENT_RUNTIME`, and `MOSAIC_AGENT_MODEL`.
|
|
||||||
3. `start-agent-session.sh` has no `MOSAIC_AGENT_COMMAND` set, so it falls through to the default
|
|
||||||
(line ~44):
|
|
||||||
```sh
|
|
||||||
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}"
|
|
||||||
```
|
|
||||||
4. The launcher bakes `MOSAIC_AGENT_NAME` into the pane command (line ~118), so `composeContract`
|
|
||||||
can inject the Fleet-Comms cheat-sheet for that role.
|
|
||||||
|
|
||||||
That is the whole worker path: roster → `.env` → `mosaic yolo <runtime>` → seeded pane.
|
|
||||||
|
|
||||||
## Orchestrator fold (PATH A — ships today)
|
|
||||||
|
|
||||||
The orchestrator is **just another roster agent** launched through the canonical path — not a
|
|
||||||
snowflake script.
|
|
||||||
|
|
||||||
| Piece | Value |
|
|
||||||
| ------------------ | ----------------------------------- |
|
|
||||||
| host-side launcher | `orchestrator-launch.sh` |
|
|
||||||
| systemd unit | `mosaic-fleet-orchestrator.service` |
|
|
||||||
| tmux session | `orchestrator` (role-named) |
|
|
||||||
|
|
||||||
Set its launch command via `fleet/agents/orchestrator.env`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
MOSAIC_AGENT_COMMAND='mosaic yolo claude --channels plugin:discord@<channel>'
|
|
||||||
```
|
|
||||||
|
|
||||||
When `MOSAIC_AGENT_COMMAND` is set, `start-agent-session.sh`'s `if [ -z "$MOSAIC_AGENT_COMMAND" ]`
|
|
||||||
guard (line ~41) is false, so the line-44 default — **including its hardcoded `yolo`** — is skipped
|
|
||||||
entirely. The override fully controls the runtime and flags. Routing through `mosaic yolo claude`
|
|
||||||
(rather than a raw `claude` invocation) is what gives the orchestrator the same full
|
|
||||||
`composeContract` seeding + Fleet-Comms cheat-sheet as every worker, with `--channels` and any
|
|
||||||
other flags passed straight through to the `claude` binary.
|
|
||||||
|
|
||||||
## Launch gotchas
|
|
||||||
|
|
||||||
1. **Flag conflict.** `mosaic yolo claude` already injects `--dangerously-skip-permissions`. Do
|
|
||||||
**not** also pass `--permission-mode bypassPermissions` — the `claude` binary would receive both.
|
|
||||||
Use `mosaic yolo claude …` alone (yolo covers the unattended posture), **or** non-yolo
|
|
||||||
`mosaic claude --permission-mode bypassPermissions …`. Never mix the two.
|
|
||||||
2. **`MOSAIC_AGENT_NAME` must reach the pane.** The launcher bakes it from the instance name, and
|
|
||||||
`composeContract` gates the Fleet-Comms block on it (`launch.ts`, in `composeContract`) — **and**
|
|
||||||
the role must be a member of `roster.yaml`, or the block resolves empty.
|
|
||||||
3. **`launchRuntime` guards.** `mosaic yolo claude` runs `checkSoul` / `checkRuntime` /
|
|
||||||
`checkSequentialThinking`. The host needs `SOUL.md` and the sequential-thinking MCP, or the
|
|
||||||
launch aborts (a raw `claude` invocation skipped these checks). Dry-run the composed command in a
|
|
||||||
throwaway tmux session before swapping a live launcher.
|
|
||||||
|
|
||||||
## Why per-agent `.env` survives upgrades (#632)
|
|
||||||
|
|
||||||
`install.sh` `PRESERVE_PATHS` includes `fleet/*.yaml`, `fleet/agents`, and `fleet/run`, so
|
|
||||||
`mosaic update`'s framework re-seed **preserves** your roster and per-agent `.env` overrides
|
|
||||||
(glob-aware `cp` fallback; matching TS parity in `file-adapter.ts`). Before #632, an auto re-seed
|
|
||||||
could wipe them — which is exactly why PATH A's `.env` override is safe to rely on now.
|
|
||||||
|
|
||||||
## Inspecting the comms wiring
|
|
||||||
|
|
||||||
- `mosaic fleet comms-block <role>` prints the Fleet-Comms cheat-sheet a given role receives at
|
|
||||||
launch — its `[host:session]` identity, the exact `agent-send.sh` command for each peer, and the
|
|
||||||
FLIP / `--verify` conventions. `--host <h>` previews a cross-host view. An unknown role or missing
|
|
||||||
roster **fails loud** (stderr + non-zero exit), so a typo is never a silent no-op.
|
|
||||||
- Versus `mosaic compose-contract <runtime>`: that emits the **whole** system prompt and reads the
|
|
||||||
role from `MOSAIC_AGENT_NAME` (a full-prompt smoke test). `comms-block` is the targeted,
|
|
||||||
explicit-arg, comms-only view — e.g. `mosaic fleet comms-block coder0-0` to preview a peer.
|
|
||||||
|
|
||||||
## North Star / future direction
|
|
||||||
|
|
||||||
**Vision:** a webUI lets the user edit each agent's launch config — switch **harness**
|
|
||||||
(claude / pi / codex / opencode), toggle **yolo**, pick a **model**, set a **command/channels**
|
|
||||||
override — with no terminal.
|
|
||||||
|
|
||||||
**Continuity — this is not a new launch path.** It is a data-model + UI-binding layer over the
|
|
||||||
existing roster-driven launcher. Field-by-field status today:
|
|
||||||
|
|
||||||
| Launch-config field | Roster-native today? | Mechanism / gap |
|
|
||||||
| ------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **harness** (`runtime`) | ✅ end-to-end | `roster.runtime` → `generateAgentEnv` emits `MOSAIC_AGENT_RUNTIME` → launcher line 44. UI just writes the field. |
|
|
||||||
| **model** (`model_hint`) | ✅ end-to-end | `roster.model_hint` → `MOSAIC_AGENT_MODEL` → launcher line 44 `--model`. UI just writes the field. |
|
|
||||||
| **yolo** | ❌ new | Launcher line 44 **hardcodes** `mosaic yolo`. A non-yolo toggle needs a roster `yolo` field → emit `MOSAIC_AGENT_YOLO` → make line 44 conditional. |
|
|
||||||
| **command / channels** | ❌ new | `MOSAIC_AGENT_COMMAND` is **consumed** (launcher line ~12) but `generateAgentEnv` does not emit it. Needs a roster `command`/`channels` field → emitted. |
|
|
||||||
|
|
||||||
**The arc:**
|
|
||||||
|
|
||||||
- **A** — `.env` `MOSAIC_AGENT_COMMAND` hatch: manual, ships now, kept safe across upgrades by #632.
|
|
||||||
- **B** — roster-native launch-config: harness + model are already there; add the **yolo** toggle
|
|
||||||
(line-44 conditional) and **command/channels** emission to complete the data model.
|
|
||||||
- **webUI** — binds dropdowns/toggles directly to those four roster fields.
|
|
||||||
|
|
||||||
PATH A's `.env` override is the **manual form** of exactly what PATH B makes roster-native and the
|
|
||||||
webUI edits — one continuous arc, not three separate features. PATH B is tracked as #636.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# Mosaic Fleet — NORTH STAR
|
|
||||||
|
|
||||||
> **Generated file — do not edit by hand.**
|
|
||||||
> Projected deterministically from [`NORTH_STAR.yaml`](./NORTH_STAR.yaml) by the pure
|
|
||||||
> generator in `packages/mosaic/src/commands/fleet.ts` (`renderNorthStarMarkdown`).
|
|
||||||
> Edit the YAML, then regenerate. Self-contained Mosaic — no Hermes dependency.
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
A self-driving Mosaic system that 24/7 unattended converts a machine-readable goal set into merged, CI-green, budget-bounded change — looping plan→backlog→assign→execute→verify→merge→reassess — on Mosaic's OWN native backlog/dispatch engine. Mosaic is general-purpose: the user declares the system type they want (software delivery, personal assistant, research, business/operations, …) and the orchestrator provisions the matching persona roster and structure; the delivery fleet is one profile among many.
|
|
||||||
|
|
||||||
## Substrate
|
|
||||||
|
|
||||||
The Mosaic Backlog is the backlog of record + dispatch engine, built on Mosaic's native Postgres storage service (@mosaicstack/db drizzle; PGlite-embedded by default, full Postgres by config). NOT Hermes.
|
|
||||||
|
|
||||||
## Standing objectives
|
|
||||||
|
|
||||||
- **NS-1** — Single machine-readable source (this file) drives planning; prose docs are projections.
|
|
||||||
- **NS-2** — Every backlog item is an independently-shippable unit with stable id, priority, depends_on DAG, represented as a Mosaic Backlog card; spend tracked as advisory projection.
|
|
||||||
- **NS-3** — The supervisor guarantees movement: no idle agent while ready dependency-satisfied work exists; no empty backlog without a replan request; assignment via Mosaic native dispatch/claim.
|
|
||||||
- **NS-4** — Exactly one merge-gate approver; nothing reaches main except via pr-merge.sh after pr-ci-wait.sh success; Gitea branch protection is the backstop.
|
|
||||||
- **NS-5** — Every unit bounded by wall-clock TTL on its claim; token caps enforced only where a real meter exists, else advisory.
|
|
||||||
- **NS-6** — Context cleared between tasks for ephemeral runners (reset_between_tasks); persona+mission re-injected per task.
|
|
||||||
- **NS-7** — Meta-loop (session-review + enhancer) continuously proposes small fleet-improvement PRs.
|
|
||||||
- **NS-8** — Single operator-flippable PAUSE kill-switch (fleet/run/PAUSED) honored before every dispatch and every merge.
|
|
||||||
- **NS-9** — Mosaic is a general-purpose multi-agent system: the user declares the SYSTEM TYPE to run (e.g. software delivery, personal assistant, research, business/operations) and the orchestrator provisions the matching persona roster and org structure from a cross-domain baseline persona library; the delivery/coding fleet is one profile among many.
|
|
||||||
|
|
||||||
## Success criteria
|
|
||||||
|
|
||||||
- **AC-NS-1** — The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer) healthy across reboot.
|
|
||||||
- **AC-NS-2** — A goal added to this YAML is decomposed to cards and either merged or escalated, with no human in the loop.
|
|
||||||
- **AC-NS-3** — No PR merges with failure/error/no-status/timeout CI, and none bypass pr-merge.sh.
|
|
||||||
- **AC-NS-4** — TTL is enforced on claims; token caps remain advisory until a real meter exists.
|
|
||||||
- **AC-NS-5** — Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
|
|
||||||
- **AC-NS-6** — A user can declare a system type and the fleet provisions the matching persona roster + topology from the baseline library, with no code change.
|
|
||||||
- **AC-NS-7** — A user-customized persona (edited or added via the orchestrator) survives `mosaic update`: baseline reseed never clobbers user overrides.
|
|
||||||
|
|
||||||
## Workstreams
|
|
||||||
|
|
||||||
| id | title |
|
|
||||||
| --- | ----------------------------------------------------------------------------------------------------------- |
|
|
||||||
| A | Substrate — Mosaic Backlog on native Postgres storage service |
|
|
||||||
| B | Supervisor — movement guarantee, two-agent floor, dispatch/claim |
|
|
||||||
| C | Planner — goal decomposition into independently-shippable cards |
|
|
||||||
| D | Merge-gate — single approver, pr-merge.sh after CI wait |
|
|
||||||
| E | Meta-loop — session-review + enhancer improvement PRs |
|
|
||||||
| F | Safety-rails — TTL claims, advisory spend, PAUSE kill-switch |
|
|
||||||
| H | Personas & system profiles — cross-domain library, system-type provisioning, update-surviving customization |
|
|
||||||
|
|
||||||
## Goals (backlog projection)
|
|
||||||
|
|
||||||
| id | title | phase | priority | depends_on |
|
|
||||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ---------- |
|
|
||||||
| A1 | Machine-readable NORTH_STAR.yaml + Markdown projection | 1 | must-have | — |
|
|
||||||
| A2 | Mosaic Backlog schema + storage-service card store (drizzle/PGlite) | 1 | must-have | A1 |
|
|
||||||
| A3a | Card lifecycle — create/claim/release with stable ids + depends_on DAG | 1 | must-have | A2 |
|
|
||||||
| A3b | TTL-bounded claim enforcement (wall-clock) on cards | 1 | must-have | A3a |
|
|
||||||
| A4 | Advisory spend projection per card (degrades to TTL, no real meter) | 1 | should-have | A3a |
|
|
||||||
| B1 | Supervisor tick — readiness scan, two-agent-floor health check | 2 | must-have | A3a |
|
|
||||||
| B2 | Native dispatch/claim — assign ready dependency-satisfied work | 2 | must-have | A3b, B1 |
|
|
||||||
| B3a | Planner decompose — goal added to YAML → cards | 2 | must-have | A2, B1 |
|
|
||||||
| B3b | Replan request on empty backlog; escalate on no-decompose | 2 | should-have | B3a |
|
|
||||||
| G1 | PAUSE kill-switch + merge-gate honored before dispatch and merge | 2 | must-have | B2 |
|
|
||||||
| H1 | Cross-domain baseline persona library (exec, marketing, ops, research, assistant + engineering roles) | 1 | must-have | A1 |
|
|
||||||
| H2 | System-type profiles — declarative mapping of system type to persona roster + topology | 2 | must-have | H1 |
|
|
||||||
| H3 | System-type provisioning — user declares type; orchestrator instantiates the matching roster + structure | 2 | must-have | H2 |
|
|
||||||
| H4 | Update-surviving persona customization — ad-hoc edits/additions persisted in a PRESERVE-protected override layer (baseline merged with overrides) | 2 | must-have | H1 |
|
|
||||||
|
|
||||||
## Assumptions (vetoable)
|
|
||||||
|
|
||||||
- **ASM-1** (vetoable) — The Mosaic Backlog on the native Postgres storage service is the backlog of record.
|
|
||||||
- **ASM-2** (vetoable) — Claude gate roles have no native busy status, so readiness = pane-idle + heartbeat.
|
|
||||||
- **ASM-3** (vetoable) — Two-agent floor = 1 orchestrator + >=1 enhancer.
|
|
||||||
- **ASM-4** (vetoable) — Baseline personas ship in framework/fleet/roles/ (reseeded on update); user overrides live in a separate PRESERVE_PATHS-protected layer and win on merge.
|
|
||||||
|
|
||||||
## Spend
|
|
||||||
|
|
||||||
- **advisory:** true
|
|
||||||
- No per-task token meter yet; budgets degrade to TTL. Spend is tracked only as an advisory projection alongside each card.
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
# Mosaic Fleet — NORTH_STAR (machine-readable source of truth)
|
|
||||||
#
|
|
||||||
# This file is the single machine-readable source of truth for fleet planning.
|
|
||||||
# Prose docs (including NORTH_STAR.md) are deterministic PROJECTIONS of this file.
|
|
||||||
# Regenerate the Markdown projection with the pure generator in
|
|
||||||
# packages/mosaic/src/commands/fleet.ts (renderNorthStarMarkdown). Edit the YAML,
|
|
||||||
# never the .md.
|
|
||||||
#
|
|
||||||
# Self-contained Mosaic. NO Hermes runtime dependency. The backlog of record is
|
|
||||||
# the Mosaic Backlog on Mosaic's OWN native Postgres storage service.
|
|
||||||
|
|
||||||
version: 1
|
|
||||||
|
|
||||||
mission: >-
|
|
||||||
A self-driving Mosaic system that 24/7 unattended converts a machine-readable
|
|
||||||
goal set into merged, CI-green, budget-bounded change — looping
|
|
||||||
plan→backlog→assign→execute→verify→merge→reassess — on Mosaic's OWN native
|
|
||||||
backlog/dispatch engine. Mosaic is general-purpose: the user declares the
|
|
||||||
system type they want (software delivery, personal assistant, research,
|
|
||||||
business/operations, …) and the orchestrator provisions the matching persona
|
|
||||||
roster and structure; the delivery fleet is one profile among many.
|
|
||||||
|
|
||||||
substrate:
|
|
||||||
note: >-
|
|
||||||
The Mosaic Backlog is the backlog of record + dispatch engine, built on
|
|
||||||
Mosaic's native Postgres storage service (@mosaicstack/db drizzle;
|
|
||||||
PGlite-embedded by default, full Postgres by config). NOT Hermes.
|
|
||||||
|
|
||||||
standing_objectives:
|
|
||||||
- id: NS-1
|
|
||||||
text: >-
|
|
||||||
Single machine-readable source (this file) drives planning; prose docs are
|
|
||||||
projections.
|
|
||||||
- id: NS-2
|
|
||||||
text: >-
|
|
||||||
Every backlog item is an independently-shippable unit with stable id,
|
|
||||||
priority, depends_on DAG, represented as a Mosaic Backlog card; spend
|
|
||||||
tracked as advisory projection.
|
|
||||||
- id: NS-3
|
|
||||||
text: >-
|
|
||||||
The supervisor guarantees movement: no idle agent while ready
|
|
||||||
dependency-satisfied work exists; no empty backlog without a replan
|
|
||||||
request; assignment via Mosaic native dispatch/claim.
|
|
||||||
- id: NS-4
|
|
||||||
text: >-
|
|
||||||
Exactly one merge-gate approver; nothing reaches main except via
|
|
||||||
pr-merge.sh after pr-ci-wait.sh success; Gitea branch protection is the
|
|
||||||
backstop.
|
|
||||||
- id: NS-5
|
|
||||||
text: >-
|
|
||||||
Every unit bounded by wall-clock TTL on its claim; token caps enforced
|
|
||||||
only where a real meter exists, else advisory.
|
|
||||||
- id: NS-6
|
|
||||||
text: >-
|
|
||||||
Context cleared between tasks for ephemeral runners
|
|
||||||
(reset_between_tasks); persona+mission re-injected per task.
|
|
||||||
- id: NS-7
|
|
||||||
text: >-
|
|
||||||
Meta-loop (session-review + enhancer) continuously proposes small
|
|
||||||
fleet-improvement PRs.
|
|
||||||
- id: NS-8
|
|
||||||
text: >-
|
|
||||||
Single operator-flippable PAUSE kill-switch (fleet/run/PAUSED) honored
|
|
||||||
before every dispatch and every merge.
|
|
||||||
- id: NS-9
|
|
||||||
text: >-
|
|
||||||
Mosaic is a general-purpose multi-agent system: the user declares the
|
|
||||||
SYSTEM TYPE to run (e.g. software delivery, personal assistant, research,
|
|
||||||
business/operations) and the orchestrator provisions the matching persona
|
|
||||||
roster and org structure from a cross-domain baseline persona library; the
|
|
||||||
delivery/coding fleet is one profile among many.
|
|
||||||
|
|
||||||
success_criteria:
|
|
||||||
- id: AC-NS-1
|
|
||||||
text: >-
|
|
||||||
The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer)
|
|
||||||
healthy across reboot.
|
|
||||||
- id: AC-NS-2
|
|
||||||
text: >-
|
|
||||||
A goal added to this YAML is decomposed to cards and either merged or
|
|
||||||
escalated, with no human in the loop.
|
|
||||||
- id: AC-NS-3
|
|
||||||
text: >-
|
|
||||||
No PR merges with failure/error/no-status/timeout CI, and none bypass
|
|
||||||
pr-merge.sh.
|
|
||||||
- id: AC-NS-4
|
|
||||||
text: >-
|
|
||||||
TTL is enforced on claims; token caps remain advisory until a real meter
|
|
||||||
exists.
|
|
||||||
- id: AC-NS-5
|
|
||||||
text: >-
|
|
||||||
Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
|
|
||||||
- id: AC-NS-6
|
|
||||||
text: >-
|
|
||||||
A user can declare a system type and the fleet provisions the matching
|
|
||||||
persona roster + topology from the baseline library, with no code change.
|
|
||||||
- id: AC-NS-7
|
|
||||||
text: >-
|
|
||||||
A user-customized persona (edited or added via the orchestrator) survives
|
|
||||||
`mosaic update`: baseline reseed never clobbers user overrides.
|
|
||||||
|
|
||||||
workstreams:
|
|
||||||
- id: A
|
|
||||||
title: Substrate — Mosaic Backlog on native Postgres storage service
|
|
||||||
- id: B
|
|
||||||
title: Supervisor — movement guarantee, two-agent floor, dispatch/claim
|
|
||||||
- id: C
|
|
||||||
title: Planner — goal decomposition into independently-shippable cards
|
|
||||||
- id: D
|
|
||||||
title: Merge-gate — single approver, pr-merge.sh after CI wait
|
|
||||||
- id: E
|
|
||||||
title: Meta-loop — session-review + enhancer improvement PRs
|
|
||||||
- id: F
|
|
||||||
title: Safety-rails — TTL claims, advisory spend, PAUSE kill-switch
|
|
||||||
- id: H
|
|
||||||
title: Personas & system profiles — cross-domain library, system-type provisioning, update-surviving customization
|
|
||||||
|
|
||||||
goals:
|
|
||||||
- id: A1
|
|
||||||
title: Machine-readable NORTH_STAR.yaml + Markdown projection
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: []
|
|
||||||
- id: A2
|
|
||||||
title: Mosaic Backlog schema + storage-service card store (drizzle/PGlite)
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [A1]
|
|
||||||
- id: A3a
|
|
||||||
title: Card lifecycle — create/claim/release with stable ids + depends_on DAG
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [A2]
|
|
||||||
- id: A3b
|
|
||||||
title: TTL-bounded claim enforcement (wall-clock) on cards
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [A3a]
|
|
||||||
- id: A4
|
|
||||||
title: Advisory spend projection per card (degrades to TTL, no real meter)
|
|
||||||
phase: 1
|
|
||||||
priority: should-have
|
|
||||||
depends_on: [A3a]
|
|
||||||
- id: B1
|
|
||||||
title: Supervisor tick — readiness scan, two-agent-floor health check
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [A3a]
|
|
||||||
- id: B2
|
|
||||||
title: Native dispatch/claim — assign ready dependency-satisfied work
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [A3b, B1]
|
|
||||||
- id: B3a
|
|
||||||
title: Planner decompose — goal added to YAML → cards
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [A2, B1]
|
|
||||||
- id: B3b
|
|
||||||
title: Replan request on empty backlog; escalate on no-decompose
|
|
||||||
phase: 2
|
|
||||||
priority: should-have
|
|
||||||
depends_on: [B3a]
|
|
||||||
- id: G1
|
|
||||||
title: PAUSE kill-switch + merge-gate honored before dispatch and merge
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [B2]
|
|
||||||
- id: H1
|
|
||||||
title: Cross-domain baseline persona library (exec, marketing, ops, research, assistant + engineering roles)
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [A1]
|
|
||||||
- id: H2
|
|
||||||
title: System-type profiles — declarative mapping of system type to persona roster + topology
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [H1]
|
|
||||||
- id: H3
|
|
||||||
title: System-type provisioning — user declares type; orchestrator instantiates the matching roster + structure
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [H2]
|
|
||||||
- id: H4
|
|
||||||
title: Update-surviving persona customization — ad-hoc edits/additions persisted in a PRESERVE-protected override layer (baseline merged with overrides)
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [H1]
|
|
||||||
|
|
||||||
assumptions:
|
|
||||||
- id: ASM-1
|
|
||||||
vetoable: true
|
|
||||||
text: >-
|
|
||||||
The Mosaic Backlog on the native Postgres storage service is the backlog
|
|
||||||
of record.
|
|
||||||
- id: ASM-2
|
|
||||||
vetoable: true
|
|
||||||
text: >-
|
|
||||||
Claude gate roles have no native busy status, so readiness = pane-idle +
|
|
||||||
heartbeat.
|
|
||||||
- id: ASM-3
|
|
||||||
vetoable: true
|
|
||||||
text: 'Two-agent floor = 1 orchestrator + >=1 enhancer.'
|
|
||||||
- id: ASM-4
|
|
||||||
vetoable: true
|
|
||||||
text: >-
|
|
||||||
Baseline personas ship in framework/fleet/roles/ (reseeded on update);
|
|
||||||
user overrides live in a separate PRESERVE_PATHS-protected layer and win
|
|
||||||
on merge.
|
|
||||||
|
|
||||||
spend:
|
|
||||||
advisory: true
|
|
||||||
note: >-
|
|
||||||
No per-task token meter yet; budgets degrade to TTL. Spend is tracked only
|
|
||||||
as an advisory projection alongside each card.
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
# PRD — Mosaic Fleet Suite (init, configure, operate)
|
|
||||||
|
|
||||||
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312` · **Phase:** 3→4 productization
|
|
||||||
> **North star:** [docs/fleet/north-star.md](./north-star.md) · prior: Phase-2 observability (#579), durable launch (#581), real-agent enablement (#583/#584/#586), releases 0.0.35–0.0.37
|
|
||||||
> **Lead:** Jarvis @ `w-jarvis`. **Collaborator:** coder agent @ `dragon-lin` (jwoltje@10.1.10.37:coder0-0).
|
|
||||||
> Owner of this file: Fleet workstream lead. Does not modify MVP single-writer control-plane files.
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
Turn the proven fleet primitives into a **user-installable, AI-free-configurable fleet product**:
|
|
||||||
a user runs `mosaic fleet init`, answers a few questions (general / coding / research / hybrid),
|
|
||||||
gets a recommended set of agents plus one always-on orchestrator wired for chat-ops, and can
|
|
||||||
operate, mutate, re-create, and observe the fleet — over tmux today and Matrix tomorrow — from
|
|
||||||
CLI/TUI and (designed-for) the webUI.
|
|
||||||
|
|
||||||
**Immediate tangible goal:** the **"Mos"** orchestrator agent running on `w-jarvis`, reachable
|
|
||||||
in **Discord channel `1517622518662434996`** (server `1112631390438166618`). Once the fleet is
|
|
||||||
functional, we use the fleet itself to continue the work.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### A. Configure-without-AI CLI
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| --- | ------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| R1 | `mosaic fleet` command set is functional end-to-end (init/install/start/stop/status/ps/verify + agent verbs). |
|
|
||||||
| R2 | `mosaic fleet init` is an interactive, **AI-free** CLI wizard. |
|
|
||||||
| R3 | Init asks the **configuration type**: `general`, `coding`, `research`, `hybrid`, … (extensible). |
|
|
||||||
| R4 | Based on the answer, the fleet is populated with a **recommended set of agents** (a preset). |
|
|
||||||
| R5 | **Exactly one main orchestrator agent** is always configured, regardless of type. |
|
|
||||||
| R10 | A set of **recommended configurations (presets)** ships for easy duplication. |
|
|
||||||
| R8 | User can **re-create** the fleet when config needs change (idempotent re-init / reconfigure). |
|
|
||||||
| R17 | Fleet controls are **simple and intuitive**. |
|
|
||||||
|
|
||||||
### B. Comms & orchestrator chat-ops
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| --- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| R6 | Init can wire the orchestrator to a chat connector — **Telegram / Discord / Matrix / Slack** — for command + comms. |
|
|
||||||
| R7 | Designed with the end-goal of **Matrix comms on a locally-controlled server**. |
|
|
||||||
| R16 | Fleet supports **tmux AND Matrix** comms, **user-configurable** at init or any time. Not all users want Matrix. |
|
|
||||||
| R19 | **"Mos" orchestrator on Discord** (`chan 1517622518662434996` / `srv 1112631390438166618`) on `w-jarvis` — the first live target. |
|
|
||||||
|
|
||||||
### C. Runtime, health, lifecycle
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| --- | ---------------------------------------------------------------------------------- |
|
|
||||||
| R9 | Fleet is **mutable by the orchestrator agent** — add/remove agents per need. |
|
|
||||||
| R13 | Fleet **gracefully handles Pi + Claude harness updates** — keep harnesses current. |
|
|
||||||
| R14 | The **Pi harness is customized** for proper tool usage, etc. |
|
|
||||||
| R15 | **Agent heartbeat** properly configured for **Claude AND GPT/Pi** agents. |
|
|
||||||
|
|
||||||
### D. Surfaces, testing, docs
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| --- | ----------------------------------------------------------------------------------- |
|
|
||||||
| R18 | Fleet built so the **webUI can view / monitor / terminate / butt-in** on a session. |
|
|
||||||
| R11 | Installed and **tested on both `w-jarvis` and `dragon-lin`**. |
|
|
||||||
| R12 | **Documentation**: how to install, configure, and use the fleet. |
|
|
||||||
|
|
||||||
## Architecture / approach
|
|
||||||
|
|
||||||
- **Config model:** `roster.yaml` is the source of truth (already exists). Add **presets** (`general`/`coding`/`research`/`hybrid`) as shipped example rosters; `init` selects a preset, always injects the orchestrator, and writes the roster. Re-init = regenerate roster (preserve user/site overrides — mirrors install env-merge from #567).
|
|
||||||
- **Orchestrator agent:** always present; carries the chat connector config (connector type + target IDs) so it can be commanded over chat. tmux is the substrate; the connector bridges chat ↔ the orchestrator session.
|
|
||||||
- **Comms layers (R16):** (1) **tmux** inter-agent (`agent-send`, proven) — default, always available. (2) **chat connector** for human↔orchestrator (Discord now; Matrix the strategic target). (3) **Matrix** as the locally-controlled cross-agent bus (future). Connector is pluggable + reconfigurable.
|
|
||||||
- **Heartbeat (R15):** runtime-agnostic launcher sidecar already covers pi/claude/codex (#584). Refine per-runtime (native HB) with the **custom Pi harness** (R14) + a Claude path.
|
|
||||||
- **Updates (R13):** `mosaic update` (CLI) + a fleet-aware harness-update step that refreshes pi/claude/codex and re-launches agents safely (drain → update → relaunch via the durable launcher).
|
|
||||||
- **webUI (R18):** the fleet exposes machine-readable state (`fleet ps --json` already carries tenant/host/heartbeat/managed) + control verbs (start/stop/watch/send); webUI consumes these (control plane rides federation per north star). Ensure a stable JSON contract + a terminate/attach(butt-in) path.
|
|
||||||
|
|
||||||
## Phases (incremental, each shippable)
|
|
||||||
|
|
||||||
| Phase | Deliverable | Notes |
|
|
||||||
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
|
|
||||||
| **F1 Presets + init wizard** | preset rosters (general/coding/research/hybrid) + always-orchestrator + AI-free `fleet init` selecting a preset; re-init idempotent | R1–R5, R8, R10, R17 |
|
|
||||||
| **F2 Connector + Mos-on-Discord** | orchestrator chat-connector config (Discord first) + **Mos live on Discord `1517…`/`1112…`** on w-jarvis | R6, R19, partial R16 |
|
|
||||||
| **F3 Heartbeat + harness** | HB confirmed for claude + pi/gpt; **custom Pi harness** (tool usage, native HB, model self-report); graceful harness updates | R13, R14, R15 |
|
|
||||||
| **F4 Matrix + comms toggle** | Matrix connector (local server) + user toggle tmux/Matrix at init/anytime | R7, R16 |
|
|
||||||
| **F5 Orchestrator-mutable fleet** | orchestrator can add/remove agents at runtime | R9 |
|
|
||||||
| **F6 webUI hooks** | stable JSON contract + terminate/attach surface for webUI view/monitor/terminate/butt-in | R18 |
|
|
||||||
| **F7 Test + docs** | install+test on w-jarvis AND dragon-lin; user docs (install/configure/use) | R11, R12 (runs alongside every phase) |
|
|
||||||
|
|
||||||
## Work division (proposed — confirm with dragon-lin)
|
|
||||||
|
|
||||||
- **Jarvis @ w-jarvis (Lead):** F1 presets+wizard, F2 connector+Mos-on-Discord, F5 mutability, F6 webUI hooks; merge authority + dual-engine reviews; co-testing on w-jarvis.
|
|
||||||
- **coder @ dragon-lin:** F3 custom Pi harness + harness-update flow (pi/codex-savvy); plus its in-flight constitution P4–P6 (P4 installer rework underpins `fleet init`/updates — coordinate the install path). Co-testing on dragon-lin (R11).
|
|
||||||
- **Shared:** F4 Matrix (whoever has bandwidth); F7 testing/docs continuous.
|
|
||||||
|
|
||||||
## Immediate target: Mos on Discord (F2 first slice)
|
|
||||||
|
|
||||||
The discord plugin is available (`~/.claude.json`). Path: configure the **orchestrator** as a durable
|
|
||||||
fleet session running Claude Code with the discord plugin bridged to channel `1517622518662434996`
|
|
||||||
(server `1112631390438166618`) on w-jarvis, with the existing Discord Bridge Protocol (ack within
|
|
||||||
~3s, reply via `mcp__discord__reply`, no `AskUserQuestion`). Heartbeat via the launcher sidecar.
|
|
||||||
|
|
||||||
## Success criteria
|
|
||||||
|
|
||||||
- A non-AI user can `mosaic fleet init`, pick a type, and get a working fleet + orchestrator.
|
|
||||||
- **Mos answers in Discord `1517…`** on w-jarvis.
|
|
||||||
- Fleet runs + is observable (`fleet ps`) on **both** w-jarvis and dragon-lin.
|
|
||||||
- Harness updates handled gracefully; HB healthy for claude + pi/gpt agents.
|
|
||||||
- Docs let a new operator install/configure/use the fleet.
|
|
||||||
- Re-init + orchestrator mutation work.
|
|
||||||
|
|
||||||
## Assumptions (veto-able)
|
|
||||||
|
|
||||||
- `ASSUMPTION:` presets ship as example rosters under the framework (`fleet/examples/*.yaml`), selected by `init`.
|
|
||||||
- `ASSUMPTION:` chat connectors are pluggable; Discord first (target exists), Matrix is the strategic default later.
|
|
||||||
- `ASSUMPTION:` "Mos" = a Claude Code orchestrator session with the discord plugin (reuses the documented Discord Bridge Protocol).
|
|
||||||
- `ASSUMPTION:` per north star, runtimes default to Codex/pi-on-Codex for workers; the orchestrator "Mos" runs Claude Code (in Claude Code, which is allowed).
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
# PRD — Fleet Phase 2: Operator Observability
|
|
||||||
|
|
||||||
> **Workstream:** W-FLEET under `mvp-20260312` · **Phase:** 2
|
|
||||||
> **North star:** [docs/fleet/north-star.md](./north-star.md)
|
|
||||||
> **Source umbrella PRD:** [docs/PRD.md](../PRD.md) (Mosaic Stack v0.1.0)
|
|
||||||
> **Tracks task:** `fleet-observability-1` — restore operator observability into fleet agent sessions.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The durable tmux fleet runs on the isolated `mosaic-fleet` socket. That isolation
|
|
||||||
(which protects the operator's default tmux) makes the fleet **invisible** to default
|
|
||||||
tooling, and truth is split across three planes no single command joins — systemd
|
|
||||||
(`systemctl --user`), tmux (`-L mosaic-fleet`), and the process tree (`pstree`).
|
|
||||||
`agent tail` (`capture-pane`) returns **blank for full-screen TUIs**, and `agent send`
|
|
||||||
confirms only keystroke injection, not acceptance. Net: the operator has near-zero
|
|
||||||
observability and no safe way to watch a session.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
1. One command shows the **whole fleet's** real state, joining all three planes.
|
|
||||||
2. **Liveness is truthful**: healthy = answered a heartbeat, not "pane alive".
|
|
||||||
3. The operator can **watch** any session read-only without disrupting it.
|
|
||||||
4. `send` reports **delivered-and-accepted**, not just injected.
|
|
||||||
5. Every record/address carries **`tenant_id` + `host`** (zero foreclosure for multi-tenant/multi-host).
|
|
||||||
|
|
||||||
## Non-goals (this phase)
|
|
||||||
|
|
||||||
- No webUI (Phase 5; rides federation for cross-host).
|
|
||||||
- No `fleetd` daemon or persistent history store.
|
|
||||||
- No real-runtime swap (Phase 3) — instrument the live **dogfood stub** fleet.
|
|
||||||
- No cross-host aggregation yet (addressing is host-tagged but queries stay local).
|
|
||||||
|
|
||||||
## Functional requirements
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| FR-1 | `mosaic fleet ps [--json]` prints one row per roster agent joining: name · tenant · host · runtime · systemd(active/enabled) · pane(alive/dead) · pid · idle · **last-heartbeat age** · **drift** flag (roster runtime ≠ actual pane command) · **boot-enable** warning (active but `UnitFileState=disabled`). |
|
|
||||||
| FR-2 | **Heartbeat protocol v1** (see below); `dogfood-agent.py` implements the responder. `fleet ps` issues probes (or reads last-seen) and reports health per FR-1. |
|
|
||||||
| FR-3 | `mosaic agent watch <name>` opens a **read-only** view of the pane (grouped session or `tmux attach -r`) that cannot send keystrokes and does not shrink the agent's window. |
|
|
||||||
| FR-4 | `mosaic agent attach <name>` remains the **explicit** interactive-takeover path (separate verb, documented as the only one that can type). |
|
|
||||||
| FR-5 | `mosaic agent send <name> --verify` confirms the message was **accepted** (not left as an unsubmitted draft) and returns non-zero if delivery cannot be verified. |
|
|
||||||
| FR-6 | All structured output (`--json`) includes `tenant_id` and `host` fields. |
|
|
||||||
|
|
||||||
## Heartbeat protocol v1
|
|
||||||
|
|
||||||
- **Probe:** operator/`fleet ps` writes a sentinel line to the agent's input or a
|
|
||||||
well-known per-agent heartbeat file path `~/.config/mosaic/fleet/run/<agent>.hb`.
|
|
||||||
- **Response:** the runtime updates `<agent>.hb` with `ts=<iso8601> pid=<pid> status=<ok|busy>`
|
|
||||||
on a fixed interval (default 15s) and on demand when probed.
|
|
||||||
- **Health rule:** `healthy` if `now - ts <= 3 × interval`; else `stale`; missing file = `unknown`.
|
|
||||||
- **Contract:** every runtime (dogfood stub now; claude/codex/pi/opencode in Phase 3)
|
|
||||||
MUST emit the heartbeat. The protocol is file-based so it works for headless stubs and
|
|
||||||
full-screen TUIs alike (no `capture-pane` dependency).
|
|
||||||
- `ASSUMPTION:` file-based heartbeat (vs in-pane echo) — chosen because it is TUI-safe and
|
|
||||||
uid-scoped, fitting per-tenant isolation. Open to an OTEL-span variant in Phase 3 (MVP-X6).
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- `mosaic fleet ps` shows all 5 live sessions on `mosaic-fleet` with correct
|
|
||||||
pane/pid/idle and flags the dogfood **drift** (`canary-pi` runtime=pi but pane runs
|
|
||||||
`dogfood-agent.py`) and the **boot-enable** gap (active but disabled).
|
|
||||||
- Killing one agent's pane flips its row to dead/stale within one `interval`.
|
|
||||||
- `agent watch` shows live output and provably cannot type into the pane; detaching
|
|
||||||
leaves the agent's window size unchanged.
|
|
||||||
- `agent send --verify` returns success on an accepting pane and non-zero on a wedged/draft pane.
|
|
||||||
- Quality gates green: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, plus
|
|
||||||
`pnpm --filter @mosaicstack/mosaic test`.
|
|
||||||
- Independent review passed; dogfood evidence captured against the live fleet.
|
|
||||||
|
|
||||||
## Test plan
|
|
||||||
|
|
||||||
- Unit/CLI specs in `packages/mosaic/src/commands/fleet.spec.ts` (and a new
|
|
||||||
`fleet-ps`/`watch`/`send-verify` spec) using the injected `CommandRunner` to assert
|
|
||||||
exact tmux/systemd command construction and JSON shape (tenant+host present).
|
|
||||||
- Situational: run against the live `mosaic-fleet` fleet; capture `fleet ps` output,
|
|
||||||
a kill-and-detect cycle, a read-only `watch`, and a `send --verify` pass/fail pair.
|
|
||||||
|
|
||||||
## Known limitations
|
|
||||||
|
|
||||||
- **Verify heuristic is best-effort:** `agent send --verify` uses a `>` -prefix draft
|
|
||||||
heuristic that is specific to pi/claude TUIs. Draft detection for codex and opencode
|
|
||||||
TUIs is best-effort only; those runtimes may not use the same input-line indicator.
|
|
||||||
- **Pane-change check is the best Phase-2 signal; verify now polls up to a bounded
|
|
||||||
timeout:** `agent send --verify` captures a BEFORE snapshot, sends the message, then
|
|
||||||
polls `capture-pane` every ~400 ms up to a configurable total timeout (default ~6 s,
|
|
||||||
controlled by `--verify-timeout <ms>`). On each poll it runs classifySendResult: if
|
|
||||||
the pane shows 'accepted' or 'draft' the loop exits immediately; while the result is
|
|
||||||
'unverifiable' (no pane change yet) it keeps polling. After the timeout with no
|
|
||||||
definitive result, it fails closed: exit 1 with "no pane change after send". This
|
|
||||||
eliminates false 'unverifiable' failures for slow/loaded TUIs that were previously
|
|
||||||
caused by the old fixed 300 ms single-capture. Definitive acceptance ultimately
|
|
||||||
requires a runtime acknowledgement (Phase-3 heartbeat-ack); the bounded pane-change
|
|
||||||
poll is the best signal available against an opaque TUI for Phase-2.
|
|
||||||
- **Blank AFTER capture fails closed:** Full-screen TUIs (claude, codex, opencode, pi)
|
|
||||||
render blank for `tmux capture-pane`. When the AFTER snapshot is empty, `send --verify`
|
|
||||||
returns non-zero with an "unverifiable" message rather than silently succeeding. This
|
|
||||||
is an intentional fail-closed design (FR-5).
|
|
||||||
- **`agent watch` uses a grouped viewer session:** `tmux attach -r` directly against the
|
|
||||||
agent session lets the viewer terminal shrink the agent's window. `agent watch` instead
|
|
||||||
creates a throwaway grouped session (`tmux new-session -d -t '=<agent>' -s
|
|
||||||
'<agent>-watch-<pid>'`), attaches read-only to that session, and kills it on detach.
|
|
||||||
The grouped session shares the agent's windows but has independent sizing, so the
|
|
||||||
agent's window is never affected. `tmux attach` is still interactive and requires
|
|
||||||
inherited stdio; the `interactiveRunner` handles TTY passthrough.
|
|
||||||
|
|
||||||
## Surfaces & parity (MVP-X1)
|
|
||||||
|
|
||||||
CLI lands this phase. TUI surface follows in the `packages/mosaic` wizard; webUI in
|
|
||||||
Phase 5 via federation. PRD records the parity debt explicitly so it is not lost.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Tasks — W-FLEET (Fleet) Phase 2: Observability
|
|
||||||
|
|
||||||
> Workstream task file for the Fleet. Single-writer: Fleet workstream lead (orchestrator).
|
|
||||||
> Workers read but never modify. This is **not** the MVP rollup (`docs/TASKS.md`) — a
|
|
||||||
> rollup row is proposed to the MVP orchestrator, not written here.
|
|
||||||
>
|
|
||||||
> Mission: `mvp-20260312` · PRD: [docs/fleet/PRD.md](./PRD.md) · North star: [docs/fleet/north-star.md](./north-star.md)
|
|
||||||
> Status: `not-started` | `in-progress` | `done` | `blocked` | `failed`
|
|
||||||
|
|
||||||
| id | status | description | depends_on | agent | pr | notes |
|
|
||||||
| ------------- | ----------- | ------------------------------------------------------------------------------------------------------------------ | --------------------- | ----------- | --- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| FLEET-OBS-000 | done | Plan: north-star + Phase-2 PRD + workstream scaffolding | — | lead | — | persisted 2026-06-20 on `feat/fleet-observability` |
|
|
||||||
| FLEET-OBS-001 | done | Heartbeat protocol v1 spec finalized in PRD + framework doc | FLEET-OBS-000 | lead | — | file-based `~/.config/mosaic/fleet/run/<agent>.hb`; spec in PRD |
|
|
||||||
| FLEET-OBS-002 | in-progress | Implement heartbeat responder in `dogfood-agent.py` | FLEET-OBS-001 | fleet-coder | — | dispatched to ad-hoc `mosaic yolo` fleet agent (dogfood) |
|
|
||||||
| FLEET-OBS-003 | done | `mosaic fleet ps` — join systemd+tmux+proc+idle+heartbeat; tenant+host tagged; drift + boot-enable flags; `--json` | FLEET-OBS-001 | worker | — | commit ab47831; LIVE-verified on mosaic-fleet; caught canary-pi DRIFT + BOOT-ENABLE. Polish: idleSeconds parse returns null |
|
|
||||||
| FLEET-OBS-004 | done | `mosaic agent watch <name>` — read-only join (no resize, no keystrokes) | FLEET-OBS-000 | worker | — | `attach -r`; verb wired |
|
|
||||||
| FLEET-OBS-005 | done | `mosaic agent send --verify` — delivery/acceptance receipt | FLEET-OBS-000 | worker | — | --verify flag; draft-heuristic verify |
|
|
||||||
| FLEET-OBS-006 | done | CLI specs for ps/watch/send-verify (tenant+host shape, command construction) | FLEET-OBS-003,004,005 | worker | — | 62 tests green (31 new); re-verified by lead |
|
|
||||||
| FLEET-OBS-007 | not-started | Framework doc: fleet observability guide + verbs | FLEET-OBS-003,004,005 | lead | — | `docs/guides/` or `framework/tools/.../README` |
|
|
||||||
| FLEET-OBS-008 | not-started | Independent review + dogfood verification on live fleet | FLEET-OBS-002..007 | reviewer | — | author ≠ reviewer; capture evidence in scratchpad |
|
|
||||||
| FLEET-OBS-009 | not-started | Open PR → green CI (queue guard) → squash-merge → close `fleet-observability-1` | FLEET-OBS-008 | lead | — | trunk merge; no direct push to main |
|
|
||||||
|
|
||||||
## Proposed MVP rollup row (for the MVP orchestrator — not written by this workstream)
|
|
||||||
|
|
||||||
```
|
|
||||||
| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |
|
|
||||||
```
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
# Fleet Backlog Conventions
|
|
||||||
|
|
||||||
The **backlog** is Mosaic's native backlog-of-record for fleet work. It is built
|
|
||||||
end-to-end on Mosaic's own storage layer (`@mosaicstack/db`, drizzle/Postgres)
|
|
||||||
and surfaced as `mosaic fleet backlog <sub> --json`.
|
|
||||||
|
|
||||||
> **Mosaic-native, no Hermes.** This backlog REPLACES the former Hermes adapter.
|
|
||||||
> There is **no** runtime dependency on Hermes, `hermes kanban`, or `~/.hermes`
|
|
||||||
> anywhere in this feature. Anything previously delegated to Hermes is recreated
|
|
||||||
> here on Mosaic's own Postgres storage layer.
|
|
||||||
|
|
||||||
## Storage tier — PGlite by default, Postgres by config
|
|
||||||
|
|
||||||
The backlog uses the existing Mosaic storage layer; there is **no** new database
|
|
||||||
engine (no sqlite, no raw client).
|
|
||||||
|
|
||||||
| Condition | Tier | Data location |
|
|
||||||
| ------------------------------ | -------------------- | -------------------------------- |
|
|
||||||
| `DATABASE_URL` set | Full server Postgres | the configured database |
|
|
||||||
| `PGLITE_DATA_DIR` set (no URL) | Embedded PGlite | that directory |
|
|
||||||
| neither (default) | Embedded PGlite | `~/.config/mosaic/fleet/backlog` |
|
|
||||||
|
|
||||||
PGlite is real Postgres semantics in-process — including the row locks the atomic
|
|
||||||
claim relies on — so the **same code** runs on a laptop (embedded, single-host
|
|
||||||
default) and on a full Postgres deployment. Switching tiers is config-only.
|
|
||||||
|
|
||||||
The schema (`backlog` table) is created automatically on first CLI use:
|
|
||||||
`runMigrations()` for Postgres, `runPgliteMigrations()` for embedded PGlite.
|
|
||||||
|
|
||||||
### Update safety
|
|
||||||
|
|
||||||
The embedded PGlite store lives under `~/.config/mosaic/fleet/backlog`, which is
|
|
||||||
listed in `PRESERVE_PATHS` in `packages/mosaic/framework/install.sh`. This means
|
|
||||||
`mosaic update` (which runs the framework sync with `rsync --delete`) will **not**
|
|
||||||
wipe the operator's backlog — same protection as the roster, per-agent env, and
|
|
||||||
heartbeat run dir.
|
|
||||||
|
|
||||||
## Card schema
|
|
||||||
|
|
||||||
A card is one row in the `backlog` table:
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
| ------------------- | ------------------- | ------------------------------------------------------------- |
|
|
||||||
| `id` | text (PK) | Stable, caller-supplied id (e.g. `A4`, `fleet-001`). |
|
|
||||||
| `title` | text | Required. |
|
|
||||||
| `body` | text (nullable) | Free-form description. |
|
|
||||||
| `phase` | text (nullable) | Board/phase grouping (see below). |
|
|
||||||
| `priority` | int (default 0) | **Higher = sooner.** Claim picks the max-priority ready card. |
|
|
||||||
| `status` | enum | `ready` \| `claimed` \| `blocked` \| `done`. |
|
|
||||||
| `depends_on` | jsonb `string[]` | DAG edges — ids of cards this one depends on. |
|
|
||||||
| `claim_owner` | text (nullable) | Owner token of the active claim. |
|
|
||||||
| `claim_ttl_seconds` | int (nullable) | TTL of the active claim. |
|
|
||||||
| `claimed_at` | timestamptz (null) | When the claim was taken. `claimed_at + ttl` = expiry. |
|
|
||||||
| `attempts` | int (default 0) | Incremented each time the card is claimed. |
|
|
||||||
| `idempotency_key` | text (unique, null) | Dedups `create`; NULLs are distinct in Postgres. |
|
|
||||||
| `acceptance` | jsonb (nullable) | Acceptance criteria (array of strings or object). |
|
|
||||||
| `created_at` | timestamptz | |
|
|
||||||
| `updated_at` | timestamptz | |
|
|
||||||
|
|
||||||
`depends_on` is modeled as a `jsonb` array column rather than a separate edge
|
|
||||||
table. Justification: it matches the repo's existing style (e.g. `tasks.tags`,
|
|
||||||
`agents.skills`, `routing_rules.conditions` are all jsonb arrays), keeps a card
|
|
||||||
self-contained, and the DAG is small (per-card dependency lists), so a join table
|
|
||||||
would add ceremony without benefit.
|
|
||||||
|
|
||||||
### Board / phase convention
|
|
||||||
|
|
||||||
`phase` is a free-form grouping string used as the board column / milestone label
|
|
||||||
(e.g. `M1`, `fleet`, `infra`). `list --phase <phase>` filters to one board lane.
|
|
||||||
`priority` orders cards **within** the ready pool regardless of phase.
|
|
||||||
|
|
||||||
## Status lifecycle
|
|
||||||
|
|
||||||
```
|
|
||||||
create
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────► ready ───── claim ─────► claimed ───── complete ─────► done
|
|
||||||
│ │ │
|
|
||||||
│ block reclaim (TTL expiry or --id)
|
|
||||||
│ ▼ │
|
|
||||||
│ blocked └──────────────────────────┘ (back to ready)
|
|
||||||
└──────────┘ (reclaim / re-create can return a card to ready)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **ready** — eligible to be claimed once every `depends_on` card is `done`.
|
|
||||||
- **claimed** — a worker holds it; `claim_owner` + `claimed_at` set.
|
|
||||||
- **blocked** — explicitly parked; never auto-claimed.
|
|
||||||
- **done** — completed; satisfies dependents.
|
|
||||||
|
|
||||||
## Atomic claim (`FOR UPDATE SKIP LOCKED`) + TTL
|
|
||||||
|
|
||||||
`claim` is atomic. Inside a single transaction it locks candidate `ready` rows
|
|
||||||
with `SELECT ... FOR UPDATE SKIP LOCKED` (via the drizzle `sql` operator), picks
|
|
||||||
the highest-priority deps-satisfied card, and flips it to `claimed`. Because a row
|
|
||||||
already locked by a concurrent claimer is **skipped**, two claimers can **never**
|
|
||||||
both win the same card — the loser falls through to the next candidate or gets
|
|
||||||
`null`. (Proven by the concurrency tests in `packages/db/src/backlog.spec.ts`.)
|
|
||||||
|
|
||||||
- **Deps gate:** a card is only claimable when every id in `depends_on` is `done`.
|
|
||||||
- **TTL:** `claim --ttl <sec>` (default **900s**) records `claim_ttl_seconds`.
|
|
||||||
- **reclaim:** releases claims whose `claimed_at + ttl` is in the past (expired)
|
|
||||||
back to `ready`, clearing the claim fields. `reclaim --id <id>` force-releases a
|
|
||||||
specific card regardless of expiry. This is how a crashed worker's card returns
|
|
||||||
to the pool.
|
|
||||||
|
|
||||||
## CLI — `mosaic fleet backlog <sub> --json`
|
|
||||||
|
|
||||||
All subcommands support `--json`.
|
|
||||||
|
|
||||||
| Subcommand | Purpose |
|
|
||||||
| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
||||||
| `create --id --title [--body --phase --priority --depends-on --acceptance --idempotency-key]` | Create a card; `idempotency_key` dedups (repeat returns the existing card). |
|
|
||||||
| `list [--status --phase --ready-only]` | List cards. `--ready-only` = status `ready` AND all deps `done`. |
|
|
||||||
| `claim --owner [--ttl <sec> --id <id>]` | Atomically claim the highest-priority ready card (or `--id`). Returns the card or `null`. |
|
|
||||||
| `reclaim [--id <id>]` | Release expired claims (or a specific card) back to `ready`. |
|
|
||||||
| `link --from --to` | Add a `depends_on` edge (`--from` depends on `--to`). |
|
|
||||||
| `stats` | Counts by status, oldest-ready age, expired-claim count. |
|
|
||||||
| `block --id` | Set a card to `blocked`. |
|
|
||||||
| `complete --id` | Set a card to `done` (releases any claim). |
|
|
||||||
|
|
||||||
### Example
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Seed two cards, the second depends on the first.
|
|
||||||
mosaic fleet backlog create --id A1 --title "schema" --priority 5
|
|
||||||
mosaic fleet backlog create --id A2 --title "service" --depends-on A1 --priority 9
|
|
||||||
|
|
||||||
# A2 is gated on A1, so claim returns A1 first.
|
|
||||||
mosaic fleet backlog claim --owner worker-1 --ttl 600 --json
|
|
||||||
|
|
||||||
# Finish A1; now A2 is ready.
|
|
||||||
mosaic fleet backlog complete --id A1
|
|
||||||
mosaic fleet backlog list --ready-only --json
|
|
||||||
|
|
||||||
# Recover stalled work.
|
|
||||||
mosaic fleet backlog reclaim --json
|
|
||||||
```
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# F4 — Orchestrator chat connector + Matrix (local homeserver)
|
|
||||||
|
|
||||||
> **Issue:** #616 · **Doctrine:** `docs/fleet/north-star.md` (#613) — orchestrator-chat-connector decision.
|
|
||||||
> **Status:** Phase 1 (abstraction + scaffold) in this PR; Phase 2+ are follow-ups (below).
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
The fleet **orchestrator** is the operator's single point of contact. The north-star makes the
|
|
||||||
chat channel a **user-chosen connector** — tmux today, Discord live ("Mos"), with Matrix /
|
|
||||||
Telegram / Slack configurable. F4 adds **Matrix** (local homeserver) as a **peer** connector and,
|
|
||||||
first, the small **connector abstraction** that makes connectors pluggable without touching fleet
|
|
||||||
core.
|
|
||||||
|
|
||||||
## The abstraction (Phase 1 — this PR)
|
|
||||||
|
|
||||||
Connectors implement one small, uniform interface (`src/fleet/connectors/types.ts`):
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface OrchestratorConnector {
|
|
||||||
readonly kind: 'tmux' | 'discord' | 'matrix';
|
|
||||||
send(message: OutboundMessage): Promise<SendResult>; // orchestrator → human
|
|
||||||
subscribe(handler: (m: InboundMessage) => void): Unsubscribe; // human → orchestrator
|
|
||||||
health(): Promise<ConnectorHealth>; // reachable + authenticated
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **send / subscribe / health** — the only surface fleet core depends on. `SendResult` is the
|
|
||||||
ack half; `health()` is the liveness half.
|
|
||||||
- **Thread-aware by metadata** — `OutboundMessage.threadId` / `InboundMessage.threadId` are
|
|
||||||
optional, so thread-capable connectors (Matrix rooms/threads, the future first-party Mosaic
|
|
||||||
Discord plugin) fit **without an interface change**.
|
|
||||||
- **Registry** (`registry.ts`) — implementations register a factory by kind; `createConnector(config)`
|
|
||||||
resolves one from roster config. Phase 1 ships the registry + `resolveConnectorKind` (defaults
|
|
||||||
`tmux` when a roster declares no connector — **back-compat**); the factories land in Phase 2.
|
|
||||||
|
|
||||||
### Config model
|
|
||||||
|
|
||||||
A roster may carry an optional `connector` block (`roster.schema.json`); absent ⇒ tmux.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
connector:
|
|
||||||
kind: matrix # tmux | discord | matrix
|
|
||||||
matrix:
|
|
||||||
homeserver_url: https://matrix.example.internal
|
|
||||||
user_id: '@mos:example.internal'
|
|
||||||
room_id: '!abc:example.internal'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Secrets are never in the roster.** `MATRIX_ACCESS_TOKEN` / `DISCORD_BOT_TOKEN` come from the
|
|
||||||
environment (the gateway env-config pattern that already masks them). The sanitization gate would
|
|
||||||
reject a token committed to a shipped file anyway.
|
|
||||||
|
|
||||||
## Matrix connector (Phase 2)
|
|
||||||
|
|
||||||
The connector speaks the **Matrix client-server API** directly over HTTPS (`fetch` — no SDK needed
|
|
||||||
for MVP), so it is **homeserver-agnostic**:
|
|
||||||
|
|
||||||
| Op | Matrix CS-API |
|
|
||||||
| ----------- | ------------------------------------------------------------------------ |
|
|
||||||
| `send` | `PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}` |
|
|
||||||
| `subscribe` | `GET /_matrix/client/v3/sync` (long-poll, `since` token) → room timeline |
|
|
||||||
| `health` | `GET /_matrix/client/versions` (reachable) + `…/account/whoami` (authed) |
|
|
||||||
| threads | `m.thread` relations ↔ `threadId` |
|
|
||||||
|
|
||||||
## Local homeserver (infra, not connector code)
|
|
||||||
|
|
||||||
Strategic default: a **self-hosted** homeserver on our own infra — no third-party gateway.
|
|
||||||
|
|
||||||
- **Default: Conduit** (Rust, single binary, low resource) — trivial to stand up for a fleet/dev
|
|
||||||
homeserver.
|
|
||||||
- **Alternative: Synapse** (mature, feature-complete) for scale.
|
|
||||||
|
|
||||||
The connector only needs `homeserver_url` + `user_id` + `room_id` + an access token, so the
|
|
||||||
homeserver choice is a **deployment** concern (a Phase-2 deploy guide), not connector code.
|
|
||||||
|
|
||||||
## Phasing
|
|
||||||
|
|
||||||
| Phase | Scope | This PR |
|
|
||||||
| ----- | --------------------------------------------------------------------------------------- | ------- |
|
|
||||||
| **1** | Connector interface + types, registry + kind resolution, roster `connector` schema, doc | ✅ yes |
|
|
||||||
| 2 | Matrix CS-API client (fetch-based send/sync/health) + registered factory + tests | follow |
|
|
||||||
| 2 | `fleet init` / `configure` connector-selection UX; roster parse wires the block | follow |
|
|
||||||
| 2 | systemd launch wiring so the orchestrator starts on the chosen connector | follow |
|
|
||||||
| 3 | Conduit deploy guide; first-party Mosaic Discord (threads) registers as a connector | follow |
|
|
||||||
|
|
||||||
## Back-compat & boundaries
|
|
||||||
|
|
||||||
- Existing rosters (no `connector`) resolve to tmux — **zero change**.
|
|
||||||
- Fleet core never branches on connector kind; it depends only on the interface.
|
|
||||||
- Cross-host reach rides the **federation** layer (W1), not a bespoke broker (north-star assumption).
|
|
||||||
- Phase 1 touches **no** `fleet.ts` core (a self-contained `connectors/` module), so it is
|
|
||||||
independent of the in-flight fleet-config PRs.
|
|
||||||
@@ -1,430 +0,0 @@
|
|||||||
# Mosaic Fleet — North Star
|
|
||||||
|
|
||||||
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312`
|
|
||||||
> **Umbrella:** [docs/MISSION-MANIFEST.md](../MISSION-MANIFEST.md) · [docs/PRD.md](../PRD.md) (Mosaic Stack v0.1.0)
|
|
||||||
> **Status:** doctrine — authored 2026-06-20. Owner of this file: Fleet workstream lead.
|
|
||||||
> This document does **not** modify the MVP rollup; a rollup row is proposed, not written here.
|
|
||||||
|
|
||||||
## Vision
|
|
||||||
|
|
||||||
A **customizable, multi-tenant fleet of always-on AI agents** — each defined by role,
|
|
||||||
materialized as a durable, joinable runtime session, coordinated by the proven
|
|
||||||
orchestrator/worker model, and observable end-to-end across hosts. Coding today;
|
|
||||||
finance, analytics, research as roster entries tomorrow — same primitives, different
|
|
||||||
roster. The fleet is the **agent-session execution layer** of the Mosaic Stack MVP:
|
|
||||||
the thing federation makes reachable across hosts and the webUI/TUI/CLI make visible.
|
|
||||||
|
|
||||||
The USC tmux PoC (durable sessions + `agent-send` comms) proved the model. This
|
|
||||||
workstream makes it an official, observable, multi-tenant Mosaic Stack capability.
|
|
||||||
|
|
||||||
## The Fleet as means of production (bootstrapping)
|
|
||||||
|
|
||||||
The Fleet has a **dual role**, and that is the point:
|
|
||||||
|
|
||||||
- **As product** — a multi-tenant agent-fleet capability of Mosaic Stack (this workstream).
|
|
||||||
- **As means of production** — the orchestrator/worker fleet that _actually builds the
|
|
||||||
entire MVP_ (federation W1, webUI, TUI, CLI, and the Fleet itself).
|
|
||||||
|
|
||||||
We are **building the system that builds the system.** Every other MVP workstream is
|
|
||||||
delivered _by_ the fleet, so fleet observability and control are not merely product
|
|
||||||
features — they are the **operational floor of the whole delivery effort**. If we cannot
|
|
||||||
see and steer the agents, we cannot trust what they ship. This is why Phase 2
|
|
||||||
(observability) leads: it is the instrument panel for the factory, dogfooded on the live
|
|
||||||
fleet that is, recursively, building Mosaic Stack.
|
|
||||||
|
|
||||||
The discipline that makes great power safe is the same gate chain the fleet enforces:
|
|
||||||
independent review before merge, green CI, honest completion, decide-and-inform cadence,
|
|
||||||
and no irreversible action without authority. The bootstrap is only as trustworthy as
|
|
||||||
those gates.
|
|
||||||
|
|
||||||
## Alignment with MVP cross-cutting requirements
|
|
||||||
|
|
||||||
The Fleet inherits — does not re-invent — the MVP's hard requirements:
|
|
||||||
|
|
||||||
| MVP req | What it means for the Fleet |
|
|
||||||
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| MVP-X1 three-surface parity | fleet observability/control reachable via **CLI + TUI + webUI** (CLI first; webUI is required for parity, not optional) |
|
|
||||||
| MVP-X2 multi-tenant isolation | one tenant = one **Linux uid** (own `systemd --user`, socket, `~/.config/mosaic`); no cross-tenant leakage |
|
|
||||||
| MVP-X3 auth (BetterAuth/SSO) | operator→fleet and cross-host views are auth-gated through the platform's existing auth |
|
|
||||||
| MVP-X4 quality gates | `pnpm typecheck`/`lint`/`format:check` green before any push |
|
|
||||||
| MVP-X5 federated topology | cross-host fleet visibility rides the **federation** boundary (W1), not a bespoke broker |
|
|
||||||
| MVP-X6 OTEL tracing | heartbeats, sends, and lifecycle events emit spans; `traceparent` crosses the federation boundary |
|
|
||||||
| MVP-X7 trunk merge | branch from `main`, squash-merge via PR, never push to `main` |
|
|
||||||
|
|
||||||
## The stack — where every concern lives
|
|
||||||
|
|
||||||
One **definition** is the source of truth; the **session** is how it runs.
|
|
||||||
|
|
||||||
| Layer | Owner | Phase-2 reality | Destination |
|
|
||||||
| -------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **Definition + identity + auth** | gateway / `mosaic-as` (scoped tokens, #541) | `roster.yaml` (tenant-tagged) | one definition; `mosaic agent --new` materializes it |
|
|
||||||
| **Tenancy boundary** | **Linux uid per tenant** (linger, own `systemd --user`, own socket, own `~/.config/mosaic`) | one tenant: `jarvis` = tenant zero | uid-per-tenant; federation aggregates across hosts |
|
|
||||||
| **Runtime** | per-tenant tmux session on isolated socket | dogfood stub sessions (live now on `mosaic-factory`) | claude/codex/pi/opencode TUIs |
|
|
||||||
| **Liveness** | **heartbeat protocol** every runtime answers | protocol defined + dogfood stub answers it | all runtimes answer; "healthy" ≠ "pane alive" |
|
|
||||||
| **Observation** | read-only `watch` (native tmux) + `pipe-pane` stream | CLI `watch`/`ps`; explicit opt-in `attach` for control | + auth-gated webUI streams |
|
|
||||||
| **Control plane** | **federation** across hosts × tenants | records already carry `tenant_id` + `host` | federated gateways expose fleet state; webUI in Phase 5 |
|
|
||||||
| **Central register** | Postgres `fleet` schema (gateway instance); access via gateway API only | _none in PoC_ (files + `roster.yaml`) | agents, missions, tasks, heartbeats, spend — single network-accessible SSOT; docs = generated projections |
|
|
||||||
| **Budget / spend governance** | **per-tenant budget policy** ingested by the orchestrator + routing layer | none today (spend is unmetered) | usage-vs-limit feedback ingested; spend auto-paced to the limit window; per-provider/per-account/concurrency/API-$ budgets enforced |
|
|
||||||
|
|
||||||
> **PoC socket hygiene:** the PoC fleet runs on the **default tmux socket** (no `-L`).
|
|
||||||
> The named production-isolation socket is **`mosaic-fleet`** (matches the product brand);
|
|
||||||
> an absent roster `socket_name` means the default socket everywhere (spawn, `fleet ps`,
|
|
||||||
> onboarding cheat-sheet). The legacy dogfood canary still runs on the old `mosaic-factory`
|
|
||||||
> socket pending migration.
|
|
||||||
|
|
||||||
## Operating model (inherited, not reinvented)
|
|
||||||
|
|
||||||
The AI-guide law stands: one accountable **orchestrator**, isolated **workers** that
|
|
||||||
stop at PR-open, the serialized **gate chain** (independent review → green CI →
|
|
||||||
diff-sanity → squash-merge → verify), **decide-and-inform** cadence, and a durable
|
|
||||||
**board** so missions survive session death. The Fleet is the infrastructure _under_
|
|
||||||
this model. See `mosaicstack-aiguide` whitepapers 01 (inter-agent comms) and 03
|
|
||||||
(orchestration model) for the rationale.
|
|
||||||
|
|
||||||
## Fleet roster — the two-agent floor and the role library
|
|
||||||
|
|
||||||
A fleet is **never a single agent**. The minimum viable fleet is **two**:
|
|
||||||
|
|
||||||
| Role | Mandate | Boundaries |
|
|
||||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
||||||
| **Orchestrator** | The user's **single point of contact**. Owns the general flow, keeps agentic actions on-target, and **adds/removes agents from the fleet at will** to meet goals and user needs. Exactly **one** per fleet (the existing R5 invariant). | Delegates source work; never the sole worker. |
|
|
||||||
| **Enhancer** | The fleet's **continuous-improvement loop**. Monitors fleet activity, analyzes for enhancements/optimizations, builds a **plan of remediation**, and — **with the orchestrator** — upgrades fleet capability: tool creation/repair, skills, harness improvements, and **bug reports filed to Mosaic Stack** for proper remediation. Recommends which agents are needed. | **Does not code, review code, or perform delivery tasks.** Improvement and diagnosis only. |
|
|
||||||
|
|
||||||
> **Why two, not one:** the orchestrator drives delivery; the enhancer makes the fleet
|
|
||||||
> _get better at delivering_ over time. The enhancer is how the fleet self-heals its tools,
|
|
||||||
> skills, and harnesses, and how real defects flow back to Mosaic Stack as bug reports.
|
|
||||||
> Together they are the irreducible core — every other role is added on demand.
|
|
||||||
|
|
||||||
A **general** fleet starts at this floor: the orchestrator (advised by the enhancer)
|
|
||||||
materializes whatever roles prove necessary over the mission's life. Specialized presets
|
|
||||||
(coding, research, etc.) seed additional roles up front, but all reduce to the same two-agent
|
|
||||||
spine plus an on-demand **role library**:
|
|
||||||
|
|
||||||
| Role profile | Purpose |
|
|
||||||
| ------------------- | --------------------------------------------------------------------------------- |
|
|
||||||
| **orchestrator** | point of contact, flow control, fleet composition (1 per fleet) |
|
|
||||||
| **enhancer** | fleet monitoring, optimization, tool/skill/harness upgrades, upstream bug reports |
|
|
||||||
| **coder** | implementation (worker; stops at PR-open) |
|
|
||||||
| **code review** | independent code review gate |
|
|
||||||
| **security review** | security/auth/secret review gate |
|
|
||||||
| **research** | investigation, synthesis, options analysis |
|
|
||||||
| **board** | deliberation panel — moonshot, contrarian, technical, business, financial lenses |
|
|
||||||
| **operations** | infra, deploy, health, incident response |
|
|
||||||
| _…extensible_ | new profiles added as missions demand (orchestrator + enhancer decide) |
|
|
||||||
|
|
||||||
## Invariants — "maximal vision, incremental delivery, zero foreclosure"
|
|
||||||
|
|
||||||
Every artifact, starting Phase 2, MUST:
|
|
||||||
|
|
||||||
1. Carry **`tenant_id` + `host`** in schema and message addressing — even with one of each today.
|
|
||||||
2. Treat **isolation socket ≠ invisibility** — anything isolated is surfaced by one command.
|
|
||||||
3. Define **healthy = answered a heartbeat within N seconds**, never just "pane alive".
|
|
||||||
4. Make **observation read-only by default**; control is an explicit, separate, opt-in verb.
|
|
||||||
|
|
||||||
> **OPS INVARIANT — runtime agents need a real TTY.** Claude/Codex/pi/opencode agents
|
|
||||||
> cannot be bare-launched from a systemd `ExecStart`; a durable harness with a real PTY is
|
|
||||||
> required. This is **why `start-agent-session.sh` launches into tmux** and uses a
|
|
||||||
> `MOSAIC_AGENT_COMMAND` override rather than running the runtime directly under systemd.
|
|
||||||
|
|
||||||
## Budget & token governance (first-class fleet concern)
|
|
||||||
|
|
||||||
Spend is a fleet-level resource, not a per-agent afterthought. The fleet treats token
|
|
||||||
and API-dollar budget the way it treats liveness: a signal every runtime exposes and the
|
|
||||||
control plane is accountable for. This rides the same primitives as everything else —
|
|
||||||
`tenant_id` + `host` on every spend record, **read-only metering by default**, and the
|
|
||||||
**federation** layer as the cross-host aggregation point (W1) — so budgeting is zero-foreclosure
|
|
||||||
from day one even while one tenant exists.
|
|
||||||
|
|
||||||
**Two spend regimes, one policy surface:**
|
|
||||||
|
|
||||||
| Regime | Feedback signal | Fleet obligation |
|
|
||||||
| ------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **OAuth-subscription runtimes** (Claude sub, Codex sub) | runtime exposes **current-usage-vs-limit** within a rolling limit window | **ingest** the signal per sub-account; **auto-pace** agentic spend so the window is not exhausted early |
|
|
||||||
| **API-token runtimes** (metered per token) | provider billing / token counts | enforce **hard $-spend ceilings**; on breach, **downgrade → queue → refuse** (below) |
|
|
||||||
|
|
||||||
**Auto-pacing law (OAuth subs) — EVEN-SPREAD default (Jason override, 2026-06-22):** the fleet
|
|
||||||
paces agentic token spend to consume the limit window **evenly over remaining time**:
|
|
||||||
target rate = _(remaining usage available)_ ÷ _(remaining time in the window)_. Example: 100% of
|
|
||||||
a 7-day window = **~14.285%/day**; the system tracks current usage and continuously re-splits the
|
|
||||||
remainder evenly to hold pace. **Anticipated token-spend-per-task is the budgeting informant** —
|
|
||||||
tasks are scheduled against the daily pace, not run until the quota is gone. Rationale: spreading
|
|
||||||
delivery evenly beats rapidly exhausting usage and losing **multiple days of momentum**.
|
|
||||||
**Rapid pacing / overspend requires EXPLICIT user authorization;** absent it, even-spread holds.
|
|
||||||
Pacing is a control-plane decision, surfaced read-only before it throttles a lane.
|
|
||||||
|
|
||||||
**Hard-cap breach behavior (ladder):** when a budget ceiling is hit mid-work, the fleet
|
|
||||||
**downgrades first** (opus → sonnet → haiku, then Claude → Codex), **queues** the lane at the
|
|
||||||
cheapest floor until the window resets, and **refuses** only as a last resort. Refusal is never
|
|
||||||
the first response to a breach.
|
|
||||||
|
|
||||||
**Spend accounting, learning & telemetry:**
|
|
||||||
|
|
||||||
- **Multi-subscription auto-routing:** a tenant with multiple subscriptions may let the fleet
|
|
||||||
**auto-route work to the account with the most available usage** (within budget policy).
|
|
||||||
- **Historical spend learning:** every task's token spend is **recorded**; historical data
|
|
||||||
continuously updates known **spend-per-task**, **typical daily spend**, and projections — so
|
|
||||||
estimates self-correct and pacing stays on target.
|
|
||||||
- **Projected + actual spend on artifacts (Mosaic Stack mandate):** PRDs, missions, and task
|
|
||||||
decomposition **MUST note projected AND actual token spend** — a Mosaic Stack process standard
|
|
||||||
(template-level), tracked separately as **#622**.
|
|
||||||
- **Anonymized telemetry → mosaicstack.dev:** spend data is reported (anonymous) to the
|
|
||||||
mosaicstack.dev telemetry endpoint so other agents/fleets budget and optimize from real,
|
|
||||||
anonymized data. Product workstream, tracked separately as **#623**.
|
|
||||||
|
|
||||||
**User-settable budgets (the policy surface).** A tenant operator can set budgets for every
|
|
||||||
configured **provider** (per-provider ceilings), the **account-to-task mapping**, the **agentic
|
|
||||||
routing flow**, **concurrency** (the spend multiplier), and **hard API-token $-limits**. Budgets
|
|
||||||
are enforced at the orchestrator + routing boundary, not inside individual workers (a worker never
|
|
||||||
decides its own budget — see delegation discipline).
|
|
||||||
|
|
||||||
**Budget CLI UX (#558):** `mosaic budget set --reset-at` sets the window reset; reset-datetimes
|
|
||||||
carry **confidence tags** (`user` / `provider` / `estimated` / `unknown`); and **urgency/criticality
|
|
||||||
is a dispatch-gate modifier** — high-urgency work may override even-spread pacing **within
|
|
||||||
authorization**. (Also feeds the budgeting workstream, not only this doc.)
|
|
||||||
|
|
||||||
## Observation model
|
|
||||||
|
|
||||||
| Verb | Behavior |
|
|
||||||
| ----------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
||||||
| `mosaic fleet ps` | one table joining systemd + tmux + process + idle + last-heartbeat, with drift + boot-enable flags |
|
|
||||||
| `mosaic agent watch <name>` | **read-only** join (grouped session / `-r`), no resize tyranny, no keystrokes |
|
|
||||||
| `mosaic agent attach <name>` | explicit interactive takeover (the only path that can type) |
|
|
||||||
| `mosaic agent send <name> --verify` | confirms message **accepted**, not merely keystroke-injected |
|
|
||||||
|
|
||||||
> Why the current PoC blocks observation: sessions live on the isolated `mosaic-factory`
|
|
||||||
> socket (invisible to default `tmux ls`), the only sanctioned read is `capture-pane`
|
|
||||||
> (blank for full-screen TUIs), and `attach` is read-write + resizes the session. The
|
|
||||||
> verbs above restore "join and observe" safely.
|
|
||||||
|
|
||||||
## Control plane & central register
|
|
||||||
|
|
||||||
### Why the register must be Postgres
|
|
||||||
|
|
||||||
The fleet is multi-host (w-jarvis + dragon-lin + future). A SQLite file is a local
|
|
||||||
file — it is not a network service and cannot be shared across hosts. Beyond topology,
|
|
||||||
Postgres MVCC eliminates the concurrent-writer corruption class Hermes hit with SQLite
|
|
||||||
under multi-agent access.
|
|
||||||
|
|
||||||
Access is exclusively through the **gateway API** (`apps/gateway` — typed, auth-gated,
|
|
||||||
scoped tokens). No agent or dispatcher pane ever holds a raw DB credential; a
|
|
||||||
compromised pane cannot corrupt or exfiltrate the register.
|
|
||||||
|
|
||||||
### Architecture (layers)
|
|
||||||
|
|
||||||
| Layer | Responsibility | Implementation |
|
|
||||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **Register** | Source of truth: agents, missions, tasks, heartbeats, spend | Postgres `fleet` schema — existing stack instance (`@mosaicstack/db`) |
|
|
||||||
| **Access** | Typed, auth-gated API | Gateway `fleet/*` routes |
|
|
||||||
| **Dispatcher** | Brief classification, BOD review, planning/coding/review/test/deploy sequencing + gates → fleet task dispatch | **forge pipeline engine** (`runPipeline`/`resumePipeline`, brief classifier, BOD) **+ thin `forge-exec` adapter → `agent-send.sh`**; NOT a new daemon — forge is reused, only stage→agent dispatch is new |
|
|
||||||
| **Orchestrator (Mos)** | Goals, missions, judgment, user/PA interface | Context-light; sets intent → re-engages only for decisions |
|
|
||||||
|
|
||||||
### Dispatcher = forge (reuse, do not rebuild)
|
|
||||||
|
|
||||||
The dispatcher is **not new work**: it is `@mosaicstack/forge`, a fully-implemented
|
|
||||||
software-factory pipeline engine (brief → Board-of-Directors review → 3 planning stages →
|
|
||||||
coding → review/remediation → testing → deploy). Forge already provides
|
|
||||||
`runPipeline`/`resumePipeline`, a brief classifier, and a BOD persona loader, so the fleet
|
|
||||||
does **not** re-implement sequencing, gate logic, or brief classification. The only new
|
|
||||||
fleet-owned code is a thin **`forge-exec` TaskExecutor adapter** (`ForgeTask` →
|
|
||||||
`agent-send.sh` to a named agent) — forge's single missing piece — tracked as a Gitea
|
|
||||||
issue and built post-PoC. The Postgres register backs forge's pipeline state (durable
|
|
||||||
`resumePipeline`, cross-host) in addition to cross-project missions/tasks/Kanban. The
|
|
||||||
north-star **'board' role IS forge's Board-of-Directors** — reused from forge, not a new
|
|
||||||
role implementation.
|
|
||||||
|
|
||||||
### Docs as projections
|
|
||||||
|
|
||||||
`docs/TASKS.md` and `MISSION-MANIFEST.md` are **generated projections** of the DB,
|
|
||||||
not hand-maintained. The dispatcher (or a scheduled job) renders Markdown from
|
|
||||||
`fleet.*` tables and commits the output. DB is authoritative; docs are for human
|
|
||||||
reference.
|
|
||||||
|
|
||||||
### Spend
|
|
||||||
|
|
||||||
`fleet.spend_ledger` records projected and actual token spend per agent/mission/task
|
|
||||||
(ties to issue #622). The dispatcher enforces budget caps before dispatching. Mos reads
|
|
||||||
the roll-up via API — no raw DB access, no context-bloating dumps.
|
|
||||||
|
|
||||||
### Federation
|
|
||||||
|
|
||||||
Cross-host fleet state flows through federated gateway queries (existing
|
|
||||||
`federation_peers` / `federation_grants` machinery). This is the existing north-star
|
|
||||||
invariant: **control plane rides federation (W1), not a bespoke broker.** No new
|
|
||||||
broker introduced.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
This is Phase 4–5 of this roadmap, materialized. It MUST NOT block the PoC (which
|
|
||||||
runs correctly on files + `roster.yaml`). Begin when Phase 2 heartbeat protocol is
|
|
||||||
stable and concurrent-agent count makes file coordination the bottleneck.
|
|
||||||
|
|
||||||
### Open sub-decision
|
|
||||||
|
|
||||||
Dedicated Postgres **instance** vs. dedicated **schema** in the existing instance.
|
|
||||||
Recommendation: dedicated schema, existing instance (a migration file, not new infra);
|
|
||||||
re-evaluate if isolation or write-volume demands it.
|
|
||||||
|
|
||||||
## Phased roadmap
|
|
||||||
|
|
||||||
| Phase | Outcome | Status |
|
|
||||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
|
||||||
| 0–1 | tmux PoC, hardening, published CLI v0.0.34 (#565–#568) | ✅ done |
|
|
||||||
| **2 — Observability** | `fleet ps` (host+tenant aware join), heartbeat protocol + dogfood stub answers it, `agent watch` (read-only), `agent send --verify` receipts | ▶ now |
|
|
||||||
| 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: **orchestrator + enhancer**; ephemeral workers per lane) | planned |
|
|
||||||
| 4 — Unified definition | one agent schema in gateway; `mosaic agent --new` → materialized per-tenant session; uid-tenant provisioning; **`fleet` schema migration + `forge-exec` TaskExecutor adapter (forge → `agent-send.sh`)** | planned |
|
|
||||||
| 5 — Control plane | federation-backed cross-host × cross-tenant fleet view; **webUI** (surface chosen then) for MVP-X1 parity; **central register live (spend ledger, docs-as-projections, multi-host Kanban)** | planned |
|
|
||||||
|
|
||||||
## Decisions of record (2026-06-20, with Jason)
|
|
||||||
|
|
||||||
- Agent model: **config defines, session runs** (gateway = definition/identity/auth; tmux = runtime).
|
|
||||||
- Tenancy: **multi-tenant from the start**; isolation = **per-tenant Linux uid**.
|
|
||||||
- Health: **heartbeat required** (dogfood stub implements the protocol now).
|
|
||||||
- Lifecycle: **hybrid** — core always-on + ephemeral workers per lane.
|
|
||||||
- Observation: **read-only default, opt-in takeover**.
|
|
||||||
- Multi-host: **designed-for from day one**; control plane **rides federation (W1)**.
|
|
||||||
- Delivery: **CLI-first now**, dogfood against the live stub fleet; webUI deferred to Phase 5.
|
|
||||||
- Runtimes: fleet agents default to **Codex / pi-on-Codex**; **Claude is reserved for Claude
|
|
||||||
Code only** (avoid alternate-harness API pricing). Validated durable recipe:
|
|
||||||
`mosaic yolo pi --model openai-codex/gpt-5.5:high`. Durable detached launch requires the
|
|
||||||
runtime-bin on PATH (baked into the pane command) + boot-survival (`enable` + linger),
|
|
||||||
which `fleet init` should automate.
|
|
||||||
|
|
||||||
## Decisions of record (2026-06-22, with Jason)
|
|
||||||
|
|
||||||
- **Two-agent floor:** every fleet has, at minimum, an **orchestrator** and an **enhancer**.
|
|
||||||
The orchestrator is the user's point of contact and composes the fleet; the enhancer runs the
|
|
||||||
continuous-improvement loop (monitor → analyze → remediate → upgrade tools/skills/harness →
|
|
||||||
file Mosaic Stack bug reports) and **does not code or review**.
|
|
||||||
- **Role library:** orchestrator, enhancer, coder, code review, security review, research,
|
|
||||||
board (moonshot/contrarian/technical/business/financial), operations — extensible; the
|
|
||||||
orchestrator (advised by the enhancer) adds roles as missions demand.
|
|
||||||
- **Orchestrator chat connector:** the orchestrator is reachable over a user-chosen connector
|
|
||||||
(tmux now; Telegram/Discord/Matrix/Slack configurable). Validated live: **"Mos" orchestrator
|
|
||||||
on Discord** via the Claude Code discord channel plugin (w-jarvis).
|
|
||||||
- **Session context cap = 200k tokens (GLOBAL to all Claude sessions):** Claude Code sessions are
|
|
||||||
capped at a **max 200k-token context window**. Long-running sessions extended toward 1M tokens
|
|
||||||
have proven **worse in practice** (degraded steering, off-plan divergence); 200k is the standard.
|
|
||||||
**Enforcement split:** the _window_ lives in **`~/.claude/settings.json`** (host-global) as
|
|
||||||
`"autoCompactWindow": 200000` + `"autoCompactEnabled": true`; the _1M-disable_ lives in **launch
|
|
||||||
ENV** (`CLAUDE_CODE_DISABLE_1M_CONTEXT=1`, plus `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000`) wherever
|
|
||||||
a `[1m]` model can be selected (`mos-claude.service` + the fleet Claude launcher), so every Claude
|
|
||||||
agent is capped at spawn. (settings = window; env = 1M-disable.)
|
|
||||||
- **Worker context bound (#8):** workers are kept context-bounded via the **ephemeral-per-lane
|
|
||||||
lifecycle + native compaction**, not via the 200k knob. The explicit `autoCompactWindow` 200k knob
|
|
||||||
**stays Claude-specific** — the _principle_ (bounded context) extends to workers, the _knob_ does not.
|
|
||||||
- **Orchestrator delegation discipline:** the orchestrator **delegates all delivery work** to
|
|
||||||
subagents / workflows / ultracode / coder agents and confines its own context to \*\*orchestration
|
|
||||||
- the personal-assistant lane\*\*. Keeping delivery out of the orchestrator's window keeps its
|
|
||||||
context unpolluted and measurably reduces off-plan divergence. The orchestrator coordinates and
|
|
||||||
decides; it does not implement.
|
|
||||||
- **Budget governance is fleet doctrine:** token/API-dollar budgeting is a first-class fleet concern
|
|
||||||
(see "Budget & token governance"). OAuth-sub usage-vs-limit feedback is ingested per account, spend
|
|
||||||
is **auto-paced EVEN-SPREAD over remaining time** (rapid/overspend only on explicit authorization),
|
|
||||||
spend is **tracked historically** to self-correct per-task/daily estimates, multi-sub tenants may
|
|
||||||
**auto-route by available usage**, and operators set budgets per provider, per account-to-task
|
|
||||||
mapping, per routing flow, per concurrency level, and as hard API-$ ceilings.
|
|
||||||
- **Spend accounting is a Mosaic Stack process mandate:** PRDs, missions, and task decomposition
|
|
||||||
**MUST carry projected + actual token spend**; used locally for pacing and reported as **anonymized
|
|
||||||
telemetry to mosaicstack.dev**. The template standard (#622) and telemetry product (#623) are
|
|
||||||
tracked separately.
|
|
||||||
- **Unified identity = "Fleet" (Jason, 2026-06-22):** the product is **Mosaic Fleet** — one unified
|
|
||||||
user-facing identity and CLI surface. **forge** is the Fleet's **internal** delivery/orchestration
|
|
||||||
engine (not a separate product); the control-plane **Postgres register is the Fleet's register**;
|
|
||||||
workers/runtime are the **Fleet substrate**. **"factory" is RETIRED as a product term** — it was
|
|
||||||
only ever the software-factory concept (which forge implements) and the old `mosaic-factory` tmux
|
|
||||||
socket name. The production-isolation socket is now **`mosaic-fleet`** (matches the product brand);
|
|
||||||
the legacy dogfood canary remains on the old `mosaic-factory` socket pending migration. **Code stays
|
|
||||||
layered** (forge + fleet + control-plane as internal layers);
|
|
||||||
only the **identity + CLI surface unify under Fleet.**
|
|
||||||
- **Role-based session naming (Jason, 2026-06-22):** agent tmux sessions are named by **role**
|
|
||||||
(`orchestrator`, `enhancer`, `research`, `coder0-0`, …), not by persona. **Persona lives in
|
|
||||||
`SOUL.md`**; the front-end / Discord presents a **friendly alias** (e.g. "Mos" = the orchestrator's
|
|
||||||
alias). The session name is the stable addressing handle; the alias is presentation.
|
|
||||||
|
|
||||||
### Control plane & central register
|
|
||||||
|
|
||||||
- **Store:** Postgres (existing stack instance, dedicated `fleet` schema via `@mosaicstack/db`). SQLite rejected: (1) it is a local file — structurally incompatible with a multi-host fleet; (2) concurrent multi-agent writes caused repeated corruption in Hermes. "SQLite + access service" rejected as reinventing a DB server badly; "LLM agent gating DB access" rejected as slow, expensive, and a single point of failure.
|
|
||||||
- **Access:** gateway API only (`apps/gateway`, `fleet/*` routes). No raw DB credentials in any agent/dispatcher pane — directly mitigates the tmux attack-surface concern.
|
|
||||||
- **Dispatcher = forge (reuse, not a new build):** the dispatcher IS `@mosaicstack/forge`'s pipeline engine (`runPipeline`/`resumePipeline` + brief classifier + BOD persona loader), a fully-implemented software-factory pipeline (brief → BOD review → 3 planning stages → coding → review/remediation → testing → deploy). We do **not** design/build a new dispatcher and do **not** re-implement sequencing, gate logic, or brief classification. The only new fleet-owned piece is a thin **`forge-exec` TaskExecutor adapter** (suggested package `packages/forge-exec`) mapping a `ForgeTask` → `agent-send.sh` dispatch to a named fleet agent — forge's single missing piece. It is tracked as a Gitea issue and built **post-PoC** (not now).
|
|
||||||
- **Register backs forge:** the Postgres `fleet` register is genuinely new (neither forge nor the fleet has cross-project state). It BACKS forge's pipeline state (durable `resumePipeline`, cross-host) plus cross-project missions/tasks/Kanban.
|
|
||||||
- **'board' role = forge BOD:** the north-star role-library 'board' role IS forge's Board-of-Directors — reused, not reinvented.
|
|
||||||
- **Orchestration vs. dispatch:** Orchestrator (Mos) sets intent and handles judgment; forge works the mechanical pipeline (sequencing, gates, status transitions, spend ledger). LLM escalation reserved for judgment: mission decomposition, re-planning on failure.
|
|
||||||
- **Spend in the register:** `fleet.spend_ledger` tracks projected vs. actual tokens per agent/mission/task; ties to issue #622.
|
|
||||||
- **Docs as projections:** `docs/TASKS.md` and `MISSION-MANIFEST.md` become generated exports of the DB, not hand-maintained.
|
|
||||||
- **Sub-decision pending:** dedicated schema in existing PG instance (recommended) vs. dedicated PG instance. Revisit if isolation or write-volume demands it.
|
|
||||||
|
|
||||||
## Decisions of record (2026-06-24, with Jason)
|
|
||||||
|
|
||||||
- **Per-agent model switch (operator-configurable, NOT a global lock):** model selection is
|
|
||||||
**per-agent**, never a host-global pin. Claude sessions MUST NOT be locked to a single model in
|
|
||||||
`~/.claude/settings.json`; each agent chooses its model independently. The plumbing already exists —
|
|
||||||
roster `model_hint` → `MOSAIC_AGENT_MODEL` → `start-agent-session.sh` appends `--model <hint>` to that
|
|
||||||
agent's harness (claude or pi); settable today via `mosaic fleet add|edit <agent> --model <hint>`.
|
|
||||||
**North-star target:** surface this as a **per-agent model switch in the webUI** (with CLI/TUI parity
|
|
||||||
per MVP-X1) — read the roster, expose a per-agent model dropdown, write `model_hint` back, and restart
|
|
||||||
that one agent to apply. Unset = inherit the harness default. This **composes with** the budget
|
|
||||||
downgrade ladder (opus → sonnet → haiku, then Claude → Codex): the operator sets the per-agent model
|
|
||||||
_intent/ceiling_; budget pacing may downgrade within policy. Tracked as a Fleet `TASKS.md` entry under
|
|
||||||
the Phase-5 webUI surface.
|
|
||||||
- **Orchestrator runtime (confirmed live):** the **orchestrator and enhancer run Claude Opus 4.8 in the
|
|
||||||
Claude Code harness**; only workers (coder/reviewer) run pi/gpt-5.5. Consistent with the 2026-06-20
|
|
||||||
"Claude reserved for Claude Code only" decision (the orchestrator runs _in_ Claude Code, not an
|
|
||||||
alternate Claude harness). Pi/gpt-5.5 as the orchestrator is permitted **only if proven** at least as
|
|
||||||
satisfactory; absent that proof, the orchestrator stays on Claude Opus 4.8.
|
|
||||||
|
|
||||||
## Future enhancements (north-star, post-MVP — not on the MVP track)
|
|
||||||
|
|
||||||
- **Mosaic Claude Discord Plugin** — a first-party Mosaic Discord connector that properly
|
|
||||||
implements the basic Discord functions **and native Discord threads**. Threads let a user
|
|
||||||
separate conversation topics with the orchestrator (the pattern proven by the Hermes agent).
|
|
||||||
A major enhancement over the current third-party channel plugin; **not required for the MVP**,
|
|
||||||
but a committed north-star target. `ASSUMPTION:` ships as a Mosaic-owned plugin so the fleet
|
|
||||||
controls Discord UX (threads, reactions, attachments, per-thread context) end-to-end.
|
|
||||||
- **Matrix on a local homeserver — strategic future transport.** **F4 (in progress) IS the Matrix
|
|
||||||
connector**: an orchestrator chat connector speaking the Matrix client-server API against a
|
|
||||||
self-hosted homeserver (Conduit default, Synapse alt). Matrix is named here as the strategic
|
|
||||||
future transport — peer to tmux/Discord, not superseded by them.
|
|
||||||
- **tmux fleet attack-surface hardening.** Many always-on tmux sessions are an attack surface;
|
|
||||||
`tmux send-keys` / socket access could enable malicious action against agents directly.
|
|
||||||
Mitigations to build toward: socket ownership/perms, per-tenant socket isolation (already an
|
|
||||||
invariant), authenticated `agent-send`, and an audit of who can write to any pane. **Post-MVP
|
|
||||||
unless a P0 surfaces.** The control-plane register reinforces this (gateway-API access = no raw
|
|
||||||
DB creds in panes). A not-started risk-assessment + mitigation-plan task rides the Fleet `TASKS.md`.
|
|
||||||
|
|
||||||
## Assumptions (veto-able)
|
|
||||||
|
|
||||||
- `ASSUMPTION:` first-class runtimes = claude, codex, pi, opencode; a "role" (analyst,
|
|
||||||
finance, researcher) = persona + skills + tools on top of a runtime, shipped as a
|
|
||||||
starter role library in the framework.
|
|
||||||
- `ASSUMPTION:` the cross-host control plane is the **federation** layer (W1), not a
|
|
||||||
separate `fleetd` daemon.
|
|
||||||
- `ASSUMPTION:` Fleet is workstream **W-FLEET** under `mvp-20260312`; a rollup row in
|
|
||||||
`docs/TASKS.md` and a workstream declaration in `MISSION-MANIFEST.md` are proposed to
|
|
||||||
the MVP orchestrator, not written by this workstream.
|
|
||||||
- `ASSUMPTION:` OAuth-subscription runtimes (Claude sub, Codex sub) expose a machine-readable
|
|
||||||
current-usage-vs-limit signal the fleet can poll/ingest; if a provider exposes no such signal,
|
|
||||||
that provider's accounts fall back to API-style hard-ceiling budgeting only (no auto-pacing).
|
|
||||||
- `ASSUMPTION:` budget policy lives at the orchestrator + routing layer and is surfaced through the
|
|
||||||
same CLI→TUI→webUI parity (MVP-X1) as the rest of fleet state — not a separate budgeting daemon.
|
|
||||||
- `ASSUMPTION:` the 200k session cap is enforced by Claude Code settings/env composition (model
|
|
||||||
variant + `autoCompactWindow`), not by a Mosaic wrapper; a wrapper is the fallback only if the
|
|
||||||
harness later removes those knobs.
|
|
||||||
- `ASSUMPTION:` The central register (Postgres `fleet` schema + gateway API + forge as dispatcher) is
|
|
||||||
the Phase 4–5 control plane, begun after Phase 2 observability is proven. It is a dedicated
|
|
||||||
**W-FLEET** sub-workstream entry, not a separate mission. The dispatcher is `@mosaicstack/forge`
|
|
||||||
(reused, not a new daemon); the only new fleet-owned code is the thin **`forge-exec` TaskExecutor
|
|
||||||
adapter** (suggested package `packages/forge-exec`, `ForgeTask` → `agent-send.sh`), tracked as a
|
|
||||||
Gitea issue and built post-PoC.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> **Release procedure (drift re-capture, 2026-06-22):** `mosaic update` only propagates new fleet
|
|
||||||
> commands when the **CLI version is bumped** — without a version bump, fleet command changes never
|
|
||||||
> reach installed hosts. The release/version-bump procedure (bump → publish → `mosaic update`
|
|
||||||
> [→ `--relaunch`]) must be documented so fleet changes actually land. (Also feeds the budgeting
|
|
||||||
> workstream.)
|
|
||||||
>
|
|
||||||
> **Tracked separately (not in scope for this doc PR):** **#622** PRD/mission/task projected+actual
|
|
||||||
> spend template standard · **#623** anonymized spend telemetry → mosaicstack.dev (product) ·
|
|
||||||
> **#625** `tenant_id` roster-schema field (multi-tenant; invariant #1 home) · **#628** `forge-exec`
|
|
||||||
> TaskExecutor adapter (post-PoC). This PR records **doctrine only** — no implementation.
|
|
||||||
@@ -293,28 +293,13 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
|
|||||||
|
|
||||||
### Plugins
|
### Plugins
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | -------------------------------------------------------------------------- |
|
||||||
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
|
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
|
||||||
| `DISCORD_SERVICE_TOKEN` | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only |
|
| `DISCORD_GUILD_ID` | Discord guild/server ID |
|
||||||
| `DISCORD_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata |
|
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) |
|
||||||
| `DISCORD_GUILD_ID` | Discord guild/server ID |
|
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
|
||||||
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) |
|
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
|
||||||
| `DISCORD_ALLOWED_GUILD_IDS` | Required comma-separated Discord guild snowflake allowlist; default-deny |
|
|
||||||
| `DISCORD_ALLOWED_CHANNEL_IDS` | Required comma-separated Discord channel snowflake allowlist; default-deny |
|
|
||||||
| `DISCORD_ALLOWED_USER_IDS` | Required comma-separated Discord user snowflake allowlist; default-deny |
|
|
||||||
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
|
|
||||||
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
|
|
||||||
|
|
||||||
### Discord ingress security
|
|
||||||
|
|
||||||
When `DISCORD_BOT_TOKEN` is configured, `DISCORD_SERVICE_TOKEN`, `DISCORD_SERVICE_USER_ID`, and all three Discord allowlists are required. Gateway startup fails rather than enabling a broad or unauthenticated remote-control surface. The service user ID identifies a provisioned Mosaic service principal for persistence; the original Discord user ID is retained in ingress audit metadata. The service token is a secret supplied by the approved runtime secret mechanism and is never committed or logged.
|
|
||||||
|
|
||||||
Inbound Discord messages must originate from an allowed guild, channel, and user, mention the bot, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. The gateway validates the service identity, envelope signature, and allowlists again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state.
|
|
||||||
|
|
||||||
### Session retention and garbage collection
|
|
||||||
|
|
||||||
Session cleanup is scoped to one session identifier and only removes that session's Valkey keys and demotes that session's hot logs. Gateway startup and scheduled jobs do not perform global session cleanup; startup removes legacy repeatable `session-gc` schedules created by older deployments. The `/gc` command is intentionally disabled until a distinct global-retention job supplies explicit authorization and audit evidence. This prevents one tenant or session's cleanup from changing another's retained data.
|
|
||||||
|
|
||||||
### Observability
|
### Observability
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Local Fleet Canary
|
# Local Fleet Canary
|
||||||
|
|
||||||
The local fleet canary runs a small tmux-backed Mosaic agent fleet on an
|
The local fleet canary runs a small tmux-backed Mosaic agent fleet on an
|
||||||
isolated tmux socket. The default socket is `mosaic-fleet`; the commands do
|
isolated tmux socket. The default socket is `mosaic-factory`; the commands do
|
||||||
not use or stop the default tmux server.
|
not use or stop the default tmux server.
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
@@ -67,7 +67,7 @@ mosaic agent tail canary-pi -n 80
|
|||||||
|
|
||||||
These commands read the roster and target the configured tmux socket. The
|
These commands read the roster and target the configured tmux socket. The
|
||||||
generated systemd agent services use `start-agent-session.sh`; message delivery
|
generated systemd agent services use `start-agent-session.sh`; message delivery
|
||||||
uses the tmux send tools with `-L mosaic-fleet`.
|
uses the tmux send tools with `-L mosaic-factory`.
|
||||||
|
|
||||||
`mosaic agent send` is operator-origin traffic unless a caller explicitly says
|
`mosaic agent send` is operator-origin traffic unless a caller explicitly says
|
||||||
otherwise. The CLI always passes a deterministic source label to
|
otherwise. The CLI always passes a deterministic source label to
|
||||||
@@ -82,7 +82,7 @@ impersonating a known handoff lane. The lower-level inter-agent wrapper
|
|||||||
Use these checks before expanding the roster:
|
Use these checks before expanding the roster:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tmux -L mosaic-fleet ls
|
tmux -L mosaic-factory ls
|
||||||
tmux ls
|
tmux ls
|
||||||
mosaic fleet verify
|
mosaic fleet verify
|
||||||
systemctl --user status mosaic-tmux-holder.service
|
systemctl --user status mosaic-tmux-holder.service
|
||||||
@@ -90,7 +90,7 @@ systemctl --user status mosaic-tmux-holder.service
|
|||||||
|
|
||||||
Expected results:
|
Expected results:
|
||||||
|
|
||||||
- `tmux -L mosaic-fleet ls` shows `_holder` and roster agent sessions.
|
- `tmux -L mosaic-factory ls` shows `_holder` and roster agent sessions.
|
||||||
- `tmux ls` shows only the default tmux server sessions and is not changed by
|
- `tmux ls` shows only the default tmux server sessions and is not changed by
|
||||||
fleet start/stop operations.
|
fleet start/stop operations.
|
||||||
- `mosaic fleet verify` checks exact session targets on the isolated socket.
|
- `mosaic fleet verify` checks exact session targets on the isolated socket.
|
||||||
@@ -108,7 +108,7 @@ Run this checklist before cutting or dogfooding a fleet release:
|
|||||||
repeated `start` against the named socket; verify the default tmux server is
|
repeated `start` against the named socket; verify the default tmux server is
|
||||||
unchanged.
|
unchanged.
|
||||||
- Liveness verification: run `mosaic fleet verify` and confirm roster sessions
|
- Liveness verification: run `mosaic fleet verify` and confirm roster sessions
|
||||||
with `tmux -L mosaic-fleet ls` or exact `has-session` checks.
|
with `tmux -L mosaic-factory ls` or exact `has-session` checks.
|
||||||
- Package dry-run: run `npm pack --dry-run --json` from `packages/mosaic` and
|
- Package dry-run: run `npm pack --dry-run --json` from `packages/mosaic` and
|
||||||
confirm `framework/fleet`, `framework/systemd/user`,
|
confirm `framework/fleet`, `framework/systemd/user`,
|
||||||
`framework/tools/fleet`, and `framework/tools/tmux` assets are included.
|
`framework/tools/fleet`, and `framework/tools/tmux` assets are included.
|
||||||
@@ -140,5 +140,5 @@ This rollback leaves the default tmux server untouched. If a canary session is
|
|||||||
still present after service stop, remove only the isolated socket server:
|
still present after service stop, remove only the isolated socket server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tmux -L mosaic-fleet kill-server
|
tmux -L mosaic-factory kill-server
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Implement enough product surface to use the fleet locally:
|
|||||||
- roster schema and examples
|
- roster schema and examples
|
||||||
- local canary docs and rollback instructions
|
- local canary docs and rollback instructions
|
||||||
- tests for CLI behavior where practical
|
- tests for CLI behavior where practical
|
||||||
- canary verification on named tmux socket `mosaic-fleet`
|
- canary verification on named tmux socket `mosaic-factory`
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ Implement enough product surface to use the fleet locally:
|
|||||||
|
|
||||||
- CLI can initialize a minimal roster outside product defaults.
|
- CLI can initialize a minimal roster outside product defaults.
|
||||||
- CLI can install user systemd units and fleet helper scripts to a configurable Mosaic home.
|
- CLI can install user systemd units and fleet helper scripts to a configurable Mosaic home.
|
||||||
- CLI can start/stop/status/verify a canary fleet using `mosaic-fleet`.
|
- CLI can start/stop/status/verify a canary fleet using `mosaic-factory`.
|
||||||
- `mosaic agent send` uses existing named-socket/exact-target tmux tooling.
|
- `mosaic agent send` uses existing named-socket/exact-target tmux tooling.
|
||||||
- `mosaic agent reset` targets only the named agent session on the named socket.
|
- `mosaic agent reset` targets only the named agent session on the named socket.
|
||||||
- Verification proves default tmux sessions remain untouched.
|
- Verification proves default tmux sessions remain untouched.
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
# Scratchpad — FED-M3-04 Scope Service
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement `apps/gateway/src/federation/server/scope.service.ts` for the M3 inbound federation scope-enforcement pipeline.
|
|
||||||
|
|
||||||
## Scope / Constraints
|
|
||||||
|
|
||||||
- Task: FED-M3-04, issue #462.
|
|
||||||
- Branch: `feat/federation-m3-scope-service` from `origin/main` @ 0.0.48.
|
|
||||||
- Pure service: no direct DB access; native RBAC/data access is injected per evaluation call.
|
|
||||||
- Reuse `parseFederationScope` from M2-03.
|
|
||||||
- Workers do not edit `docs/federation/TASKS.md` per repo AGENTS.md.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
1. Resource allowlist and `excluded_resources` enforced.
|
|
||||||
2. Native RBAC evaluated as `subjectUserId` through an injected evaluator.
|
|
||||||
3. Scope filter intersection supports `include_teams` and `include_personal` without widening native RBAC.
|
|
||||||
4. `max_rows_per_query` caps requested limits.
|
|
||||||
5. Service returns `{ allowed: true, filter }` or a structured deny reason usable by M4 audit.
|
|
||||||
6. Unit tests cover every deny path.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Inspect existing federation scope/schema/auth guard contracts.
|
|
||||||
2. Add pure `FederationScopeService` plus typed result/filter/deny interfaces.
|
|
||||||
3. Add focused unit tests for happy paths, filter intersection, row cap, and deny paths.
|
|
||||||
4. Export/register service for future verb controllers.
|
|
||||||
5. Run situational tests, baseline gates, code review, then PR.
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
- Provided model tier: sonnet.
|
|
||||||
- Estimate from task row: 10K tokens.
|
|
||||||
- Working cap assumption: keep implementation focused to FED-M3-04 surfaces only.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- Intake complete; dirty base worktree avoided by creating isolated worktree at `/home/jarvis/src/mosaic-mono-v1-fed-m3-04`.
|
|
||||||
- Project PRD and federation task spec reviewed.
|
|
||||||
- Added `FederationScopeService` with structured allow/deny result types and injected native RBAC evaluator contract.
|
|
||||||
- Added unit coverage for happy path, row cap, filter intersection, and every deny path.
|
|
||||||
- Exported/registered the service for upcoming M3 verb controllers.
|
|
||||||
|
|
||||||
## Verification Evidence
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/__tests__/scope.service.spec.ts` — pass (10 tests before review update; 11 tests after adding include_personal no-leak coverage).
|
|
||||||
- `pnpm build` — pass (23 successful tasks).
|
|
||||||
- `pnpm typecheck` — pass (41 successful tasks; re-run after review update).
|
|
||||||
- `pnpm lint` — pass (23 successful tasks; re-run after review update).
|
|
||||||
- `pnpm format:check` — pass (re-run after review update).
|
|
||||||
- `pnpm test` — pass after starting local `postgres`/`valkey` and running `pnpm --filter @mosaicstack/db db:push` for the DB-backed cross-user isolation suite (41 successful tasks; gateway 477 passed / 11 skipped).
|
|
||||||
- Code review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings.
|
|
||||||
- Security review: `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — risk none, 0 findings.
|
|
||||||
|
|
||||||
## Risks / Blockers
|
|
||||||
|
|
||||||
- Issue #462 is already closed in provider output; likely milestone tracking mismatch. Will still reference #462 in PR body unless orchestrator redirects.
|
|
||||||
- Local full-test setup required `docker compose up -d postgres valkey` + `db:push`; containers were stopped with `docker compose down` after verification.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Issue #561 — Bare python on agent hosts
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Make the durable bootstrap/provisioning guidance ensure agent hosts provide a bare `python` command that resolves to Python 3.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Add Debian/Ubuntu `python-is-python3` to agent-host prerequisites in bootstrap docs.
|
|
||||||
- Check for actual OS package provisioning scripts and update only if an existing agent-host package install path exists.
|
|
||||||
- Do not touch live host state.
|
|
||||||
- Do not update `docs/TASKS.md`; repo guidance says workers read it but never modify it.
|
|
||||||
|
|
||||||
## Recon
|
|
||||||
|
|
||||||
- Issue #561 confirms repeated `python: command not found` failures from fleet agents that emit `python foo.py`.
|
|
||||||
- `guides/BOOTSTRAP.md` and `packages/mosaic/framework/guides/BOOTSTRAP.md` are the source and packaged framework copies of the bootstrap guide.
|
|
||||||
- Targeted repo sweep found no agent-host Debian package provisioning script. Existing `apt-get install` hits are CI/test helper paths or unrelated deployment docs.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Add a host prerequisite section to both bootstrap guide copies.
|
|
||||||
2. Include `python-is-python3` in the Debian/Ubuntu package list with an issue comment.
|
|
||||||
3. Note the non-Debian equivalent as a `/usr/bin/python -> python3` symlink.
|
|
||||||
4. Validate markdown/diff, run shell syntax checks where applicable, run required review, commit, queue guard, and push.
|
|
||||||
|
|
||||||
## Validation Log
|
|
||||||
|
|
||||||
- `rg` recon: no existing agent-host Debian package provisioning script; only CI/test helper `apt-get install` paths and unrelated deployment docs.
|
|
||||||
- `git diff --check`: passed.
|
|
||||||
- `bash -n packages/mosaic/framework/install.sh tools/install.sh packages/mosaic/framework/tools/bootstrap/init-project.sh packages/mosaic/framework/tools/_scripts/mosaic-bootstrap-repo`: passed. No touched shell scripts.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`: approved, 0 findings.
|
|
||||||
- `pnpm format:check`: initially blocked because `node_modules` was absent and `prettier` was unavailable; `pnpm install --frozen-lockfile` initially hit an invalid `/root` pnpm store path. Reran install with `--store-dir /home/hermes/agent-work/.pnpm-store`, then `pnpm format:check` passed.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# #631 — re-seed must preserve user fleet data (CRITICAL data-loss)
|
|
||||||
|
|
||||||
- **Issue:** #631 · **Branch:** `fix/631-reseed-preserves-fleet-data`
|
|
||||||
|
|
||||||
## Root cause
|
|
||||||
|
|
||||||
`mosaic update` auto-runs `install.sh` keep-mode sync (#610). install.sh's rsync `--delete` (keep mode)
|
|
||||||
honored PRESERVE_PATHS, but `fleet/` wasn't listed → the sync WIPED `~/.config/mosaic/fleet/roster.yaml`
|
|
||||||
(+ run/, agents/). Any user running `mosaic update` lost their roster. (overwrite mode wipes by design;
|
|
||||||
the live loss was keep mode.)
|
|
||||||
|
|
||||||
## Fix (PRIMARY)
|
|
||||||
|
|
||||||
- install.sh PRESERVE_PATHS += `fleet/*.yaml`, `fleet/agents`, `fleet/run` — the framework still SEEDS
|
|
||||||
fleet/examples + fleet/roles + fleet/roster.schema.json (synced), but user files survive.
|
|
||||||
- Made the cp-fallback (no-rsync) GLOB-AWARE so `fleet/*.yaml` preserves every user roster there too;
|
|
||||||
fixed the restore to re-glob per-pattern (so only the user file is restored, not the whole fleet/ dir).
|
|
||||||
- file-adapter.ts (TS installer): mirrored the preserve list for parity. (TS syncDirectory is copy-only,
|
|
||||||
never --delete, so it never had the bug — belt-and-suspenders + parity.)
|
|
||||||
|
|
||||||
## Fix (SECONDARY)
|
|
||||||
|
|
||||||
- `refreshActiveFleetUnits()` (update-checker.ts): the re-seed updates ~/.config/mosaic/systemd/user but
|
|
||||||
systemd runs ~/.config/systemd/user, so unit fixes (#627) didn't take effect. After the re-seed,
|
|
||||||
`mosaic update` now copies the fresh mosaic-\*.service → the active dir + daemon-reload (best-effort,
|
|
||||||
only when a fleet is already installed). Wired into the cli.ts update flow.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- bash F6 fixture (6 checks: roster/custom-yaml/agents/run survive + examples refreshed + schema seeded);
|
|
||||||
20/20 migration matrix green. TS file-adapter test (roster/run/agents survive keep sync). 2 unit tests
|
|
||||||
for refreshActiveFleetUnits. tsc/eslint/prettier/sanitize clean.
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# #633 — comms-block emitter + FLEET-LAUNCH runbook
|
|
||||||
|
|
||||||
Branch: `feat/633-comms-block-runbook` (off `bf2a6745`, post-#632 merge)
|
|
||||||
Issue: #633 · Follow-up filed: #636 (PATH B)
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
PATH A of the orchestrator-launch fix: give every launch path the Fleet-Comms onboarding, and
|
|
||||||
document the canonical roster-driven launcher so the orchestrator stops being a bespoke snowflake.
|
|
||||||
|
|
||||||
## Deliverables
|
|
||||||
|
|
||||||
1. **`mosaic fleet comms-block <role> [--host <h>]`** — explicit-arg, comms-block-only emitter.
|
|
||||||
- Backed by new `resolveCommsBlock(mosaicHome, role, fleetHost?)` in `fleet/comms-onboarding.ts`
|
|
||||||
returning `{ ok, output, error }`.
|
|
||||||
- Unlike `readFleetCommsBlock` (returns `''` on any miss so `composeContract` can no-op silently
|
|
||||||
during launch), the emitter **fails loud**: unknown role / missing roster → `ok:false` → CLI
|
|
||||||
prints to stderr + sets `process.exitCode = 1`. A typo is never a silent no-op.
|
|
||||||
- Distinct from `mosaic compose-contract <runtime>` (whole prompt, env-coupled via
|
|
||||||
`MOSAIC_AGENT_NAME`); comms-block is the targeted, explicit-arg, comms-only view.
|
|
||||||
2. **`docs/fleet/FLEET-LAUNCH.md`** — worker path + orchestrator `.env` fold + 3 launch gotchas +
|
|
||||||
#632 preserve note + North-Star 4-field arc.
|
|
||||||
|
|
||||||
## Key findings (drove the design)
|
|
||||||
|
|
||||||
- `mosaic yolo claude` **already** forwards `--channels`/`--permission-mode` to the binary
|
|
||||||
(`launch.ts` claude case `cliArgs.push(...args)`) AND injects the comms block via
|
|
||||||
`composeContract` → `readFleetCommsBlock(home, env.MOSAIC_AGENT_NAME)`. So no `launch.ts` change
|
|
||||||
was needed — PATH A is `.env` + doc only.
|
|
||||||
- `start-agent-session.sh` line ~41 `[ -z "$MOSAIC_AGENT_COMMAND" ]` short-circuits the line-44
|
|
||||||
default, so an `.env` `MOSAIC_AGENT_COMMAND` override bypasses the hardcoded `yolo` entirely — the
|
|
||||||
yolo-conditional is therefore a PATH B (default-path) concern, not PATH A.
|
|
||||||
- `generateAgentEnv` (`fleet.ts` ~202-207) emits NAME/RUNTIME/MODEL but **not** `MOSAIC_AGENT_COMMAND`
|
|
||||||
— the seam PATH B (#636) closes.
|
|
||||||
|
|
||||||
## A → B → webUI arc (North Star)
|
|
||||||
|
|
||||||
- A = `.env` `MOSAIC_AGENT_COMMAND` hatch (manual, ships now, #632-safe).
|
|
||||||
- B (#636) = roster-native launch-config: harness ✅ + model ✅ already there; add **yolo** (line-44
|
|
||||||
conditional `MOSAIC_AGENT_YOLO`) + **command/channels** (`generateAgentEnv` emission).
|
|
||||||
- webUI binds dropdowns/toggles to those four roster fields. One launcher, no new launch path.
|
|
||||||
|
|
||||||
## Results
|
|
||||||
|
|
||||||
- TDD: spec first (`comms-onboarding.spec.ts`, 6 new `resolveCommsBlock` cases) → red → implement → green.
|
|
||||||
- `fleet.spec.ts` subcommand-list assertion extended with `comms-block`.
|
|
||||||
- 177 fleet+comms tests green; typecheck clean; eslint clean; prettier clean.
|
|
||||||
|
|
||||||
## Risks / notes
|
|
||||||
|
|
||||||
- Pre-existing local-only failure `uninstall.spec.ts > removeFramework > handles missing mosaicHome
|
|
||||||
gracefully` (EACCES on `/nonexistent` as non-root) — unrelated to #633, passes in CI as root.
|
|
||||||
- Did NOT run `mosaic update` / anything auto-reseed: installed CLI still 0.0.40 (roster-wipe live
|
|
||||||
until mos-claude-0 ships 0.0.41). All work is in-repo + vitest, never touches the live mosaic home.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Scratchpad — fleet-personas spec timeout
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Raise the `@mosaicstack/mosaic` Vitest timeout to 30s at config level so filesystem-backed fleet drift-guard specs (`fleet-personas`, `fleet-profiles`, and siblings) stop false-reding under contended CI.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Move timeout policy into `packages/mosaic/vitest.config.ts` with `testTimeout: 30_000`.
|
|
||||||
2. Remove the narrower `fleet-personas.spec.ts` local override so PR #677 fixes the suite class, not one file.
|
|
||||||
3. Run targeted fleet specs plus typecheck/lint/format gates.
|
|
||||||
4. Commit, queue guard, push, PR update.
|
|
||||||
|
|
||||||
## Evidence
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet-personas.spec.ts` — pass (8 tests; initial narrow fix).
|
|
||||||
- `pnpm typecheck` — pass (41 tasks; initial narrow fix).
|
|
||||||
- `pnpm lint` — pass (23 tasks; initial narrow fix).
|
|
||||||
- `pnpm format:check` — pass after formatting this scratchpad (initial narrow fix).
|
|
||||||
- Package-wide timeout follow-up:
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet-personas.spec.ts src/commands/fleet-profiles.spec.ts` — pass (24 tests).
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic test` — pass (44 files / 618 tests).
|
|
||||||
- `pnpm typecheck` — pass (41 tasks).
|
|
||||||
- `pnpm lint` — pass (23 tasks).
|
|
||||||
- `pnpm format:check` — pass.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# #703 Git Wrapper Interactive and Auth Resilience
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Restore the deployed Git wrapper contract: issue-create supports interactive invocation and Gitea mutation behavior tolerates a stale Tea authenticated user by validating current identity and using the existing host-scoped API fallback.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- `packages/mosaic/framework/tools/git/issue-create.sh`
|
|
||||||
- `packages/mosaic/framework/tools/git/detect-platform.sh`
|
|
||||||
- Git wrapper regression harnesses
|
|
||||||
- This scratchpad
|
|
||||||
|
|
||||||
## Requirements / acceptance evidence
|
|
||||||
|
|
||||||
1. `issue-create -i` and `--interactive` prompt for missing issue fields without exposing credentials.
|
|
||||||
2. Explicit command-line fields retain precedence and do not trigger prompt input.
|
|
||||||
3. Gitea wrapper resolves the current user dynamically from the target host and does not rely on the saved Tea user identity.
|
|
||||||
4. A Tea `GetUserByName` failure falls back to authenticated API creation.
|
|
||||||
5. Existing body-safety, login-resolution, issue-create, and pr-create paths remain green.
|
|
||||||
6. Source framework is re-seeded to deployed `~/.config/mosaic`, then deployed wrappers are verified end to end.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Add failing shell regression harness for interactive input and stale Tea user fallback.
|
|
||||||
2. Implement minimal helper and parser changes.
|
|
||||||
3. Run wrapper harnesses, syntax checks, and repository baseline checks.
|
|
||||||
4. Re-seed deployed framework and run live wrapper verification.
|
|
||||||
5. Commit, queue guard, push, open PR, and stop for independent review.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- Issue #703 filed before code; issue comment records #536 root cause and stale-login trigger.
|
|
||||||
- Deployed wrapper `issue-create.sh -i` reproduced: `Unknown option: -i` (exit 1).
|
|
||||||
- Live Tea mutation did not reproduce `GetUserByName` on this host because the current mosaicstack Tea login is valid. The test harness models the reported stale authenticated-user condition.
|
|
||||||
- Implemented `-i` / `--interactive` prompt collection and a dynamic Tea `/user` validation. A stale Tea identity now selects the existing host-scoped Gitea API fallback before mutation for both issue and PR creation.
|
|
||||||
- Re-seeded the framework with `MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash packages/mosaic/framework/install.sh`. Installed and source wrapper SHA-256 values matched.
|
|
||||||
- Live deployed verification: interactive issue-create opened then closed #704; installed dynamic identity resolved `jason.woltje`.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh`
|
|
||||||
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh`
|
|
||||||
- PASS: `packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh`
|
|
||||||
- PASS: `packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh`
|
|
||||||
- PASS: `packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh`
|
|
||||||
- PASS: `bash packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh`
|
|
||||||
- PASS: `bash -n packages/mosaic/framework/tools/git/*.sh`
|
|
||||||
- PASS: Prettier check for this scratchpad
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# FED-M3-05 — Federation List Verb Scratchpad
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement `POST /api/federation/v1/list/:resource`.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Wire `FederationAuthGuard` → `FederationScopeService` → read-only list query layer.
|
|
||||||
- Apply `max_rows_per_query` row cap and return pagination metadata when truncated.
|
|
||||||
- Tag returned rows with `_source: "local"`.
|
|
||||||
- Keep audit writes deferred to M4.
|
|
||||||
- No request/response body persistence.
|
|
||||||
|
|
||||||
## Base / branch
|
|
||||||
|
|
||||||
- Branch: `feat/federation-m3-verb-list`
|
|
||||||
- Base: `main` after M3-04 scope service merged via PR #672 (`c739256a`).
|
|
||||||
|
|
||||||
## Implementation notes
|
|
||||||
|
|
||||||
- Added `ListController` under `apps/gateway/src/federation/server/verbs/`.
|
|
||||||
- Added `FederationListQueryService` as the read-only query layer and native RBAC evaluator.
|
|
||||||
- Query resources supported in M3 list path:
|
|
||||||
- `tasks`: project/mission scoped tasks visible through personal/team project access.
|
|
||||||
- `notes`: non-empty `mission_tasks.notes` rows visible through personal/team mission access.
|
|
||||||
- `memory`: user-owned `insights` and `preferences` rows.
|
|
||||||
- `credentials` / `api_keys`: denied by native RBAC in M3 even if present in scope; sensitive-resource implementation is not part of FED-M3-05.
|
|
||||||
- Cursor pagination uses an opaque base64url keyset cursor over `(createdAt, id)`; DB reads fetch at most `limit + 1` rows per resource query.
|
|
||||||
- Reviewer isolation fix: `mission_tasks.notes` rows are always constrained by `missionTasks.userId = subjectUserId` and accessible mission IDs; team scope narrows missions but never widens to other users' mission task notes.
|
|
||||||
- Follow-up review fix: memory listing now uses deterministic table-block pagination (`insights` first, then `preferences`) with cursor source metadata, so one table's cursor is never applied to the other.
|
|
||||||
- Follow-up hardening: missing auth-guard context returns a structured federation `unauthorized` envelope; unsupported resources and non-encodable truncated cursors throw instead of silently crashing/truncating.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test -- list.controller.spec.ts list-query.service.spec.ts` — PASS (16 tests, including PGlite regression coverage for team-scoped notes isolation, unauthorized mission notes exclusion, `includePersonal: false`, deterministic memory pagination, missing context envelope, unsupported resource, and cursor encode failure).
|
|
||||||
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
|
|
||||||
- `pnpm format:check` — PASS.
|
|
||||||
- `pnpm typecheck` — PASS (41/41 turbo tasks).
|
|
||||||
- `pnpm lint` — PASS (23/23 turbo tasks).
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup cannot connect to local PostgreSQL on `localhost:5433`. New list tests pass; failure is outside FED-M3-05.
|
|
||||||
|
|
||||||
## Review evidence
|
|
||||||
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS after follow-up remediation; approve, no findings.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS after follow-up remediation; risk level none, no findings.
|
|
||||||
- Security-review note: read-path audit logging remains intentionally deferred to M4 per orchestrator clarification and FED-M3-05 scope.
|
|
||||||
|
|
||||||
## Risks / follow-up
|
|
||||||
|
|
||||||
- Read-path audit logging remains intentionally deferred to M4.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# FED-M3-07 — Capabilities Verb Scratchpad
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement `GET /api/federation/v1/capabilities` in `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Add read-only capabilities controller under federation server verbs.
|
|
||||||
- Use `FederationAuthGuard` only; active grant is sufficient and no native RBAC/scope-service eval runs.
|
|
||||||
- Response shape: `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope.
|
|
||||||
- Register controller in `FederationModule`.
|
|
||||||
- Unit-test happy path, defaults, no-context guard seam, and invalid scope handling.
|
|
||||||
|
|
||||||
## Constraints / assumptions
|
|
||||||
|
|
||||||
- Issue: #462.
|
|
||||||
- Branch: `feat/federation-m3-verb-capabilities` from `origin/main` (`3eeed04e`).
|
|
||||||
- Depends on M3-03 auth guard; guard attaches `request.federationContext.scope` after active-grant validation.
|
|
||||||
- ASSUMPTION: `supported_verbs` is the M3 verb set from `@mosaicstack/types` (`list`, `get`, `capabilities`).
|
|
||||||
- ASSUMPTION: `filters`/`rate_limit` are intentionally omitted for FED-M3-07 because the card’s response shape lists only the four required fields.
|
|
||||||
- Budget: no explicit hard cap from orchestrator; working cap ~4K-8K tokens for card implementation + tests + PR cycle.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Write controller unit tests first.
|
|
||||||
2. Implement controller and module registration.
|
|
||||||
3. Run scoped tests + typecheck/lint/format.
|
|
||||||
4. Run Codex code/security review and remediate.
|
|
||||||
5. Commit, queue guard, push, PR via wrapper.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- 2026-06-24: Intake complete; fresh worktree created from origin/main.
|
|
||||||
- 2026-06-24: Added `CapabilitiesController`, registered it in `FederationModule`, and added 5 unit tests.
|
|
||||||
- 2026-06-24: Code/security reviews passed with no findings.
|
|
||||||
|
|
||||||
## Tests run
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test -- capabilities.controller.spec.ts` — PASS (5 tests).
|
|
||||||
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
|
|
||||||
- `pnpm format:check` — PASS.
|
|
||||||
- `pnpm typecheck` — PASS (41/41 turbo tasks).
|
|
||||||
- `pnpm lint` — PASS (23/23 turbo tasks).
|
|
||||||
- `pnpm test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup hit PostgreSQL connection/schema state for the `messages` table. Changed capabilities tests passed; failure is outside FED-M3-07 surface. No `fleet-personas.spec` flake encountered.
|
|
||||||
|
|
||||||
## Review evidence
|
|
||||||
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS/approve, no findings.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS, risk level none, no findings.
|
|
||||||
|
|
||||||
## Risks / blockers
|
|
||||||
|
|
||||||
- Full repo `pnpm test` may hit known `fleet-personas.spec` flake per orchestrator; ignore that specific flake if encountered.
|
|
||||||
- Previous card saw local DB schema issue in `cross-user-isolation.test.ts`; scoped capabilities tests should be authoritative for this surface.
|
|
||||||
|
|
||||||
## Acceptance evidence mapping
|
|
||||||
|
|
||||||
| Acceptance criterion | Evidence |
|
|
||||||
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| GET `/api/federation/v1/capabilities` exists | Route metadata test in `capabilities.controller.spec.ts`; scoped test PASS |
|
|
||||||
| Uses active-grant auth guard and no RBAC eval | Guard metadata test confirms only `FederationAuthGuard`; controller has no service injections/RBAC calls; scoped test PASS |
|
|
||||||
| Response enumerates resources/excluded/max rows/supported verbs from scope | Happy-path/default scope tests + response schema parse; scoped test PASS |
|
|
||||||
| Read-only/no persistence side effects | Controller only parses request `federationContext.scope` and returns a DTO; no DB/service dependency; code review PASS |
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# FED-M3-09 — Query Source Service Scratchpad
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement `apps/gateway/src/federation/client/query-source.service.ts` for `source: "local" | "federated:<host>" | "all"` routing.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Add QuerySourceService in gateway federation client layer.
|
|
||||||
- Unit-test local-only, single federated peer, all-source fan-out/merge, and per-peer partial failures.
|
|
||||||
- Keep `docs/federation/TASKS.md` read-only per project agent guidance.
|
|
||||||
|
|
||||||
## Constraints / assumptions
|
|
||||||
|
|
||||||
- Issue: #462.
|
|
||||||
- Branch: `feat/federation-m3-query-source` from `origin/main` (`e0e7be70`).
|
|
||||||
- ASSUMPTION: `federated:<host>` should match active outbound peers by `commonName` first and by `endpointUrl` host/hostname as compatibility fallback; source tags use `peer.commonName` per `@mosaicstack/types` source-tag docs.
|
|
||||||
- ASSUMPTION: QuerySourceService provides list/fan-out behavior; get/source routing can be layered later because card acceptance says merge rows.
|
|
||||||
- ASSUMPTION: `source: "all"` cannot safely return a single continuation cursor for multiple sub-sources; any subquery cursor marks the merged response `_partial: true` + `_truncated: true` while omitting `nextCursor`.
|
|
||||||
- Budget: no explicit hard cap from orchestrator; working cap ~8K-12K tokens for card 1 implementation + tests + PR cycle.
|
|
||||||
- OpenBrain unavailable: credential loader failed with missing `/home/jarvis/.config/mosaic/credentials.json`; not blocking code delivery.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Review federation client/types/db patterns.
|
|
||||||
2. Write unit tests for source behavior.
|
|
||||||
3. Implement QuerySourceService and export/register it in FederationModule.
|
|
||||||
4. Run scoped tests, typecheck, lint, format.
|
|
||||||
5. Run codex uncommitted review and remediate.
|
|
||||||
6. Commit, queue guard, push, PR via wrapper.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- 2026-06-24: Intake complete; using isolated worktree to avoid dirty orchestrator files in original checkout.
|
|
||||||
- 2026-06-24: Added QuerySourceService, module export, barrel export, and 7 unit tests.
|
|
||||||
- 2026-06-24: First Codex review found pagination and port-host matching issues; both remediated with tests.
|
|
||||||
|
|
||||||
## Tests run
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test -- query-source.service.spec.ts` — PASS (7 tests).
|
|
||||||
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
|
|
||||||
- `pnpm format:check` — PASS.
|
|
||||||
- `pnpm typecheck` — PASS (41/41 turbo tasks).
|
|
||||||
- `pnpm lint` — PASS (23/23 turbo tasks).
|
|
||||||
- `pnpm test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup hit `relation "messages" does not exist` against local PostgreSQL. Changed QuerySource unit tests passed; failure is outside FED-M3-09 surface and appears tied to local DB schema state.
|
|
||||||
|
|
||||||
## Review evidence
|
|
||||||
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — first pass request-changes, 2 should-fix findings (all-source cursor handling; endpoint port host matching).
|
|
||||||
- Remediation: `_partial` + `_truncated` when any all-source subquery has `nextCursor`; endpoint match accepts URL `host` and `hostname`; added tests for both.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS/approve, no findings.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS, risk level none, no findings.
|
|
||||||
|
|
||||||
## Risks / blockers
|
|
||||||
|
|
||||||
- Federation query layer is not yet wired; service API needs to be stable and easy to compose.
|
|
||||||
- Must avoid hard-failing `source: all` on remote peer failures.
|
|
||||||
|
|
||||||
## Acceptance evidence mapping
|
|
||||||
|
|
||||||
| Acceptance criterion | Evidence |
|
|
||||||
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
||||||
| local source returns local rows tagged `_source: local` | `query-source.service.spec.ts` local test; scoped test PASS |
|
|
||||||
| `federated:<host>` queries selected peer and tags rows with peer source | `query-source.service.spec.ts` commonName/endpoint-host tests; scoped test PASS |
|
|
||||||
| `all` fans out local + active outbound peers in parallel and merges tagged rows | `query-source.service.spec.ts` all-source call-order/merge test; scoped test PASS |
|
|
||||||
| per-peer failure on `all` returns `_partial: true`, not throw | `query-source.service.spec.ts` peer failure test; scoped test PASS |
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# F3-m3 — `mosaic update` re-seeds framework + relaunches agents (R13)
|
|
||||||
|
|
||||||
- **Issue:** #609 · **Branch:** `feat/f3-m3-update-reseed`
|
|
||||||
|
|
||||||
## Gap (found in 0.0.39 production validation)
|
|
||||||
|
|
||||||
`mosaic update` installs the new npm CLI but never re-seeds `~/.config/mosaic/` from the package's
|
|
||||||
bundled `framework/`. So the shipped custom Pi harness (agent-name export + native HB, 0.0.39) stays
|
|
||||||
DORMANT until a re-seed — operators get the new CLI on a stale framework.
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
- `update-checker.ts`: `resolveBundledFrameworkRoot()`, `buildReseedCommand()` (install.sh in
|
|
||||||
`MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep` — the P4 data-safe reconcile), `runFrameworkReseed()`,
|
|
||||||
`readRosterAgentNames()`, `buildRelaunchCommands()` (systemctl --user restart per agent).
|
|
||||||
- `cli.ts` `update`: after a successful CLI install that includes `@mosaicstack/mosaic`, re-seed the
|
|
||||||
framework (default-on; `--no-reseed` to skip). Then either `--relaunch` (restart rostered agents) or
|
|
||||||
print clear guidance to run `mosaic update --relaunch` / `mosaic fleet restart`.
|
|
||||||
|
|
||||||
## Flow
|
|
||||||
|
|
||||||
`update CLI → re-seed framework (data-safe) → relaunch agents (opt-in)` — closes R13, activates the
|
|
||||||
native harness for every operator.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- 6 new unit tests (reseed command/env, relaunch commands, roster parse, missing-installer guard).
|
|
||||||
- 19 runtime + 26 launch tests still green; tsc/eslint/prettier clean.
|
|
||||||
- Data-safety of the sync is already proven (P4 5-fixture matrix + live dragon-lin validation).
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# F4 — Orchestrator chat connector + Matrix (#616)
|
|
||||||
|
|
||||||
- **Issue:** #616 · **Branch:** `feat/f4-matrix-connector` (off main; independent of #615) · **Doctrine:** north-star #613.
|
|
||||||
|
|
||||||
## Phase 1 (this PR) — abstraction + scaffold
|
|
||||||
|
|
||||||
- `src/fleet/connectors/types.ts`: `OrchestratorConnector` (send/subscribe/health) + message/config types; thread-aware via optional `threadId`; `DEFAULT_CONNECTOR_KIND=tmux`.
|
|
||||||
- `src/fleet/connectors/registry.ts`: extensible factory registry; `resolveConnectorKind` (defaults tmux, back-compat); `createConnector` throws `ConnectorNotImplementedError` until Phase 2 registers factories.
|
|
||||||
- `roster.schema.json`: optional `connector` block (tmux|discord|matrix; matrix homeserver/user/room; secrets via env, never roster).
|
|
||||||
- Design doc `docs/fleet/f4-matrix-connector.md`: interface, config, Matrix CS-API mapping, Conduit-default infra, phasing.
|
|
||||||
- **No fleet.ts changes** → self-contained, zero conflict with stacked #615.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- 7 connector tests green; tsc/eslint/prettier/sanitize clean; schema valid JSON.
|
|
||||||
|
|
||||||
## Phase 2+ (follow-ups, in the doc)
|
|
||||||
|
|
||||||
Matrix CS-API client (fetch send/sync/health) + factory; init/configure connector-selection UX + roster-parse wiring; systemd launch wiring; Conduit deploy guide; first-party Mosaic Discord (threads) as a connector.
|
|
||||||
|
|
||||||
## Phase 2a (feat/f4-matrix-client, stacked on #617) — Matrix CS-API client
|
|
||||||
|
|
||||||
- `src/fleet/connectors/matrix.ts`: `MatrixConnector implements OrchestratorConnector` over the Matrix
|
|
||||||
client-server API (injectable fetch, no SDK). `send` → PUT m.room.message (thread-aware); `subscribe`
|
|
||||||
→ /sync long-poll loop using the pure `parseSyncResponse`; `health` → /versions + /whoami.
|
|
||||||
`registerMatrixConnector(env)` registers the factory (token from MATRIX_ACCESS_TOKEN, never roster).
|
|
||||||
- Pure helpers `buildMessageBody` + `parseSyncResponse` make send/receive unit-testable.
|
|
||||||
- 13 Matrix tests + 7 registry = 20 connector tests green; tsc/eslint/prettier clean.
|
|
||||||
- Remaining Phase 2: init/configure connector-selection UX + roster-parse wiring (touches fleet.ts —
|
|
||||||
after #615); systemd launch wiring; Conduit deploy guide.
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# Fleet onboarding-injection — comms cheat-sheet + peer roster (#620)
|
|
||||||
|
|
||||||
- **Issue:** #620 · **Branch:** `feat/fleet-comms-onboarding` (off main). Root cause of Mos's failed first send.
|
|
||||||
|
|
||||||
## What
|
|
||||||
|
|
||||||
Inject a `# Fleet Comms` block into each spawned fleet agent's system prompt (via composeContract — the
|
|
||||||
runtime-agnostic path every `mosaic yolo <runtime>` agent hits), so it boots knowing how to reach peers.
|
|
||||||
|
|
||||||
- `src/fleet/comms-onboarding.ts` (standalone, no fleet.ts coupling):
|
|
||||||
- `parseRosterAgents` (name/class/host/ssh, lenient), `renderPeerReach` (same-host `-s` vs cross-host
|
|
||||||
`-H <ssh> -s`), `buildFleetCommsBlock` (self [host:session] identity + agent-send path + peer table +
|
|
||||||
FLIP-to-reply + `agent send --verify`=ACCEPTED), `readFleetCommsBlock` (reads roster.yaml; '' if not a member).
|
|
||||||
- `composeContract` appends it only when MOSAIC_AGENT_NAME is set + the agent is in the roster.
|
|
||||||
- `roster.schema.json`: optional per-agent `host` + `ssh` (cross-host addresses; manual = pre-federation
|
|
||||||
stopgap, federation/W1 auto-discovers later).
|
|
||||||
|
|
||||||
## Acceptance criteria (Mos) — all covered
|
|
||||||
|
|
||||||
1. own [host:session] + agent-send path + peer roster ✓
|
|
||||||
2. cross-host correctness: local→`-s` (no -H); remote→`-H <ssh> -s` ✓ (concrete coder0-0@dragon-lin)
|
|
||||||
3. FLIP-the-preamble reply rule ✓
|
|
||||||
4. `agent send --verify` = ACCEPTED ✓
|
|
||||||
5. no `-L` (default socket); matches live tooling ✓
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- 10 onboarding unit tests (parse, render local/remote/fallback/equal-host, build, situational read) +
|
|
||||||
2 composeContract situational tests (injects for fleet agent w/ correct cross-host addr; no-op when
|
|
||||||
MOSAIC_AGENT_NAME unset). tsc/eslint/prettier/sanitize clean.
|
|
||||||
- Post-merge validation: Mos spawns a real w-jarvis agent → first-try reach to coder0-0@dragon-lin + a local peer.
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Fleet enhancer role + two-agent floor (#614)
|
|
||||||
|
|
||||||
- **Issue:** #614 · **Branch:** `feat/fleet-enhancer-floor` (stacked on #612 `feat/fleet-polish-bundle`)
|
|
||||||
- **Doctrine:** `docs/fleet/north-star.md` (PR #613) — every fleet = orchestrator + enhancer minimum.
|
|
||||||
|
|
||||||
## Changes
|
|
||||||
|
|
||||||
- **Presets** (general, coding, research, hybrid): add `enhancer` (claude, `class: enhancer`,
|
|
||||||
`persistent_persona: true`) as a core always-on agent alongside the orchestrator. minimal/local-canary
|
|
||||||
unchanged.
|
|
||||||
- **fleet.ts**: `countEnhancers` helper; init guarantee extended — non-minimal profiles must yield
|
|
||||||
exactly 1 orchestrator AND >=1 enhancer (hard-fail otherwise); `removeAgentFromRoster` refuses to drop
|
|
||||||
the sole enhancer (symmetric with the sole-orchestrator guard) so the floor holds at runtime, not just init.
|
|
||||||
- **Role doc**: `framework/fleet/roles/enhancer.md` — the enhancer mandate (monitor → analyze → plan →
|
|
||||||
upgrade tools/skills/harness WITH orchestrator → file Mosaic Stack bug reports) + boundaries (does NOT
|
|
||||||
code or review).
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- 155 fleet tests green (new: countEnhancers; remove-sole-enhancer guard; remove-allows-when-another;
|
|
||||||
init two-agent-floor; every-non-minimal-preset-has-enhancer; updated preset rosters). tsc/eslint/
|
|
||||||
prettier/sanitize clean. TDD on the init guarantee + remove protection.
|
|
||||||
|
|
||||||
## Stacking
|
|
||||||
|
|
||||||
Built on #612's init-R5 code. PR shows #612 + enhancer until #612 merges; then rebase onto main → clean.
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user