Compare commits
81 Commits
chore/ci-c
...
f6d6d0bb62
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6d6d0bb62 | ||
| e72388b2cb | |||
| 6345dbfcf2 | |||
| c6e3cfbd95 | |||
| 5789711ee0 | |||
| f40e6ba388 | |||
| b7b0f508e6 | |||
| 3378b857eb | |||
| e2376190e5 | |||
| cca6aaf947 | |||
| 2363f155b4 | |||
| 76325ca3f2 | |||
| 9e5b9188ce | |||
| f1c6b37b46 | |||
| 0b621660c8 | |||
| 84d884b932 | |||
| 8246ee0137 | |||
| 99a2d0fc9d | |||
| e3b5113be2 | |||
| 24b07d0f83 | |||
| 86a50138a9 | |||
| 7b9f40d3b7 | |||
| 9a8a572fcf | |||
| 753a360517 | |||
| e92186d768 | |||
| 119f64e69d | |||
| 46ca3ce742 | |||
| 227b73fcdf | |||
| a959b1d6b4 | |||
| 62f8177806 | |||
| 353e43c947 | |||
| ca9c2b5c23 | |||
| b580d37d51 | |||
| 59e49cfd15 | |||
| a99aded26d | |||
| 4df38f7e81 | |||
| 4e9e053800 | |||
| 851c67c27b | |||
| 86e106fcc9 | |||
| 67135d3822 | |||
| adb153428b | |||
| c739256a2c | |||
| fc2970916f | |||
| 79eae2ffce | |||
| 7035cd23bf | |||
| 6b94d014a8 | |||
| 838c44086c | |||
| 3eeed04e17 | |||
| e0e7be70f5 | |||
| d7eaa19380 | |||
| 248193cd3b | |||
| a9857c5043 | |||
| e4ede69144 | |||
| a8008138c8 | |||
| 0d17a29ebe | |||
| 28cfecda94 | |||
| 6c84ccd0b1 | |||
| 84d2757817 | |||
| a738ac1410 | |||
| 538f0556d5 | |||
| a094c86eea | |||
| f852250419 | |||
| 61b1bdac2a | |||
| cabb179d5a | |||
| eb795bab18 | |||
| 937077f6be | |||
| 1020cfaf9b | |||
| 70661e3fab | |||
| ec8dd7ca86 | |||
| d887555852 | |||
| e3adc6a1bc | |||
| aa27c42129 | |||
| 16ae809442 | |||
| 6980e40e51 | |||
| e6b53ea103 | |||
| 4da87640e8 | |||
| a38a491403 | |||
| 78d67c6261 | |||
| 94e5cd7a81 | |||
| 4e84f8e850 | |||
| cf8ceb3095 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -15,3 +15,10 @@ infra/step-ca/dev-password
|
||||
|
||||
# Scratch dirs created by the framework git-wrapper shell test harnesses
|
||||
.mosaic-test-work/
|
||||
|
||||
# Transient config files vite/vitest/esbuild write next to a *.config.ts while
|
||||
# loading it, then unlink. They are untracked but were not ignored, so turbo's
|
||||
# package traversal hashed them and intermittently failed CI with "Package
|
||||
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
|
||||
# file vanished mid-scan. Ignoring them removes the race.
|
||||
*.timestamp-*.mjs
|
||||
|
||||
4
.npmrc
4
.npmrc
@@ -1 +1,5 @@
|
||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
|
||||
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
||||
# instead of repopulating a fresh one.
|
||||
store-dir=/root/.local/share/pnpm/store
|
||||
|
||||
@@ -5,3 +5,5 @@ pnpm-lock.yaml
|
||||
**/drizzle
|
||||
**/.next
|
||||
.claude/
|
||||
docs/tess/TASKS.md
|
||||
docs/scratchpads/
|
||||
|
||||
40
.woodpecker/ci-image.yml
Normal file
40
.woodpecker/ci-image.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
# 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,9 +1,20 @@
|
||||
# &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:
|
||||
- &node_image 'node:22-alpine'
|
||||
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
||||
- &enable_pnpm 'corepack enable'
|
||||
|
||||
when:
|
||||
- event: [push, pull_request, manual]
|
||||
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
|
||||
# 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
|
||||
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||
@@ -15,8 +26,9 @@ steps:
|
||||
image: *node_image
|
||||
commands:
|
||||
- corepack enable
|
||||
- apk add --no-cache python3 make g++
|
||||
- pnpm install --frozen-lockfile
|
||||
# python3/make/g++ are baked into ci-base; --prefer-offline resolves from
|
||||
# the baked pnpm store.
|
||||
- pnpm install --frozen-lockfile --prefer-offline
|
||||
|
||||
# Blocking gate: public framework package must contain no operator-specific
|
||||
# personal data or private $HOME defaults. Runs early (no node_modules needed).
|
||||
@@ -64,8 +76,7 @@ steps:
|
||||
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
# Install postgresql-client for pg_isready
|
||||
- apk add --no-cache postgresql-client
|
||||
# postgresql-client (pg_isready) is baked into ci-base.
|
||||
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
||||
- |
|
||||
ready=0
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
# Runs only on main branch push/tag
|
||||
|
||||
variables:
|
||||
- &node_image 'node:22-alpine'
|
||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-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'
|
||||
# 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
|
||||
@@ -31,7 +33,8 @@ steps:
|
||||
image: *node_image
|
||||
commands:
|
||||
- corepack enable
|
||||
- pnpm install --frozen-lockfile
|
||||
# Resolve from the baked pnpm store instead of a cold network fetch.
|
||||
- pnpm install --frozen-lockfile --prefer-offline
|
||||
|
||||
build:
|
||||
image: *node_image
|
||||
|
||||
45
Dockerfile.ci
Normal file
45
Dockerfile.ci
Normal file
@@ -0,0 +1,45 @@
|
||||
# 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
|
||||
@@ -31,6 +31,7 @@
|
||||
"@mariozechner/pi-ai": "^0.65.0",
|
||||
"@mariozechner/pi-coding-agent": "^0.65.0",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@mosaicstack/agent": "workspace:^",
|
||||
"@mosaicstack/auth": "workspace:^",
|
||||
"@mosaicstack/brain": "workspace:^",
|
||||
"@mosaicstack/config": "workspace:^",
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { InMemoryDurableSessionStore } from '@mosaicstack/agent';
|
||||
import {
|
||||
createDiscordIngressEnvelope,
|
||||
DiscordPlugin,
|
||||
type DiscordIngressPayload,
|
||||
} from '@mosaicstack/discord-plugin';
|
||||
import { InteractionController } from '../../agent/interaction.controller.js';
|
||||
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
|
||||
import { DurableSessionService } from '../../agent/durable-session.service.js';
|
||||
import { ChatGateway } from '../../chat/chat.gateway.js';
|
||||
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
|
||||
|
||||
const SERVICE_TOKEN = 'test-discord-service-token';
|
||||
const envKeys = [
|
||||
'DISCORD_SERVICE_TOKEN',
|
||||
'DISCORD_SERVICE_TENANT_ID',
|
||||
'DISCORD_INTERACTION_BINDINGS',
|
||||
'DISCORD_ALLOWED_GUILD_IDS',
|
||||
'DISCORD_ALLOWED_CHANNEL_IDS',
|
||||
'DISCORD_ALLOWED_USER_IDS',
|
||||
'MOSAIC_AGENT_NAME',
|
||||
] as const;
|
||||
const priorEnv = new Map<string, string | undefined>();
|
||||
|
||||
function payload(content: string, messageId: string, correlationId: string): DiscordIngressPayload {
|
||||
return {
|
||||
content,
|
||||
messageId,
|
||||
correlationId,
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
userId: 'discord-admin-1',
|
||||
conversationId: 'Nova:discord:channel-1',
|
||||
};
|
||||
}
|
||||
|
||||
function authorization(): CommandAuthorizationService {
|
||||
const entries = new Map<string, string>();
|
||||
return new CommandAuthorizationService(
|
||||
{
|
||||
select: () => ({
|
||||
from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }),
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
get: async (key: string) => entries.get(key) ?? null,
|
||||
set: async (key: string, value: string) => entries.set(key, value),
|
||||
del: async (key: string) => Number(entries.delete(key)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('interaction Discord/CLI durable-session integration', () => {
|
||||
afterEach(() => {
|
||||
for (const key of envKeys) {
|
||||
const value = priorEnv.get(key);
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
priorEnv.clear();
|
||||
});
|
||||
|
||||
it('enrolls through the CLI surface then resolves the same durable session from Discord', async () => {
|
||||
for (const key of envKeys) priorEnv.set(key, process.env[key]);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
|
||||
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-1';
|
||||
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-1';
|
||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-1';
|
||||
process.env['DISCORD_ALLOWED_USER_IDS'] = 'discord-admin-1';
|
||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
pairedUsers: {
|
||||
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const durable = new DurableSessionService(
|
||||
new InMemoryDurableSessionStore() as never,
|
||||
{} as never,
|
||||
);
|
||||
const enrollmentRuntime = {
|
||||
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
|
||||
};
|
||||
const controller = new InteractionController(enrollmentRuntime as never, durable);
|
||||
await controller.enroll(
|
||||
'Nova',
|
||||
'Nova:discord:channel-1',
|
||||
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
{ id: 'mosaic-admin-1', tenantId: 'tenant-1' },
|
||||
'cli-enrollment-correlation',
|
||||
);
|
||||
|
||||
const authz = authorization();
|
||||
const terminated = vi.fn().mockResolvedValue(undefined);
|
||||
const runtime = new RuntimeProviderService(
|
||||
{
|
||||
require: () => ({
|
||||
capabilities: async () => ({ supported: ['session.terminate'] }),
|
||||
terminate: terminated,
|
||||
}),
|
||||
} as never,
|
||||
{ record: async () => undefined } as never,
|
||||
{
|
||||
consume: (approvalId, action) =>
|
||||
authz.consumeRuntimeTerminationApproval(approvalId, action),
|
||||
},
|
||||
);
|
||||
const gateway = new ChatGateway(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
authz,
|
||||
runtime,
|
||||
durable,
|
||||
);
|
||||
const client = { data: { discordService: true }, emit: vi.fn() };
|
||||
const plugin = new DiscordPlugin({
|
||||
token: 'unused',
|
||||
gatewayUrl: 'http://unused',
|
||||
serviceToken: SERVICE_TOKEN,
|
||||
allowedGuildIds: ['guild-1'],
|
||||
allowedChannelIds: ['channel-1'],
|
||||
allowedUserIds: ['discord-admin-1'],
|
||||
interactionBindings: [
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
pairedUsers: {
|
||||
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const pluginInternals = plugin as unknown as {
|
||||
client: { user: { id: string } };
|
||||
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
|
||||
handleDiscordMessage(message: unknown): void;
|
||||
};
|
||||
const pluginSocket = { connected: true, emit: vi.fn() };
|
||||
pluginInternals.client = { user: { id: 'bot-1' } };
|
||||
pluginInternals.socket = pluginSocket;
|
||||
pluginInternals.handleDiscordMessage({
|
||||
id: 'approve-1',
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
author: { id: 'discord-admin-1', bot: false },
|
||||
mentions: { has: () => true },
|
||||
content: '<@bot-1> /approve',
|
||||
channel: { parentId: null },
|
||||
attachments: new Map(),
|
||||
});
|
||||
|
||||
expect(pluginSocket.emit).toHaveBeenCalledWith('discord:approve', expect.any(Object));
|
||||
const approvalEnvelope = pluginSocket.emit.mock.calls[0]?.[1];
|
||||
await gateway.handleDiscordApproval(client as never, approvalEnvelope);
|
||||
const approval = client.emit.mock.calls.find(
|
||||
([event]) => event === 'discord:approval',
|
||||
)?.[1] as {
|
||||
approvalId: string;
|
||||
success: boolean;
|
||||
};
|
||||
expect(approval.success).toBe(true);
|
||||
|
||||
await gateway.handleDiscordStop(
|
||||
client as never,
|
||||
createDiscordIngressEnvelope(
|
||||
payload(`/stop ${approval.approvalId}`, 'stop-1', 'discord-stop-correlation'),
|
||||
SERVICE_TOKEN,
|
||||
),
|
||||
);
|
||||
|
||||
expect(terminated).toHaveBeenCalledWith(
|
||||
'runtime-1',
|
||||
approval.approvalId,
|
||||
expect.objectContaining({ actorId: 'mosaic-admin-1' }),
|
||||
);
|
||||
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
|
||||
correlationId: 'discord-stop-correlation',
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ export class AdminHealthController {
|
||||
async check(): Promise<HealthStatusDto> {
|
||||
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
|
||||
|
||||
const sessions = this.agentService.listSessions();
|
||||
const sessions = this.agentService.listAllSessionsForSystem();
|
||||
const providers = this.providerService.listProviders();
|
||||
|
||||
const allOk = database.status === 'ok' && cache.status === 'ok';
|
||||
|
||||
179
apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts
Normal file
179
apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AgentService, type AgentSession } from '../agent.service.js';
|
||||
import type { ActorTenantScope } from '../../auth/session-scope.js';
|
||||
|
||||
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-user', tenantId: 'owner-tenant' };
|
||||
const FOREIGN_SCOPE: ActorTenantScope = { userId: 'foreign-user', tenantId: 'foreign-tenant' };
|
||||
|
||||
type AgentServiceInternals = {
|
||||
sessions: Map<string, AgentSession>;
|
||||
creating: Map<string, Promise<AgentSession>>;
|
||||
};
|
||||
|
||||
function makeService(operatorMemory: unknown = null): AgentService {
|
||||
return new AgentService(
|
||||
{
|
||||
getDefaultModel: vi.fn(() => null),
|
||||
getRegistry: vi.fn(() => ({})),
|
||||
findModel: vi.fn(),
|
||||
listAvailableModels: vi.fn(() => []),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ available: false } as never,
|
||||
{} as never,
|
||||
{ getToolDefinitions: vi.fn(() => []) } as never,
|
||||
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
||||
null,
|
||||
null,
|
||||
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||
operatorMemory as never,
|
||||
);
|
||||
}
|
||||
|
||||
function internals(service: AgentService): AgentServiceInternals {
|
||||
return service as unknown as AgentServiceInternals;
|
||||
}
|
||||
|
||||
function makeSession(scope: ActorTenantScope = OWNER_SCOPE): AgentSession {
|
||||
return {
|
||||
id: CONVERSATION_ID,
|
||||
provider: 'test-provider',
|
||||
modelId: 'test-model',
|
||||
piSession: {
|
||||
thinkingLevel: 'off',
|
||||
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
|
||||
setThinkingLevel: vi.fn(),
|
||||
abort: vi.fn().mockResolvedValue(undefined),
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
getSessionStats: vi.fn(),
|
||||
getContextUsage: vi.fn(),
|
||||
} as unknown as AgentSession['piSession'],
|
||||
listeners: new Set(),
|
||||
unsubscribe: vi.fn(),
|
||||
createdAt: Date.now(),
|
||||
promptCount: 0,
|
||||
channels: new Set(),
|
||||
skillPromptAdditions: [],
|
||||
sandboxDir: '/tmp/tess-session-ownership-test',
|
||||
allowedTools: null,
|
||||
userId: scope.userId,
|
||||
tenantId: scope.tenantId,
|
||||
metrics: {
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
modelSwitches: 0,
|
||||
messageCount: 0,
|
||||
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentService owner/tenant scope enforcement', () => {
|
||||
it('allows owner-scoped operations and rejects foreign scopes for seeded sessions', async () => {
|
||||
const service = makeService();
|
||||
const session = makeSession();
|
||||
internals(service).sessions.set(CONVERSATION_ID, session);
|
||||
|
||||
expect(service.getSession(CONVERSATION_ID, OWNER_SCOPE)).toBe(session);
|
||||
expect(service.getSession(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
|
||||
expect(service.getSessionInfo(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
|
||||
expect(service.listSessions(OWNER_SCOPE)).toHaveLength(1);
|
||||
expect(service.listSessions(FOREIGN_SCOPE)).toEqual([]);
|
||||
|
||||
service.addChannel(CONVERSATION_ID, 'websocket:owner', OWNER_SCOPE);
|
||||
expect(session.channels.has('websocket:owner')).toBe(true);
|
||||
expect(() => service.addChannel(CONVERSATION_ID, 'websocket:foreign', FOREIGN_SCOPE)).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(() => service.removeChannel(CONVERSATION_ID, 'websocket:owner', FOREIGN_SCOPE)).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
service.updateSessionModel(CONVERSATION_ID, 'foreign-model', FOREIGN_SCOPE),
|
||||
).toThrow(ForbiddenException);
|
||||
service.updateSessionModel(CONVERSATION_ID, 'owner-model', OWNER_SCOPE);
|
||||
expect(session.modelId).toBe('owner-model');
|
||||
|
||||
expect(() =>
|
||||
service.applyAgentConfig(CONVERSATION_ID, 'agent-foreign', 'Foreign Agent', FOREIGN_SCOPE),
|
||||
).toThrow(ForbiddenException);
|
||||
service.applyAgentConfig(CONVERSATION_ID, 'agent-owner', 'Owner Agent', OWNER_SCOPE);
|
||||
expect(session.agentConfigId).toBe('agent-owner');
|
||||
|
||||
expect(() => service.onEvent(CONVERSATION_ID, vi.fn(), FOREIGN_SCOPE)).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
const cleanup = service.onEvent(CONVERSATION_ID, vi.fn(), OWNER_SCOPE);
|
||||
cleanup();
|
||||
|
||||
await expect(
|
||||
service.prompt(CONVERSATION_ID, 'foreign prompt', FOREIGN_SCOPE),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
|
||||
expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt');
|
||||
|
||||
await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(true);
|
||||
|
||||
await service.destroySession(CONVERSATION_ID, OWNER_SCOPE);
|
||||
expect(session.piSession.dispose).toHaveBeenCalled();
|
||||
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it('derives the operator-memory scope on the createSession production path', async () => {
|
||||
const plugin = { capture: vi.fn(), search: vi.fn() };
|
||||
const service = makeService(plugin);
|
||||
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox').mockReturnValue([]);
|
||||
|
||||
// Session construction reaches the real scope derivation before the intentionally incomplete
|
||||
// Pi test double rejects later in createAgentSession.
|
||||
await service.createSession(CONVERSATION_ID, OWNER_SCOPE).catch(() => undefined);
|
||||
|
||||
expect(buildTools).toHaveBeenCalledWith(expect.any(String), OWNER_SCOPE.userId, {
|
||||
tenantId: OWNER_SCOPE.tenantId,
|
||||
ownerId: OWNER_SCOPE.userId,
|
||||
sessionId: CONVERSATION_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('denies a foreign actor before it can obtain another session operator-memory scope', async () => {
|
||||
const plugin = { capture: vi.fn(), search: vi.fn() };
|
||||
const service = makeService(plugin);
|
||||
internals(service).sessions.set(CONVERSATION_ID, makeSession());
|
||||
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox');
|
||||
|
||||
await expect(service.createSession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
|
||||
expect(buildTools).not.toHaveBeenCalled();
|
||||
expect(plugin.capture).not.toHaveBeenCalled();
|
||||
expect(plugin.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks owner/tenant scope before returning an in-flight session creation', async () => {
|
||||
const service = makeService();
|
||||
const session = makeSession();
|
||||
internals(service).creating.set(CONVERSATION_ID, Promise.resolve(session));
|
||||
|
||||
await expect(
|
||||
service.createSession(CONVERSATION_ID, {
|
||||
userId: FOREIGN_SCOPE.userId,
|
||||
tenantId: FOREIGN_SCOPE.tenantId,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
await expect(
|
||||
service.createSession(CONVERSATION_ID, {
|
||||
userId: OWNER_SCOPE.userId,
|
||||
tenantId: OWNER_SCOPE.tenantId,
|
||||
}),
|
||||
).resolves.toBe(session);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
262
apps/gateway/src/agent/__tests__/session-ownership.test.ts
Normal file
262
apps/gateway/src/agent/__tests__/session-ownership.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
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,4 +1,5 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AgentRuntimeProviderRegistry, HermesRuntimeProvider } from '@mosaicstack/agent';
|
||||
import { AgentService } from './agent.service.js';
|
||||
import { ProviderService } from './provider.service.js';
|
||||
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
||||
@@ -8,24 +9,66 @@ import { SkillLoaderService } from './skill-loader.service.js';
|
||||
import { ProvidersController } from './providers.controller.js';
|
||||
import { SessionsController } from './sessions.controller.js';
|
||||
import { AgentConfigsController } from './agent-configs.controller.js';
|
||||
import { InteractionController } from './interaction.controller.js';
|
||||
import { RoutingController } from './routing/routing.controller.js';
|
||||
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||
import { DurableSessionService } from './durable-session.service.js';
|
||||
import { CoordModule } from '../coord/coord.module.js';
|
||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||
import { SkillsModule } from '../skills/skills.module.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
import { LogModule } from '../log/log.module.js';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
|
||||
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
|
||||
import {
|
||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
RUNTIME_APPROVAL_VERIFIER,
|
||||
RUNTIME_PROVIDER_AUDIT_SINK,
|
||||
RuntimeProviderAuditService,
|
||||
RuntimeProviderService,
|
||||
} from './runtime-provider-registry.service.js';
|
||||
|
||||
export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegistry {
|
||||
const registry = new AgentRuntimeProviderRegistry();
|
||||
registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport()));
|
||||
return registry;
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [CoordModule, McpClientModule, SkillsModule, GCModule],
|
||||
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule],
|
||||
providers: [
|
||||
ProviderService,
|
||||
ProviderCredentialsService,
|
||||
RoutingService,
|
||||
RoutingEngineService,
|
||||
SkillLoaderService,
|
||||
DurableSessionRepository,
|
||||
DurableSessionService,
|
||||
{
|
||||
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
useFactory: createGatewayRuntimeProviderRegistry,
|
||||
},
|
||||
RuntimeProviderAuditService,
|
||||
{
|
||||
provide: RUNTIME_PROVIDER_AUDIT_SINK,
|
||||
useExisting: RuntimeProviderAuditService,
|
||||
},
|
||||
{
|
||||
provide: RUNTIME_APPROVAL_VERIFIER,
|
||||
useExisting: CommandRuntimeApprovalVerifier,
|
||||
},
|
||||
RuntimeProviderService,
|
||||
AgentService,
|
||||
],
|
||||
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
|
||||
controllers: [
|
||||
ProvidersController,
|
||||
SessionsController,
|
||||
AgentConfigsController,
|
||||
InteractionController,
|
||||
RoutingController,
|
||||
],
|
||||
exports: [
|
||||
AgentService,
|
||||
ProviderService,
|
||||
@@ -33,6 +76,9 @@ import { GCModule } from '../gc/gc.module.js';
|
||||
RoutingService,
|
||||
RoutingEngineService,
|
||||
SkillLoaderService,
|
||||
DurableSessionService,
|
||||
RuntimeProviderService,
|
||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
],
|
||||
})
|
||||
export class AgentModule {}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common';
|
||||
import {
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
Optional,
|
||||
type OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
@@ -8,9 +15,10 @@ import {
|
||||
type ToolDefinition,
|
||||
} from '@mariozechner/pi-coding-agent';
|
||||
import type { Brain } from '@mosaicstack/brain';
|
||||
import type { Memory } from '@mosaicstack/memory';
|
||||
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { MEMORY } from '../memory/memory.tokens.js';
|
||||
import { OPERATOR_MEMORY_PLUGIN } from '../memory/memory.module.js';
|
||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
||||
import { CoordService } from '../coord/coord.service.js';
|
||||
import { ProviderService } from './provider.service.js';
|
||||
@@ -28,6 +36,7 @@ import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
import { PreferencesService } from '../preferences/preferences.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. */
|
||||
export interface ConversationHistoryMessage {
|
||||
@@ -68,6 +77,8 @@ export interface AgentSessionOptions {
|
||||
agentConfigId?: string;
|
||||
/** ID of the user who owns this session. Used for preferences and system override lookups. */
|
||||
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.
|
||||
* These messages are formatted and prepended to the system prompt so the
|
||||
@@ -94,6 +105,8 @@ export interface AgentSession {
|
||||
allowedTools: string[] | null;
|
||||
/** User ID that owns this session, used for preference lookups. */
|
||||
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). */
|
||||
agentConfigId?: string;
|
||||
/** Human-readable agent name applied to this session, if any (M5-001). */
|
||||
@@ -123,6 +136,9 @@ export class AgentService implements OnModuleDestroy {
|
||||
@Inject(PreferencesService)
|
||||
private readonly preferencesService: PreferencesService | null,
|
||||
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
||||
@Optional()
|
||||
@Inject(OPERATOR_MEMORY_PLUGIN)
|
||||
private readonly operatorMemory: OperatorMemoryPlugin | null = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -134,6 +150,7 @@ export class AgentService implements OnModuleDestroy {
|
||||
private buildToolsForSandbox(
|
||||
sandboxDir: string,
|
||||
sessionUserId: string | undefined,
|
||||
sessionScope?: { tenantId: string; ownerId: string; sessionId: string },
|
||||
): ToolDefinition[] {
|
||||
return [
|
||||
...createBrainTools(this.brain),
|
||||
@@ -142,6 +159,9 @@ export class AgentService implements OnModuleDestroy {
|
||||
this.memory,
|
||||
this.embeddingService.available ? this.embeddingService : null,
|
||||
sessionUserId,
|
||||
this.operatorMemory && sessionScope
|
||||
? { plugin: this.operatorMemory, scope: sessionScope }
|
||||
: undefined,
|
||||
),
|
||||
...createFileTools(sandboxDir),
|
||||
...createGitTools(sandboxDir),
|
||||
@@ -174,12 +194,20 @@ export class AgentService implements OnModuleDestroy {
|
||||
.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);
|
||||
if (existing) return existing;
|
||||
if (existing) {
|
||||
this.assertSessionScope(existing, scope);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const inflight = this.creating.get(sessionId);
|
||||
if (inflight) return inflight;
|
||||
if (inflight) {
|
||||
const session = await inflight;
|
||||
this.assertSessionScope(session, scope);
|
||||
return session;
|
||||
}
|
||||
|
||||
const promise = this.doCreateSession(sessionId, options).finally(() => {
|
||||
this.creating.delete(sessionId);
|
||||
@@ -208,6 +236,7 @@ export class AgentService implements OnModuleDestroy {
|
||||
isAdmin: options.isAdmin,
|
||||
agentConfigId: options.agentConfigId,
|
||||
userId: options.userId,
|
||||
tenantId: options.tenantId,
|
||||
conversationHistory: options.conversationHistory,
|
||||
};
|
||||
this.logger.log(
|
||||
@@ -247,7 +276,15 @@ export class AgentService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
// Build per-session tools scoped to the sandbox directory and authenticated user
|
||||
const sandboxTools = this.buildToolsForSandbox(sandboxDir, mergedOptions?.userId);
|
||||
const sessionUserId = mergedOptions?.userId;
|
||||
const sessionTenantId = this.tenantIdFor(sessionUserId, mergedOptions?.tenantId);
|
||||
const sandboxTools = this.buildToolsForSandbox(
|
||||
sandboxDir,
|
||||
sessionUserId,
|
||||
sessionUserId && sessionTenantId
|
||||
? { tenantId: sessionTenantId, ownerId: sessionUserId, sessionId }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Combine static tools with dynamically discovered MCP client tools and skill tools
|
||||
const mcpTools = this.mcpClientService.getToolDefinitions();
|
||||
@@ -342,6 +379,7 @@ export class AgentService implements OnModuleDestroy {
|
||||
sandboxDir,
|
||||
allowedTools,
|
||||
userId: mergedOptions?.userId,
|
||||
tenantId: sessionTenantId,
|
||||
agentConfigId: mergedOptions?.agentConfigId,
|
||||
agentName: resolvedAgentName,
|
||||
metrics: {
|
||||
@@ -473,38 +511,70 @@ export class AgentService implements OnModuleDestroy {
|
||||
return this.providerService.getDefaultModel() ?? null;
|
||||
}
|
||||
|
||||
getSession(sessionId: string): AgentSession | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
getSession(sessionId: string, scope: ActorTenantScope): AgentSession | undefined {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || !this.sessionMatchesScope(session, scope)) return undefined;
|
||||
return session;
|
||||
}
|
||||
|
||||
listSessions(): SessionInfoDto[] {
|
||||
listSessions(scope: ActorTenantScope): SessionInfoDto[] {
|
||||
const now = Date.now();
|
||||
return Array.from(this.sessions.values()).map((s) => ({
|
||||
id: s.id,
|
||||
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 },
|
||||
}));
|
||||
return Array.from(this.sessions.values())
|
||||
.filter((s) => this.sessionMatchesScope(s, scope))
|
||||
.map((s) => this.toSessionInfo(s, now));
|
||||
}
|
||||
|
||||
getSessionInfo(sessionId: string): SessionInfoDto | undefined {
|
||||
listAllSessionsForSystem(): SessionInfoDto[] {
|
||||
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);
|
||||
if (!s) return undefined;
|
||||
if (!s || !this.sessionMatchesScope(s, scope)) return undefined;
|
||||
return this.toSessionInfo(s);
|
||||
}
|
||||
|
||||
private scopeFromOptions(options: AgentSessionOptions): ActorTenantScope {
|
||||
if (!options.userId) {
|
||||
throw new ForbiddenException('Session owner scope is required');
|
||||
}
|
||||
return {
|
||||
id: s.id,
|
||||
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: Date.now() - s.createdAt,
|
||||
metrics: { ...s.metrics },
|
||||
userId: options.userId,
|
||||
tenantId: this.tenantIdFor(options.userId, options.tenantId) ?? options.userId,
|
||||
};
|
||||
}
|
||||
|
||||
private tenantIdFor(
|
||||
userId: string | undefined,
|
||||
tenantId: string | undefined,
|
||||
): string | undefined {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -553,9 +623,10 @@ export class AgentService implements OnModuleDestroy {
|
||||
* 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.
|
||||
*/
|
||||
updateSessionModel(sessionId: string, modelId: string): void {
|
||||
updateSessionModel(sessionId: string, modelId: string, scope: ActorTenantScope): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.assertSessionScope(session, scope);
|
||||
const prev = session.modelId;
|
||||
session.modelId = modelId;
|
||||
this.recordModelSwitch(sessionId);
|
||||
@@ -572,48 +643,51 @@ export class AgentService implements OnModuleDestroy {
|
||||
sessionId: string,
|
||||
agentConfigId: string,
|
||||
agentName: string,
|
||||
scope: ActorTenantScope,
|
||||
modelId?: string,
|
||||
): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.assertSessionScope(session, scope);
|
||||
session.agentConfigId = agentConfigId;
|
||||
session.agentName = agentName;
|
||||
if (modelId) {
|
||||
this.updateSessionModel(sessionId, modelId);
|
||||
this.updateSessionModel(sessionId, modelId, scope);
|
||||
}
|
||||
this.logger.log(
|
||||
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
|
||||
);
|
||||
}
|
||||
|
||||
addChannel(sessionId: string, channel: string): void {
|
||||
addChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.channels.add(channel);
|
||||
}
|
||||
if (!session) return;
|
||||
this.assertSessionScope(session, scope);
|
||||
session.channels.add(channel);
|
||||
}
|
||||
|
||||
removeChannel(sessionId: string, channel: string): void {
|
||||
removeChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.channels.delete(channel);
|
||||
}
|
||||
if (!session) return;
|
||||
this.assertSessionScope(session, scope);
|
||||
session.channels.delete(channel);
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, message: string): Promise<void> {
|
||||
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`No agent session found: ${sessionId}`);
|
||||
}
|
||||
this.assertSessionScope(session, scope);
|
||||
session.promptCount += 1;
|
||||
|
||||
// Prepend session-scoped system override if present (renew TTL on each turn)
|
||||
let effectiveMessage = message;
|
||||
if (this.systemOverride) {
|
||||
const override = await this.systemOverride.get(sessionId);
|
||||
const override = await this.systemOverride.get(sessionId, scope);
|
||||
if (override) {
|
||||
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
|
||||
await this.systemOverride.renew(sessionId);
|
||||
await this.systemOverride.renew(sessionId, scope);
|
||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||
}
|
||||
}
|
||||
@@ -629,16 +703,28 @@ export class AgentService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void {
|
||||
onEvent(
|
||||
sessionId: string,
|
||||
listener: (event: AgentSessionEvent) => void,
|
||||
scope: ActorTenantScope,
|
||||
): () => void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`No agent session found: ${sessionId}`);
|
||||
}
|
||||
this.assertSessionScope(session, scope);
|
||||
session.listeners.add(listener);
|
||||
return () => session.listeners.delete(listener);
|
||||
}
|
||||
|
||||
async destroySession(sessionId: string): Promise<void> {
|
||||
async destroySession(sessionId: string, scope: ActorTenantScope): 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);
|
||||
if (!session) return;
|
||||
this.logger.log(`Destroying agent session ${sessionId}`);
|
||||
@@ -667,7 +753,7 @@ export class AgentService implements OnModuleDestroy {
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
this.logger.log('Shutting down all agent sessions');
|
||||
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id));
|
||||
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySessionForSystem(id));
|
||||
const results = await Promise.allSettled(stops);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
|
||||
10
apps/gateway/src/agent/durable-session.dto.ts
Normal file
10
apps/gateway/src/agent/durable-session.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
|
||||
|
||||
/** Server-side request for a replay-safe provider message. */
|
||||
export interface ProviderOutboxDto {
|
||||
sessionId: string;
|
||||
idempotencyKey: string;
|
||||
correlationId: string;
|
||||
content: string;
|
||||
context: RuntimeProviderRequestContext;
|
||||
}
|
||||
416
apps/gateway/src/agent/durable-session.repository.test.ts
Normal file
416
apps/gateway/src/agent/durable-session.repository.test.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/db';
|
||||
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
|
||||
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||
import { DurableSessionService } from './durable-session.service.js';
|
||||
|
||||
const IDENTITY: DurableSessionIdentity = {
|
||||
agentName: 'Nova',
|
||||
sessionId: 'tess-pglite-session',
|
||||
tenantId: 'tenant-pglite',
|
||||
ownerId: 'tess-owner',
|
||||
providerId: 'fleet',
|
||||
runtimeSessionId: 'nova',
|
||||
};
|
||||
|
||||
describe('DurableSessionRepository', () => {
|
||||
let dataDir: string | undefined;
|
||||
let handle: DbHandle;
|
||||
let previousAuthSecret: string | undefined;
|
||||
|
||||
beforeAll(async (): Promise<void> => {
|
||||
previousAuthSecret = process.env['BETTER_AUTH_SECRET'];
|
||||
process.env['BETTER_AUTH_SECRET'] = 'tess-durable-state-test-sealing-key';
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'tess-durable-state-'));
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
await seedOwner(handle);
|
||||
}, 30_000);
|
||||
|
||||
beforeEach(async (): Promise<void> => {
|
||||
await handle.db.execute(sql`DELETE FROM interaction_handoffs`);
|
||||
await handle.db.execute(sql`DELETE FROM interaction_checkpoints`);
|
||||
await handle.db.execute(sql`DELETE FROM interaction_inbox`);
|
||||
await handle.db.execute(sql`DELETE FROM interaction_outbox`);
|
||||
await handle.db.execute(sql`DELETE FROM interaction_sessions`);
|
||||
});
|
||||
|
||||
afterAll(async (): Promise<void> => {
|
||||
await handle.close();
|
||||
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
|
||||
if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET'];
|
||||
else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret;
|
||||
});
|
||||
|
||||
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
|
||||
const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||
await beforeRestart.create(IDENTITY);
|
||||
await beforeRestart.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'inbox-before-kill',
|
||||
correlationId: 'correlation-before-kill',
|
||||
content: 'resume after a kill',
|
||||
});
|
||||
await beforeRestart.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'outbox-before-kill',
|
||||
correlationId: 'correlation-before-kill',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'one response only',
|
||||
});
|
||||
await beforeRestart.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-before-kill',
|
||||
cursor: 'cursor-before-kill',
|
||||
summary: 'restart-safe state',
|
||||
compactionEpoch: 1,
|
||||
});
|
||||
await beforeRestart.handoff({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
handoffId: 'handoff-before-kill',
|
||||
destination: 'mos',
|
||||
correlationId: 'correlation-before-kill',
|
||||
checkpointId: 'checkpoint-before-kill',
|
||||
status: 'pending',
|
||||
});
|
||||
await beforeRestart.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-after-handoff',
|
||||
cursor: 'cursor-after-handoff',
|
||||
summary: 'newer state cannot strand the portable handoff',
|
||||
compactionEpoch: 2,
|
||||
});
|
||||
|
||||
await handle.close();
|
||||
handle = createPgliteDb(dataDir!);
|
||||
|
||||
const afterRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||
const recovered = await afterRestart.recover(IDENTITY.sessionId);
|
||||
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
|
||||
const handled: string[] = [];
|
||||
const effects: string[] = [];
|
||||
|
||||
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||
handled.push(entry.idempotencyKey);
|
||||
});
|
||||
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||
effects.push(entry.idempotencyKey);
|
||||
});
|
||||
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||
handled.push(entry.idempotencyKey);
|
||||
});
|
||||
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||
effects.push(entry.idempotencyKey);
|
||||
});
|
||||
|
||||
expect(recovered.identity).toEqual(IDENTITY);
|
||||
expect(recovered.checkpoint).toMatchObject({ checkpointId: 'checkpoint-after-handoff' });
|
||||
expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-before-kill' }]);
|
||||
expect(resumedHandoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-before-kill' });
|
||||
expect(handled).toEqual(['inbox-before-kill']);
|
||||
expect(effects).toEqual(['outbox-before-kill']);
|
||||
}, 30_000);
|
||||
|
||||
it('redacts sensitive durable payloads before persistence', async () => {
|
||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||
await coordinator.create(IDENTITY);
|
||||
await coordinator.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'redacted-inbox',
|
||||
correlationId: 'correlation-redaction',
|
||||
content: 'api_key=super-secret-canary',
|
||||
});
|
||||
await coordinator.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'redacted-outbox',
|
||||
correlationId: 'correlation-redaction',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'email operator@example.test api_key=super-secret-canary',
|
||||
});
|
||||
await coordinator.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'redacted-checkpoint',
|
||||
cursor: 'bearer super-secret-canary',
|
||||
summary: 'email operator@example.test',
|
||||
compactionEpoch: 0,
|
||||
});
|
||||
|
||||
const snapshot = await coordinator.snapshot(IDENTITY.sessionId);
|
||||
const [persisted] = await handle.db
|
||||
.select({ content: interactionInbox.content })
|
||||
.from(interactionInbox)
|
||||
.where(eq(interactionInbox.idempotencyKey, 'redacted-inbox'));
|
||||
|
||||
expect(JSON.stringify(snapshot)).not.toContain('super-secret-canary');
|
||||
expect(JSON.stringify(snapshot)).not.toContain('operator@example.test');
|
||||
expect(persisted?.content).not.toContain('super-secret-canary');
|
||||
expect(persisted?.content).not.toContain('[REDACTED]');
|
||||
}, 30_000);
|
||||
|
||||
it('fails closed when the configured idempotency secret is unavailable', async () => {
|
||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||
await coordinator.create(IDENTITY);
|
||||
const secret = process.env['BETTER_AUTH_SECRET'];
|
||||
delete process.env['BETTER_AUTH_SECRET'];
|
||||
try {
|
||||
await expect(
|
||||
coordinator.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'requires-idempotency-secret',
|
||||
correlationId: 'correlation-secret',
|
||||
content: 'sensitive payload',
|
||||
}),
|
||||
).rejects.toThrow(/required for durable idempotency digests/);
|
||||
} finally {
|
||||
if (secret === undefined) delete process.env['BETTER_AUTH_SECRET'];
|
||||
else process.env['BETTER_AUTH_SECRET'] = secret;
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
|
||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||
await coordinator.create(IDENTITY);
|
||||
const input = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-secret-conflict',
|
||||
cursor: 'api_key=secret-one',
|
||||
summary: 'bearer secret-one',
|
||||
compactionEpoch: 1,
|
||||
};
|
||||
await coordinator.checkpoint(input);
|
||||
|
||||
await expect(
|
||||
coordinator.checkpoint({
|
||||
...input,
|
||||
cursor: 'api_key=secret-two',
|
||||
summary: 'bearer secret-two',
|
||||
}),
|
||||
).rejects.toThrow(/checkpoint identity conflict/);
|
||||
|
||||
const [persisted] = await handle.db
|
||||
.select({
|
||||
digest: interactionCheckpoints.contentDigest,
|
||||
cursor: interactionCheckpoints.cursor,
|
||||
})
|
||||
.from(interactionCheckpoints)
|
||||
.where(eq(interactionCheckpoints.checkpointId, input.checkpointId));
|
||||
expect(persisted?.cursor).not.toContain('secret-one');
|
||||
expect(persisted?.digest).not.toBe(
|
||||
createHash('sha256')
|
||||
.update(JSON.stringify([input.cursor, input.summary]))
|
||||
.digest('hex'),
|
||||
);
|
||||
|
||||
await coordinator.checkpoint({
|
||||
...input,
|
||||
checkpointId: 'checkpoint-delimiter-conflict',
|
||||
cursor: 'a\u0000b',
|
||||
summary: 'c',
|
||||
});
|
||||
await expect(
|
||||
coordinator.checkpoint({
|
||||
...input,
|
||||
checkpointId: 'checkpoint-delimiter-conflict',
|
||||
cursor: 'a',
|
||||
summary: 'b\u0000c',
|
||||
}),
|
||||
).rejects.toThrow(/checkpoint identity conflict/);
|
||||
}, 30_000);
|
||||
|
||||
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
|
||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||
await coordinator.create(IDENTITY);
|
||||
const inbox = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'inbox-secret-conflict',
|
||||
correlationId: 'correlation-inbox-secret',
|
||||
content: 'api_key=secret-one',
|
||||
};
|
||||
const outbox = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'outbox-secret-conflict',
|
||||
correlationId: 'correlation-outbox-secret',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'api_key=secret-one',
|
||||
};
|
||||
await coordinator.receive(inbox);
|
||||
await coordinator.enqueueOutbox(outbox);
|
||||
|
||||
await expect(coordinator.receive({ ...inbox, content: 'api_key=secret-two' })).rejects.toThrow(
|
||||
/idempotency conflict/,
|
||||
);
|
||||
await expect(
|
||||
coordinator.enqueueOutbox({ ...outbox, content: 'api_key=secret-two' }),
|
||||
).rejects.toThrow(/idempotency conflict/);
|
||||
}, 30_000);
|
||||
|
||||
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
|
||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||
await coordinator.create(IDENTITY);
|
||||
await coordinator.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'inbox-conflict',
|
||||
correlationId: 'correlation-inbox',
|
||||
content: 'original inbox',
|
||||
});
|
||||
await coordinator.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'outbox-conflict',
|
||||
correlationId: 'correlation-outbox',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'original outbox',
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'inbox-conflict',
|
||||
correlationId: 'forged-correlation',
|
||||
content: 'original inbox',
|
||||
}),
|
||||
).rejects.toThrow(/idempotency conflict/);
|
||||
await expect(
|
||||
coordinator.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'outbox-conflict',
|
||||
correlationId: 'correlation-outbox',
|
||||
channelId: 'forged-channel',
|
||||
kind: 'provider.send',
|
||||
content: 'original outbox',
|
||||
}),
|
||||
).rejects.toThrow(/idempotency conflict/);
|
||||
}, 30_000);
|
||||
|
||||
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
|
||||
const repository = new DurableSessionRepository(handle.db);
|
||||
const coordinator = new DurableSessionCoordinator(repository);
|
||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||
const service = new DurableSessionService(repository, runtimeProviders as never);
|
||||
const input = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'live-effect',
|
||||
correlationId: 'correlation-live',
|
||||
content: 'must not duplicate',
|
||||
context: {
|
||||
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
|
||||
channelId: 'cli',
|
||||
correlationId: 'correlation-live',
|
||||
},
|
||||
};
|
||||
|
||||
await coordinator.create(IDENTITY);
|
||||
await service.queueProviderSend(input);
|
||||
expect(await repository.claimOutbox(IDENTITY.sessionId)).toMatchObject({
|
||||
status: 'processing',
|
||||
});
|
||||
|
||||
await service.dispatchProviderOutbox(IDENTITY.sessionId, input);
|
||||
await expect(
|
||||
service.recoverProviderSession(IDENTITY.sessionId, {
|
||||
...input,
|
||||
context: {
|
||||
...input.context,
|
||||
actorScope: { userId: 'intruder', tenantId: 'tenant-pglite' },
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/scope or correlation mismatch/);
|
||||
|
||||
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
|
||||
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
|
||||
outbox: [{ idempotencyKey: 'live-effect', status: 'processing' }],
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
|
||||
const repository = new DurableSessionRepository(handle.db);
|
||||
const coordinator = new DurableSessionCoordinator(repository);
|
||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||
const service = new DurableSessionService(repository, runtimeProviders as never);
|
||||
const input = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'mismatch-effect',
|
||||
correlationId: 'correlation-expected',
|
||||
content: 'must remain pending',
|
||||
context: {
|
||||
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
|
||||
channelId: 'cli',
|
||||
correlationId: 'correlation-expected',
|
||||
},
|
||||
};
|
||||
|
||||
await coordinator.create(IDENTITY);
|
||||
await service.queueProviderSend(input);
|
||||
await expect(
|
||||
service.dispatchProviderOutbox(IDENTITY.sessionId, {
|
||||
...input,
|
||||
correlationId: 'correlation-forged',
|
||||
context: { ...input.context, correlationId: 'correlation-forged' },
|
||||
}),
|
||||
).rejects.toThrow(/scope or correlation mismatch/);
|
||||
|
||||
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
|
||||
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
|
||||
outbox: [{ idempotencyKey: 'mismatch-effect', status: 'pending' }],
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
|
||||
const repository = new DurableSessionRepository(handle.db);
|
||||
const coordinator = new DurableSessionCoordinator(repository);
|
||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||
const service = new DurableSessionService(repository, runtimeProviders as never);
|
||||
const first = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'scoped-effect-one',
|
||||
correlationId: 'correlation-one',
|
||||
content: 'first result',
|
||||
context: {
|
||||
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
|
||||
channelId: 'cli',
|
||||
correlationId: 'correlation-one',
|
||||
},
|
||||
};
|
||||
const second = {
|
||||
...first,
|
||||
idempotencyKey: 'scoped-effect-two',
|
||||
correlationId: 'correlation-two',
|
||||
content: 'second result',
|
||||
context: { ...first.context, correlationId: 'correlation-two' },
|
||||
};
|
||||
|
||||
await coordinator.create(IDENTITY);
|
||||
await service.queueProviderSend(first);
|
||||
await service.queueProviderSend(second);
|
||||
await service.dispatchProviderOutbox(IDENTITY.sessionId, first);
|
||||
|
||||
expect(runtimeProviders.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeProviders.sendMessage).toHaveBeenCalledWith(
|
||||
IDENTITY.providerId,
|
||||
IDENTITY.runtimeSessionId,
|
||||
{ content: 'first result', idempotencyKey: 'scoped-effect-one' },
|
||||
first.context,
|
||||
);
|
||||
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
|
||||
outbox: [
|
||||
{ idempotencyKey: 'scoped-effect-one', status: 'delivered' },
|
||||
{ idempotencyKey: 'scoped-effect-two', status: 'pending' },
|
||||
],
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
async function seedOwner(handle: DbHandle): Promise<void> {
|
||||
await handle.db.execute(sql`
|
||||
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
||||
VALUES ('tess-owner', 'Tess Owner', 'tess-owner@example.test', false, now(), now())
|
||||
`);
|
||||
}
|
||||
529
apps/gateway/src/agent/durable-session.repository.ts
Normal file
529
apps/gateway/src/agent/durable-session.repository.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
import { createHash, createHmac } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
interactionCheckpoints,
|
||||
interactionHandoffs,
|
||||
interactionInbox,
|
||||
interactionOutbox,
|
||||
interactionSessions,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import { seal, unseal } from '@mosaicstack/auth';
|
||||
import { redactSensitiveContent } from '@mosaicstack/log';
|
||||
import type {
|
||||
DurableCheckpoint,
|
||||
DurableCheckpointInput,
|
||||
DurableEnqueueResult,
|
||||
DurableHandoff,
|
||||
DurableHandoffInput,
|
||||
DurableInboxEntry,
|
||||
DurableInboxInput,
|
||||
DurableInboxStatus,
|
||||
DurableOutboxEntry,
|
||||
DurableOutboxInput,
|
||||
DurableOutboxStatus,
|
||||
DurableSessionIdentity,
|
||||
DurableSessionSnapshot,
|
||||
DurableSessionStore,
|
||||
} from '@mosaicstack/agent';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
@Injectable()
|
||||
export class DurableSessionRepository implements DurableSessionStore {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async create(identity: DurableSessionIdentity): Promise<void> {
|
||||
await this.db
|
||||
.insert(interactionSessions)
|
||||
.values({
|
||||
id: identity.sessionId,
|
||||
agentName: identity.agentName,
|
||||
tenantId: identity.tenantId,
|
||||
ownerId: identity.ownerId,
|
||||
providerId: identity.providerId,
|
||||
runtimeSessionId: identity.runtimeSessionId,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
const existing = await this.session(identity.sessionId);
|
||||
if (!existing || !sameEnrollmentScope(existing, identity)) {
|
||||
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
|
||||
}
|
||||
// A recovered/re-enrolled runtime can receive a new provider session ID;
|
||||
// the conversation handle and owner scope remain immutable.
|
||||
if (
|
||||
existing.providerId !== identity.providerId ||
|
||||
existing.runtimeSessionId !== identity.runtimeSessionId
|
||||
) {
|
||||
await this.db
|
||||
.update(interactionSessions)
|
||||
.set({ providerId: identity.providerId, runtimeSessionId: identity.runtimeSessionId })
|
||||
.where(eq(interactionSessions.id, identity.sessionId));
|
||||
}
|
||||
}
|
||||
|
||||
async snapshot(sessionId: string): Promise<DurableSessionSnapshot | null> {
|
||||
const identity = await this.session(sessionId);
|
||||
if (!identity) return null;
|
||||
|
||||
const [inbox, outbox, checkpoints, handoffs] = await Promise.all([
|
||||
this.db
|
||||
.select()
|
||||
.from(interactionInbox)
|
||||
.where(eq(interactionInbox.sessionId, sessionId))
|
||||
.orderBy(asc(interactionInbox.createdAt)),
|
||||
this.db
|
||||
.select()
|
||||
.from(interactionOutbox)
|
||||
.where(eq(interactionOutbox.sessionId, sessionId))
|
||||
.orderBy(asc(interactionOutbox.createdAt)),
|
||||
this.db
|
||||
.select()
|
||||
.from(interactionCheckpoints)
|
||||
.where(eq(interactionCheckpoints.sessionId, sessionId))
|
||||
.orderBy(
|
||||
desc(interactionCheckpoints.compactionEpoch),
|
||||
desc(interactionCheckpoints.createdAt),
|
||||
)
|
||||
.limit(1),
|
||||
this.db
|
||||
.select()
|
||||
.from(interactionHandoffs)
|
||||
.where(eq(interactionHandoffs.sessionId, sessionId))
|
||||
.orderBy(asc(interactionHandoffs.createdAt)),
|
||||
]);
|
||||
|
||||
const checkpoint = checkpoints[0];
|
||||
return {
|
||||
identity,
|
||||
inbox: inbox.map(toInbox),
|
||||
outbox: outbox.map(toOutbox),
|
||||
...(checkpoint ? { checkpoint: toCheckpoint(checkpoint) } : {}),
|
||||
handoffs: handoffs.map(toHandoff),
|
||||
};
|
||||
}
|
||||
|
||||
async enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
|
||||
const digest = contentDigest(input.content);
|
||||
const record: DurableInboxInput = {
|
||||
...input,
|
||||
content: redactSensitiveContent(input.content).content,
|
||||
};
|
||||
const inserted = await this.db
|
||||
.insert(interactionInbox)
|
||||
.values({
|
||||
...record,
|
||||
content: seal(record.content),
|
||||
contentDigest: digest,
|
||||
status: 'pending',
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ status: interactionInbox.status });
|
||||
if (inserted[0]) return { accepted: true, status: inserted[0].status };
|
||||
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(interactionInbox)
|
||||
.where(
|
||||
and(
|
||||
eq(interactionInbox.sessionId, input.sessionId),
|
||||
eq(interactionInbox.idempotencyKey, input.idempotencyKey),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing[0]) throw new Error(`Durable inbox enqueue failed: ${input.idempotencyKey}`);
|
||||
const entry = toInbox(existing[0]);
|
||||
if (
|
||||
!sameInbox(entry, record) ||
|
||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
||||
) {
|
||||
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
|
||||
}
|
||||
return { accepted: false, status: entry.status };
|
||||
}
|
||||
|
||||
async claimInbox(sessionId: string): Promise<DurableInboxEntry | null> {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const candidate = await this.db
|
||||
.select()
|
||||
.from(interactionInbox)
|
||||
.where(
|
||||
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'pending')),
|
||||
)
|
||||
.orderBy(asc(interactionInbox.createdAt))
|
||||
.limit(1);
|
||||
const entry = candidate[0];
|
||||
if (!entry) return null;
|
||||
const claimed = await this.db
|
||||
.update(interactionInbox)
|
||||
.set({ status: 'processing', updatedAt: new Date() })
|
||||
.where(and(eq(interactionInbox.id, entry.id), eq(interactionInbox.status, 'pending')))
|
||||
.returning();
|
||||
if (claimed[0]) return toInbox(claimed[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async completeInbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||
await this.db
|
||||
.update(interactionInbox)
|
||||
.set({ status: 'processed', updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(interactionInbox.sessionId, sessionId),
|
||||
eq(interactionInbox.idempotencyKey, idempotencyKey),
|
||||
eq(interactionInbox.status, 'processing'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async releaseInbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||
await this.db
|
||||
.update(interactionInbox)
|
||||
.set({ status: 'pending', updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(interactionInbox.sessionId, sessionId),
|
||||
eq(interactionInbox.idempotencyKey, idempotencyKey),
|
||||
eq(interactionInbox.status, 'processing'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async enqueueOutbox(
|
||||
input: DurableOutboxInput,
|
||||
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
|
||||
const digest = contentDigest(input.content);
|
||||
const record: DurableOutboxInput = {
|
||||
...input,
|
||||
content: redactSensitiveContent(input.content).content,
|
||||
};
|
||||
const inserted = await this.db
|
||||
.insert(interactionOutbox)
|
||||
.values({
|
||||
...record,
|
||||
content: seal(record.content),
|
||||
contentDigest: digest,
|
||||
status: 'pending',
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ status: interactionOutbox.status });
|
||||
if (inserted[0]) return { accepted: true, status: inserted[0].status };
|
||||
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(interactionOutbox)
|
||||
.where(
|
||||
and(
|
||||
eq(interactionOutbox.sessionId, input.sessionId),
|
||||
eq(interactionOutbox.idempotencyKey, input.idempotencyKey),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`);
|
||||
const entry = toOutbox(existing[0]);
|
||||
if (
|
||||
!sameOutbox(entry, record) ||
|
||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
||||
) {
|
||||
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
|
||||
}
|
||||
return { accepted: false, status: entry.status };
|
||||
}
|
||||
|
||||
async claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null> {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const candidate = await this.db
|
||||
.select()
|
||||
.from(interactionOutbox)
|
||||
.where(
|
||||
and(eq(interactionOutbox.sessionId, sessionId), eq(interactionOutbox.status, 'pending')),
|
||||
)
|
||||
.orderBy(asc(interactionOutbox.createdAt))
|
||||
.limit(1);
|
||||
const entry = candidate[0];
|
||||
if (!entry) return null;
|
||||
const claimed = await this.db
|
||||
.update(interactionOutbox)
|
||||
.set({ status: 'processing', updatedAt: new Date() })
|
||||
.where(and(eq(interactionOutbox.id, entry.id), eq(interactionOutbox.status, 'pending')))
|
||||
.returning();
|
||||
if (claimed[0]) return toOutbox(claimed[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async claimOutboxByKey(
|
||||
sessionId: string,
|
||||
idempotencyKey: string,
|
||||
): Promise<DurableOutboxEntry | null> {
|
||||
const claimed = await this.db
|
||||
.update(interactionOutbox)
|
||||
.set({ status: 'processing', updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(interactionOutbox.sessionId, sessionId),
|
||||
eq(interactionOutbox.idempotencyKey, idempotencyKey),
|
||||
eq(interactionOutbox.status, 'pending'),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return claimed[0] ? toOutbox(claimed[0]) : null;
|
||||
}
|
||||
|
||||
async completeOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||
await this.db
|
||||
.update(interactionOutbox)
|
||||
.set({ status: 'delivered', updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(interactionOutbox.sessionId, sessionId),
|
||||
eq(interactionOutbox.idempotencyKey, idempotencyKey),
|
||||
eq(interactionOutbox.status, 'processing'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||
await this.db
|
||||
.update(interactionOutbox)
|
||||
.set({ status: 'pending', updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(interactionOutbox.sessionId, sessionId),
|
||||
eq(interactionOutbox.idempotencyKey, idempotencyKey),
|
||||
eq(interactionOutbox.status, 'processing'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async checkpoint(input: DurableCheckpointInput): Promise<void> {
|
||||
// Compute identity before redaction. The persisted digest is keyed so a database
|
||||
// reader cannot use it as an offline oracle for sensitive cursor/summary values.
|
||||
const digest = contentDigest(JSON.stringify([input.cursor, input.summary]));
|
||||
const checkpoint: DurableCheckpointInput = {
|
||||
...input,
|
||||
cursor: redactSensitiveContent(input.cursor).content,
|
||||
summary: redactSensitiveContent(input.summary).content,
|
||||
};
|
||||
const inserted = await this.db
|
||||
.insert(interactionCheckpoints)
|
||||
.values({
|
||||
...checkpoint,
|
||||
contentDigest: digest,
|
||||
cursor: seal(checkpoint.cursor),
|
||||
summary: seal(checkpoint.summary),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ checkpointId: interactionCheckpoints.checkpointId });
|
||||
if (inserted[0]) return;
|
||||
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(interactionCheckpoints)
|
||||
.where(
|
||||
and(
|
||||
eq(interactionCheckpoints.sessionId, input.sessionId),
|
||||
eq(interactionCheckpoints.checkpointId, input.checkpointId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (
|
||||
!existing[0] ||
|
||||
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
|
||||
!matchesCheckpointDigest(existing[0].contentDigest, digest)
|
||||
) {
|
||||
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null> {
|
||||
const checkpoints = await this.db
|
||||
.select()
|
||||
.from(interactionCheckpoints)
|
||||
.where(
|
||||
and(
|
||||
eq(interactionCheckpoints.sessionId, sessionId),
|
||||
eq(interactionCheckpoints.checkpointId, checkpointId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const checkpoint = checkpoints[0];
|
||||
return checkpoint ? toCheckpoint(checkpoint) : null;
|
||||
}
|
||||
|
||||
async handoff(input: DurableHandoffInput): Promise<void> {
|
||||
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
|
||||
if (!checkpoint) {
|
||||
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
|
||||
}
|
||||
const inserted = await this.db
|
||||
.insert(interactionHandoffs)
|
||||
.values({ ...input })
|
||||
.onConflictDoNothing()
|
||||
.returning({ handoffId: interactionHandoffs.handoffId });
|
||||
if (inserted[0]) return;
|
||||
|
||||
const existing = await this.findHandoff(input.handoffId);
|
||||
if (!existing || !sameHandoff(existing, input)) {
|
||||
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async findHandoff(handoffId: string): Promise<DurableHandoff | null> {
|
||||
const handoffs = await this.db
|
||||
.select()
|
||||
.from(interactionHandoffs)
|
||||
.where(eq(interactionHandoffs.handoffId, handoffId))
|
||||
.limit(1);
|
||||
const handoff = handoffs[0];
|
||||
return handoff ? toHandoff(handoff) : null;
|
||||
}
|
||||
|
||||
async requeueInFlight(sessionId: string): Promise<void> {
|
||||
// Inbox handlers are process-local work. A provider outbox claim may have
|
||||
// reached an external target before a crash, so it is deliberately not
|
||||
// replayed by generic recovery.
|
||||
await this.db
|
||||
.update(interactionInbox)
|
||||
.set({ status: 'pending', updatedAt: new Date() })
|
||||
.where(
|
||||
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'processing')),
|
||||
);
|
||||
}
|
||||
|
||||
private async session(sessionId: string): Promise<DurableSessionIdentity | null> {
|
||||
const sessions = await this.db
|
||||
.select()
|
||||
.from(interactionSessions)
|
||||
.where(eq(interactionSessions.id, sessionId))
|
||||
.limit(1);
|
||||
const session = sessions[0];
|
||||
return session
|
||||
? {
|
||||
agentName: session.agentName,
|
||||
sessionId: session.id,
|
||||
tenantId: session.tenantId,
|
||||
ownerId: session.ownerId,
|
||||
providerId: session.providerId,
|
||||
runtimeSessionId: session.runtimeSessionId,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
function contentDigest(content: string): string {
|
||||
const secret = process.env['BETTER_AUTH_SECRET'];
|
||||
if (!secret) {
|
||||
throw new Error('BETTER_AUTH_SECRET is required for durable idempotency digests');
|
||||
}
|
||||
return `hmac:v1:${createHmac('sha256', secret).update(content).digest('hex')}`;
|
||||
}
|
||||
|
||||
function matchesContentDigest(stored: string, content: string): boolean {
|
||||
return (
|
||||
stored === contentDigest(content) ||
|
||||
stored === createHash('sha256').update(content).digest('hex')
|
||||
);
|
||||
}
|
||||
|
||||
function matchesCheckpointDigest(stored: string, digest: string): boolean {
|
||||
// Legacy rows predate any pre-redaction identity and cannot safely prove equality.
|
||||
// Reject rather than let redaction collapse distinct sensitive checkpoint payloads.
|
||||
return stored === digest;
|
||||
}
|
||||
|
||||
function sameEnrollmentScope(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean {
|
||||
return (
|
||||
left.agentName === right.agentName &&
|
||||
left.sessionId === right.sessionId &&
|
||||
left.tenantId === right.tenantId &&
|
||||
left.ownerId === right.ownerId
|
||||
);
|
||||
}
|
||||
|
||||
function sameInbox(left: DurableInboxEntry, right: DurableInboxInput): boolean {
|
||||
return (
|
||||
left.sessionId === right.sessionId &&
|
||||
left.idempotencyKey === right.idempotencyKey &&
|
||||
left.correlationId === right.correlationId &&
|
||||
left.content === right.content
|
||||
);
|
||||
}
|
||||
|
||||
function sameOutbox(left: DurableOutboxEntry, right: DurableOutboxInput): boolean {
|
||||
return (
|
||||
left.sessionId === right.sessionId &&
|
||||
left.idempotencyKey === right.idempotencyKey &&
|
||||
left.correlationId === right.correlationId &&
|
||||
left.channelId === right.channelId &&
|
||||
left.kind === right.kind &&
|
||||
left.content === right.content
|
||||
);
|
||||
}
|
||||
|
||||
function sameCheckpoint(left: DurableCheckpoint, right: DurableCheckpointInput): boolean {
|
||||
return (
|
||||
left.sessionId === right.sessionId &&
|
||||
left.checkpointId === right.checkpointId &&
|
||||
left.compactionEpoch === right.compactionEpoch
|
||||
);
|
||||
}
|
||||
|
||||
function sameHandoff(left: DurableHandoff, right: DurableHandoffInput): boolean {
|
||||
return (
|
||||
left.sessionId === right.sessionId &&
|
||||
left.handoffId === right.handoffId &&
|
||||
left.destination === right.destination &&
|
||||
left.correlationId === right.correlationId &&
|
||||
left.checkpointId === right.checkpointId &&
|
||||
left.status === right.status
|
||||
);
|
||||
}
|
||||
|
||||
function toInbox(row: typeof interactionInbox.$inferSelect): DurableInboxEntry {
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
idempotencyKey: row.idempotencyKey,
|
||||
correlationId: row.correlationId,
|
||||
content: unseal(row.content),
|
||||
status: row.status,
|
||||
};
|
||||
}
|
||||
|
||||
function toOutbox(row: typeof interactionOutbox.$inferSelect): DurableOutboxEntry {
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
idempotencyKey: row.idempotencyKey,
|
||||
correlationId: row.correlationId,
|
||||
channelId: row.channelId,
|
||||
kind: row.kind,
|
||||
content: unseal(row.content),
|
||||
status: row.status,
|
||||
};
|
||||
}
|
||||
|
||||
function toCheckpoint(row: typeof interactionCheckpoints.$inferSelect): DurableCheckpoint {
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
checkpointId: row.checkpointId,
|
||||
cursor: unseal(row.cursor),
|
||||
summary: unseal(row.summary),
|
||||
compactionEpoch: row.compactionEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
function toHandoff(row: typeof interactionHandoffs.$inferSelect): DurableHandoff {
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
handoffId: row.handoffId,
|
||||
destination: row.destination,
|
||||
correlationId: row.correlationId,
|
||||
checkpointId: row.checkpointId,
|
||||
status: row.status,
|
||||
};
|
||||
}
|
||||
123
apps/gateway/src/agent/durable-session.service.ts
Normal file
123
apps/gateway/src/agent/durable-session.service.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||
import type { ProviderOutboxDto } from './durable-session.dto.js';
|
||||
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||
import {
|
||||
RuntimeProviderService,
|
||||
type RuntimeProviderRequestContext,
|
||||
} from './runtime-provider-registry.service.js';
|
||||
|
||||
/**
|
||||
* Scoped gateway boundary for the canonical durable session state machine. It deliberately
|
||||
* uses composition: raw state methods cannot be injected into channel, CLI, or
|
||||
* MCP adapters without a server-derived actor/tenant/correlation context.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DurableSessionService {
|
||||
private readonly coordinator: DurableSessionCoordinator;
|
||||
|
||||
constructor(
|
||||
@Inject(DurableSessionRepository) repository: DurableSessionRepository,
|
||||
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
|
||||
) {
|
||||
this.coordinator = new DurableSessionCoordinator(repository);
|
||||
}
|
||||
|
||||
/** Enroll a verified runtime session under the stable cross-surface conversation handle. */
|
||||
async enroll(
|
||||
identity: DurableSessionIdentity,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): Promise<void> {
|
||||
if (
|
||||
identity.ownerId !== context.actorScope.userId ||
|
||||
identity.tenantId !== context.actorScope.tenantId
|
||||
) {
|
||||
throw new ForbiddenException('Durable session enrollment scope mismatch');
|
||||
}
|
||||
await this.coordinator.create(identity);
|
||||
}
|
||||
|
||||
async queueProviderSend(input: ProviderOutboxDto): Promise<void> {
|
||||
const snapshot = await this.coordinator.snapshot(input.sessionId);
|
||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||
await this.coordinator.enqueueOutbox({
|
||||
sessionId: input.sessionId,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
correlationId: input.correlationId,
|
||||
channelId: input.context.channelId,
|
||||
kind: 'provider.send',
|
||||
content: input.content,
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchProviderOutbox(sessionId: string, input: ProviderOutboxDto): Promise<void> {
|
||||
if (sessionId !== input.sessionId) {
|
||||
throw new ForbiddenException('Durable outbox session mismatch');
|
||||
}
|
||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||
const pendingEntry = snapshot.outbox.find(
|
||||
(entry): boolean => entry.idempotencyKey === input.idempotencyKey,
|
||||
);
|
||||
if (!pendingEntry) return;
|
||||
// Validate immutable routing before claiming. A caller with a mismatched
|
||||
// correlation/channel must not strand a pending external side effect.
|
||||
this.assertOutboxScope(pendingEntry, input);
|
||||
await this.coordinator.dispatchOutboxEntry(
|
||||
sessionId,
|
||||
input.idempotencyKey,
|
||||
async (entry): Promise<void> => {
|
||||
this.assertOutboxScope(entry, input);
|
||||
await this.runtimeProviders.sendMessage(
|
||||
snapshot.identity.providerId,
|
||||
snapshot.identity.runtimeSessionId,
|
||||
{ content: entry.content, idempotencyKey: entry.idempotencyKey },
|
||||
input.context,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Read durable identity/state only after deriving and checking the server-side actor scope. */
|
||||
async getSnapshot(sessionId: string, context: RuntimeProviderRequestContext) {
|
||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, {
|
||||
sessionId,
|
||||
content: '',
|
||||
idempotencyKey: 'read-only',
|
||||
correlationId: context.correlationId,
|
||||
context,
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
|
||||
async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): Promise<void> {
|
||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||
await this.coordinator.recover(sessionId);
|
||||
}
|
||||
|
||||
private assertOutboxScope(
|
||||
entry: { kind: string; correlationId: string; channelId: string },
|
||||
input: ProviderOutboxDto,
|
||||
): void {
|
||||
if (
|
||||
entry.kind !== 'provider.send' ||
|
||||
entry.correlationId !== input.correlationId ||
|
||||
entry.channelId !== input.context.channelId
|
||||
) {
|
||||
throw new ForbiddenException('Durable outbox scope or correlation mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
private assertScope(ownerId: string, tenantId: string, input: ProviderOutboxDto): void {
|
||||
if (
|
||||
input.context.actorScope.userId !== ownerId ||
|
||||
input.context.actorScope.tenantId !== tenantId ||
|
||||
input.context.correlationId !== input.correlationId
|
||||
) {
|
||||
throw new ForbiddenException('Durable session scope or correlation mismatch');
|
||||
}
|
||||
}
|
||||
}
|
||||
144
apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts
Normal file
144
apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { HermesRuntimeProvider } from '@mosaicstack/agent';
|
||||
import { AgentModule } from './agent.module.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { CoordModule } from '../coord/coord.module.js';
|
||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||
import { SkillsModule } from '../skills/skills.module.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
import { LogModule } from '../log/log.module.js';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import {
|
||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
RUNTIME_APPROVAL_VERIFIER,
|
||||
RUNTIME_PROVIDER_AUDIT_SINK,
|
||||
RuntimeProviderAuditService,
|
||||
} from './runtime-provider-registry.service.js';
|
||||
import { DurableSessionService } from './durable-session.service.js';
|
||||
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||
import { AgentService } from './agent.service.js';
|
||||
import { ProviderService } from './provider.service.js';
|
||||
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
||||
import { RoutingService } from './routing.service.js';
|
||||
import { RoutingEngineService } from './routing/routing-engine.service.js';
|
||||
import { SkillLoaderService } from './skill-loader.service.js';
|
||||
|
||||
const authenticatedUser = { id: 'operator-1', tenantId: 'tenant-1' };
|
||||
|
||||
@Module({})
|
||||
class EmptyAgentDependencyModule {}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: AUTH,
|
||||
useValue: {
|
||||
api: {
|
||||
getSession: vi.fn(async ({ headers }: { headers: Headers }) =>
|
||||
headers.get('cookie') === 'session=trusted'
|
||||
? { user: authenticatedUser, session: { id: 'session-1' } }
|
||||
: null,
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
AuthGuard,
|
||||
{ provide: BRAIN, useValue: {} },
|
||||
{ provide: DB, useValue: {} },
|
||||
],
|
||||
exports: [AUTH, AuthGuard, BRAIN, DB],
|
||||
})
|
||||
class AuthenticatedRequestModule {}
|
||||
|
||||
/**
|
||||
* This is deliberately an HTTP test rather than a controller unit test: it
|
||||
* exercises AgentModule's actual provider factory, Nest DI, and AuthGuard.
|
||||
*/
|
||||
describe('Hermes runtime provider reachability', (): void => {
|
||||
let app: NestFastifyApplication | undefined;
|
||||
|
||||
beforeAll(async (): Promise<void> => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AuthenticatedRequestModule, AgentModule],
|
||||
})
|
||||
.overrideModule(CoordModule)
|
||||
.useModule(EmptyAgentDependencyModule)
|
||||
.overrideModule(McpClientModule)
|
||||
.useModule(EmptyAgentDependencyModule)
|
||||
.overrideModule(SkillsModule)
|
||||
.useModule(EmptyAgentDependencyModule)
|
||||
.overrideModule(GCModule)
|
||||
.useModule(EmptyAgentDependencyModule)
|
||||
.overrideModule(LogModule)
|
||||
.useModule(EmptyAgentDependencyModule)
|
||||
.overrideModule(CommandsModule)
|
||||
.useModule(EmptyAgentDependencyModule)
|
||||
.overrideProvider(RuntimeProviderAuditService)
|
||||
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
|
||||
.overrideProvider(RUNTIME_PROVIDER_AUDIT_SINK)
|
||||
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
|
||||
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
|
||||
.useValue({ consume: vi.fn().mockResolvedValue(false) })
|
||||
.overrideProvider(DurableSessionService)
|
||||
.useValue({})
|
||||
.overrideProvider(DurableSessionRepository)
|
||||
.useValue({})
|
||||
.overrideProvider(AgentService)
|
||||
.useValue({})
|
||||
.overrideProvider(ProviderService)
|
||||
.useValue({})
|
||||
.overrideProvider(ProviderCredentialsService)
|
||||
.useValue({})
|
||||
.overrideProvider(RoutingService)
|
||||
.useValue({})
|
||||
.overrideProvider(RoutingEngineService)
|
||||
.useValue({})
|
||||
.overrideProvider(SkillLoaderService)
|
||||
.useValue({})
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async (): Promise<void> => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('requires authentication and reaches the Hermes provider registered by AgentModule', async (): Promise<void> => {
|
||||
if (!app) throw new Error('Nest application did not initialize');
|
||||
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
|
||||
expect(registry.get('runtime.hermes')).toBeInstanceOf(HermesRuntimeProvider);
|
||||
|
||||
const denied = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
|
||||
headers: { 'x-correlation-id': 'correlation-1' },
|
||||
});
|
||||
expect(denied.statusCode).toBe(401);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
|
||||
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual([
|
||||
{ capability: 'kanban', status: 'unsupported' },
|
||||
{ capability: 'skills', status: 'unsupported' },
|
||||
{ capability: 'memory', status: 'unsupported' },
|
||||
{ capability: 'tools', status: 'unsupported' },
|
||||
{ capability: 'cron', status: 'unsupported' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
46
apps/gateway/src/agent/hermes-runtime.transport.test.ts
Normal file
46
apps/gateway/src/agent/hermes-runtime.transport.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
|
||||
|
||||
const scope = {
|
||||
actorId: 'owner-1',
|
||||
tenantId: 'tenant-1',
|
||||
channelId: 'cli',
|
||||
correlationId: 'correlation-1',
|
||||
};
|
||||
|
||||
describe('GatewayHermesRuntimeTransport', () => {
|
||||
it('preserves a configured path prefix and authenticates the concrete runtime request', async () => {
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify(['session.list']), { status: 200 }));
|
||||
const transport = new GatewayHermesRuntimeTransport(
|
||||
'https://runtime.example.test/hermes',
|
||||
'test-service-token',
|
||||
fetchFn,
|
||||
);
|
||||
|
||||
await expect(transport.capabilities(scope)).resolves.toEqual(['session.list']);
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
new URL('https://runtime.example.test/hermes/capabilities'),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
authorization: 'Bearer test-service-token',
|
||||
'x-mosaic-channel-id': 'cli',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-loopback HTTP runtime endpoints before sending identity headers', async () => {
|
||||
const fetchFn = vi.fn();
|
||||
const transport = new GatewayHermesRuntimeTransport(
|
||||
'http://runtime.example.test/hermes',
|
||||
'test-service-token',
|
||||
fetchFn,
|
||||
);
|
||||
|
||||
await expect(transport.capabilities(scope)).rejects.toThrow('requires HTTPS');
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
121
apps/gateway/src/agent/hermes-runtime.transport.ts
Normal file
121
apps/gateway/src/agent/hermes-runtime.transport.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { HermesLegacySession, HermesRuntimeTransport } from '@mosaicstack/agent';
|
||||
import type {
|
||||
RuntimeAttachHandle,
|
||||
RuntimeAttachMode,
|
||||
RuntimeMessage,
|
||||
RuntimeScope,
|
||||
RuntimeStreamEvent,
|
||||
} from '@mosaicstack/types';
|
||||
|
||||
/** Concrete HTTP transport for a configured legacy Hermes runtime endpoint. */
|
||||
export class GatewayHermesRuntimeTransport implements HermesRuntimeTransport {
|
||||
constructor(
|
||||
private readonly baseUrl = process.env['MOSAIC_HERMES_RUNTIME_URL']?.trim(),
|
||||
private readonly serviceToken = process.env['MOSAIC_HERMES_RUNTIME_TOKEN']?.trim(),
|
||||
private readonly fetchFn: typeof fetch = fetch,
|
||||
) {}
|
||||
|
||||
async capabilities(scope: RuntimeScope): Promise<string[]> {
|
||||
return this.request<string[]>('/capabilities', scope);
|
||||
}
|
||||
|
||||
async health(scope: RuntimeScope): Promise<{ status: string; detail?: string }> {
|
||||
return this.request<{ status: string; detail?: string }>('/health', scope);
|
||||
}
|
||||
|
||||
async sessions(scope: RuntimeScope): Promise<HermesLegacySession[]> {
|
||||
return this.request<HermesLegacySession[]>('/sessions', scope);
|
||||
}
|
||||
|
||||
async *stream(
|
||||
sessionId: string,
|
||||
cursor: string | undefined,
|
||||
scope: RuntimeScope,
|
||||
): AsyncIterable<RuntimeStreamEvent> {
|
||||
const params = new URLSearchParams(cursor ? { cursor } : {});
|
||||
const events = await this.request<RuntimeStreamEvent[]>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/stream?${params.toString()}`,
|
||||
scope,
|
||||
);
|
||||
yield* events;
|
||||
}
|
||||
|
||||
async send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void> {
|
||||
await this.request(`/sessions/${encodeURIComponent(sessionId)}/messages`, scope, {
|
||||
method: 'POST',
|
||||
body: message,
|
||||
});
|
||||
}
|
||||
|
||||
async attach(
|
||||
sessionId: string,
|
||||
mode: RuntimeAttachMode,
|
||||
scope: RuntimeScope,
|
||||
): Promise<RuntimeAttachHandle> {
|
||||
return this.request<RuntimeAttachHandle>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/attach`,
|
||||
scope,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { mode },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
|
||||
await this.request(`/attachments/${encodeURIComponent(attachmentId)}`, scope, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
|
||||
await this.request(`/sessions/${encodeURIComponent(sessionId)}/terminate`, scope, {
|
||||
method: 'POST',
|
||||
body: { approvalRef },
|
||||
});
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
path: string,
|
||||
scope: RuntimeScope,
|
||||
init: { method?: string; body?: unknown } = {},
|
||||
): Promise<T> {
|
||||
if (!this.baseUrl || !this.serviceToken) {
|
||||
throw new Error(
|
||||
'MOSAIC_HERMES_RUNTIME_URL and MOSAIC_HERMES_RUNTIME_TOKEN must configure Hermes transport',
|
||||
);
|
||||
}
|
||||
const endpoint = new URL(this.baseUrl);
|
||||
if (endpoint.protocol !== 'https:' && !isLoopbackHttp(endpoint)) {
|
||||
throw new Error('Hermes runtime transport requires HTTPS outside loopback');
|
||||
}
|
||||
const response = await this.fetchFn(
|
||||
new URL(path.replace(/^\//, ''), `${endpoint.toString().replace(/\/$/, '')}/`),
|
||||
{
|
||||
method: init.method ?? 'GET',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
authorization: `Bearer ${this.serviceToken}`,
|
||||
'x-mosaic-actor-id': scope.actorId,
|
||||
'x-mosaic-tenant-id': scope.tenantId,
|
||||
'x-mosaic-channel-id': scope.channelId,
|
||||
'x-correlation-id': scope.correlationId,
|
||||
...(init.body ? { 'content-type': 'application/json' } : {}),
|
||||
},
|
||||
...(init.body ? { body: JSON.stringify(init.body) } : {}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`Hermes runtime request failed: ${response.status}`);
|
||||
if (response.status === 204) return undefined as T;
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackHttp(endpoint: URL): boolean {
|
||||
return (
|
||||
endpoint.protocol === 'http:' &&
|
||||
(endpoint.hostname === 'localhost' ||
|
||||
endpoint.hostname === '127.0.0.1' ||
|
||||
endpoint.hostname === '::1')
|
||||
);
|
||||
}
|
||||
263
apps/gateway/src/agent/interaction.controller.test.ts
Normal file
263
apps/gateway/src/agent/interaction.controller.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { createGatewayRuntimeProviderRegistry } from './agent.module.js';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
RuntimeApprovalDeniedError,
|
||||
RuntimeProviderService,
|
||||
} from './runtime-provider-registry.service.js';
|
||||
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
||||
import { InteractionController } from './interaction.controller.js';
|
||||
|
||||
describe('InteractionController', (): void => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('maps a denied runtime approval to Fastify HTTP 403', () => {
|
||||
const send = vi.fn();
|
||||
const status = vi.fn().mockReturnValue({ send });
|
||||
const response = { status };
|
||||
const host = { switchToHttp: () => ({ getResponse: () => response }) };
|
||||
|
||||
new RuntimeApprovalDeniedFilter().catch(new RuntimeApprovalDeniedError(), host as never);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(403);
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
statusCode: 403,
|
||||
message: 'Runtime termination approval denied',
|
||||
});
|
||||
});
|
||||
|
||||
it('honors a differently named configured instance without a code change', async () => {
|
||||
const prior = process.env['MOSAIC_AGENT_NAME'];
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const runtime = { listSessions: vi.fn().mockResolvedValue([]) };
|
||||
const controller = new InteractionController(runtime as never, {} as never);
|
||||
|
||||
await expect(
|
||||
controller.sessions('Nova', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
controller.sessions('Other', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
|
||||
).rejects.toThrow('Interaction agent is not configured');
|
||||
|
||||
if (prior === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||
else process.env['MOSAIC_AGENT_NAME'] = prior;
|
||||
});
|
||||
|
||||
it('reaches the registered Hermes provider through the authenticated transitional matrix route', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const registry = createGatewayRuntimeProviderRegistry();
|
||||
const runtime = new RuntimeProviderService(
|
||||
registry,
|
||||
{ record: vi.fn().mockResolvedValue(undefined) },
|
||||
{ consume: vi.fn().mockResolvedValue(false) },
|
||||
);
|
||||
const controller = new InteractionController(runtime, {} as never);
|
||||
|
||||
await expect(
|
||||
controller.transitionalCapabilities(
|
||||
'Nova',
|
||||
'runtime.hermes',
|
||||
{ id: 'owner', tenantId: 'team' },
|
||||
'corr-1',
|
||||
),
|
||||
).resolves.toEqual([
|
||||
{ capability: 'kanban', status: 'unsupported' },
|
||||
{ capability: 'skills', status: 'unsupported' },
|
||||
{ capability: 'memory', status: 'unsupported' },
|
||||
{ capability: 'tools', status: 'unsupported' },
|
||||
{ capability: 'cron', status: 'unsupported' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a request without the non-simple correlation header', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never);
|
||||
|
||||
await expect(controller.sessions('Nova', 'fleet', { id: 'owner' })).rejects.toThrow(
|
||||
'X-Correlation-Id is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('enrolls a visible runtime session under the cross-surface conversation handle', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const runtime = {
|
||||
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
|
||||
};
|
||||
const durable = { enroll: vi.fn().mockResolvedValue(undefined) };
|
||||
const controller = new InteractionController(runtime as never, durable as never);
|
||||
|
||||
await expect(
|
||||
controller.enroll(
|
||||
'Nova',
|
||||
'conversation-1',
|
||||
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
{ id: 'owner', tenantId: 'team' },
|
||||
'corr-1',
|
||||
),
|
||||
).resolves.toEqual({ status: 'enrolled', sessionId: 'conversation-1' });
|
||||
expect(durable.enroll).toHaveBeenCalledWith(
|
||||
{
|
||||
agentName: 'Nova',
|
||||
sessionId: 'conversation-1',
|
||||
tenantId: 'team',
|
||||
ownerId: 'owner',
|
||||
providerId: 'fleet',
|
||||
runtimeSessionId: 'runtime-1',
|
||||
},
|
||||
expect.objectContaining({ correlationId: 'corr-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an invalid attach mode before invoking a provider', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const controller = new InteractionController({ attach: vi.fn() } as never, {} as never);
|
||||
|
||||
await expect(
|
||||
controller.attach('Nova', 'durable-1', { mode: 'write' as never }, { id: 'owner' }, 'corr-1'),
|
||||
).rejects.toThrow('Interaction attach mode is invalid');
|
||||
});
|
||||
|
||||
it('resumes a durable session by attaching and streaming its runtime events', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const runtimeEvent = {
|
||||
type: 'message.delta' as const,
|
||||
sessionId: 'runtime-1',
|
||||
cursor: 'cursor-1',
|
||||
occurredAt: '2026-07-13T00:00:00.000Z',
|
||||
content: 'resumed',
|
||||
};
|
||||
const runtime = {
|
||||
attach: vi.fn().mockResolvedValue({ attachmentId: 'attach-1', sessionId: 'runtime-1' }),
|
||||
streamSession: vi.fn(async function* () {
|
||||
yield runtimeEvent;
|
||||
}),
|
||||
};
|
||||
const durable = {
|
||||
getSnapshot: vi.fn().mockResolvedValue({
|
||||
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
}),
|
||||
};
|
||||
const controller = new InteractionController(runtime as never, durable as never);
|
||||
|
||||
await controller.attach('Nova', 'conversation-1', { mode: 'read' }, { id: 'owner' }, 'corr-1');
|
||||
await expect(
|
||||
firstValueFrom(
|
||||
controller.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1'),
|
||||
),
|
||||
).resolves.toEqual({ data: runtimeEvent });
|
||||
|
||||
expect(runtime.attach).toHaveBeenCalledWith(
|
||||
'fleet',
|
||||
'runtime-1',
|
||||
'read',
|
||||
expect.objectContaining({ correlationId: 'corr-1' }),
|
||||
);
|
||||
expect(runtime.streamSession).toHaveBeenCalledWith(
|
||||
'fleet',
|
||||
'runtime-1',
|
||||
undefined,
|
||||
expect.objectContaining({ correlationId: 'corr-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not create a runtime stream after the SSE subscriber disconnects during snapshot lookup', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
let resolveSnapshot!: (value: { identity: Record<string, string> }) => void;
|
||||
const snapshot = new Promise<{ identity: Record<string, string> }>((resolve) => {
|
||||
resolveSnapshot = resolve;
|
||||
});
|
||||
const runtime = { streamSession: vi.fn() };
|
||||
const durable = { getSnapshot: vi.fn().mockReturnValue(snapshot) };
|
||||
const controller = new InteractionController(runtime as never, durable as never);
|
||||
|
||||
const subscription = controller
|
||||
.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1')
|
||||
.subscribe();
|
||||
subscription.unsubscribe();
|
||||
resolveSnapshot({
|
||||
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(runtime.streamSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['wrong actor', { getSnapshot: vi.fn().mockRejectedValue(new Error('scope mismatch')) }],
|
||||
[
|
||||
'session-agent mismatch',
|
||||
{
|
||||
getSnapshot: vi.fn().mockResolvedValue({
|
||||
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
}),
|
||||
},
|
||||
],
|
||||
])('denies a CLI stop for %s', async (_reason, durable) => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
|
||||
const controller = new InteractionController(runtime as never, durable as never);
|
||||
|
||||
await expect(
|
||||
controller.stop(
|
||||
'Nova',
|
||||
'durable-1',
|
||||
{ approvalRef: 'approval-1' },
|
||||
{ id: 'owner' },
|
||||
'corr-1',
|
||||
),
|
||||
).rejects.toBeDefined();
|
||||
expect(runtime.terminate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces a denied runtime approval to the CLI interaction surface', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const runtime = {
|
||||
terminate: vi.fn().mockRejectedValue(new Error('Runtime termination approval denied')),
|
||||
};
|
||||
const durable = {
|
||||
getSnapshot: vi.fn().mockResolvedValue({
|
||||
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
}),
|
||||
};
|
||||
const controller = new InteractionController(runtime as never, durable as never);
|
||||
|
||||
await expect(
|
||||
controller.stop(
|
||||
'Nova',
|
||||
'durable-1',
|
||||
{ approvalRef: 'approval-1' },
|
||||
{ id: 'owner' },
|
||||
'corr-1',
|
||||
),
|
||||
).rejects.toThrow('Runtime termination approval denied');
|
||||
});
|
||||
|
||||
it('uses the durable session identity and runtime registry for an approved stop', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
|
||||
const durable = {
|
||||
getSnapshot: vi.fn().mockResolvedValue({
|
||||
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
}),
|
||||
};
|
||||
const controller = new InteractionController(runtime as never, durable as never);
|
||||
|
||||
await controller.stop(
|
||||
'Nova',
|
||||
'durable-1',
|
||||
{ approvalRef: 'approval-1' },
|
||||
{ id: 'owner' },
|
||||
'corr-1',
|
||||
);
|
||||
|
||||
expect(runtime.terminate).toHaveBeenCalledWith(
|
||||
'fleet',
|
||||
'runtime-1',
|
||||
'approval-1',
|
||||
expect.objectContaining({
|
||||
correlationId: 'corr-1',
|
||||
actorScope: { userId: 'owner', tenantId: 'owner' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
293
apps/gateway/src/agent/interaction.controller.ts
Normal file
293
apps/gateway/src/agent/interaction.controller.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Headers,
|
||||
Sse,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseFilters,
|
||||
} from '@nestjs/common';
|
||||
import type { RuntimeAttachMode, RuntimeStreamEvent } from '@mosaicstack/types';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||
import { DurableSessionService } from './durable-session.service.js';
|
||||
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
||||
import {
|
||||
RuntimeProviderService,
|
||||
type RuntimeProviderRequestContext,
|
||||
} from './runtime-provider-registry.service.js';
|
||||
|
||||
/**
|
||||
* Authenticated HTTP boundary for operator interaction clients. Identity is
|
||||
* selected from deployment configuration, never a client-side command name.
|
||||
*/
|
||||
@Controller('api/interaction/:agentName')
|
||||
@UseGuards(AuthGuard)
|
||||
@UseFilters(RuntimeApprovalDeniedFilter)
|
||||
export class InteractionController {
|
||||
constructor(
|
||||
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
|
||||
@Inject(DurableSessionService) private readonly durable: DurableSessionService,
|
||||
) {}
|
||||
|
||||
@Get('sessions')
|
||||
async sessions(
|
||||
@Param('agentName') agentName: string,
|
||||
@Query('provider') providerId: string,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
return this.runtime.listSessions(
|
||||
this.requiredProvider(providerId),
|
||||
this.context(user, correlationId),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('transitional-capabilities')
|
||||
async transitionalCapabilities(
|
||||
@Param('agentName') agentName: string,
|
||||
@Query('provider') providerId: string,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
return this.runtime.transitionalCapabilityMatrix(
|
||||
this.requiredProvider(providerId),
|
||||
this.context(user, correlationId),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('tree')
|
||||
async tree(
|
||||
@Param('agentName') agentName: string,
|
||||
@Query('provider') providerId: string,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
return this.runtime.getSessionTree(
|
||||
this.requiredProvider(providerId),
|
||||
this.context(user, correlationId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind an existing, authorized runtime session to the stable conversation ID.
|
||||
* This is the lifecycle boundary where both runtime identifiers are known.
|
||||
*/
|
||||
@Post('sessions/:sessionId/enroll')
|
||||
async enroll(
|
||||
@Param('agentName') agentName: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Body() body: { providerId?: string; runtimeSessionId?: string } = {},
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
const providerId = this.requiredProvider(body.providerId ?? '');
|
||||
const runtimeSessionId = body.runtimeSessionId?.trim();
|
||||
if (!runtimeSessionId) throw new ForbiddenException('Runtime session identity is required');
|
||||
const context = this.context(user, correlationId);
|
||||
const sessions = await this.runtime.listSessions(providerId, context);
|
||||
if (!sessions.some((session): boolean => session.id === runtimeSessionId)) {
|
||||
throw new ForbiddenException('Runtime session is not visible to this actor');
|
||||
}
|
||||
await this.durable.enroll(
|
||||
{
|
||||
agentName,
|
||||
sessionId,
|
||||
tenantId: context.actorScope.tenantId,
|
||||
ownerId: context.actorScope.userId,
|
||||
providerId,
|
||||
runtimeSessionId,
|
||||
},
|
||||
context,
|
||||
);
|
||||
return { status: 'enrolled', sessionId };
|
||||
}
|
||||
|
||||
@Post('sessions/:sessionId/attach')
|
||||
async attach(
|
||||
@Param('agentName') agentName: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Body() body: { mode?: RuntimeAttachMode } = {},
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
const context = this.context(user, correlationId);
|
||||
const mode = body.mode ?? 'read';
|
||||
if (mode !== 'read' && mode !== 'control') {
|
||||
throw new ForbiddenException('Interaction attach mode is invalid');
|
||||
}
|
||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||
return this.runtime.attach(
|
||||
snapshot.identity.providerId,
|
||||
snapshot.identity.runtimeSessionId,
|
||||
mode,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
@Sse('sessions/:sessionId/stream')
|
||||
stream(
|
||||
@Param('agentName') agentName: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Query('cursor') cursor: string | undefined,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
): Observable<{ data: RuntimeStreamEvent }> {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
const context = this.context(user, correlationId);
|
||||
return new Observable((subscriber) => {
|
||||
let iterator: AsyncIterator<RuntimeStreamEvent> | undefined;
|
||||
let cancelled = false;
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||
if (cancelled || subscriber.closed) return;
|
||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||
iterator = this.runtime
|
||||
.streamSession(
|
||||
snapshot.identity.providerId,
|
||||
snapshot.identity.runtimeSessionId,
|
||||
cursor?.trim() || undefined,
|
||||
context,
|
||||
)
|
||||
[Symbol.asyncIterator]();
|
||||
if (cancelled || subscriber.closed) {
|
||||
await iterator.return?.();
|
||||
return;
|
||||
}
|
||||
while (!cancelled && !subscriber.closed) {
|
||||
const next = await iterator.next();
|
||||
if (next.done || cancelled || subscriber.closed) break;
|
||||
subscriber.next({ data: next.value });
|
||||
}
|
||||
if (!subscriber.closed) subscriber.complete();
|
||||
} catch (error: unknown) {
|
||||
if (!subscriber.closed) subscriber.error(error);
|
||||
}
|
||||
})();
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
void iterator?.return?.();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Post('sessions/:sessionId/send')
|
||||
async send(
|
||||
@Param('agentName') agentName: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Body() body: { content?: string; idempotencyKey?: string } = {},
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
if (!body.content?.trim() || !body.idempotencyKey?.trim()) {
|
||||
throw new ForbiddenException('Content and idempotency key are required');
|
||||
}
|
||||
const context = this.context(user, correlationId);
|
||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||
const input = {
|
||||
sessionId,
|
||||
content: body.content,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
correlationId: context.correlationId,
|
||||
context,
|
||||
};
|
||||
await this.durable.queueProviderSend(input);
|
||||
await this.durable.dispatchProviderOutbox(sessionId, input);
|
||||
return { status: 'queued', sessionId };
|
||||
}
|
||||
|
||||
@Post('sessions/:sessionId/stop')
|
||||
async stop(
|
||||
@Param('agentName') agentName: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Body() body: { approvalRef?: string } = {},
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
if (!body.approvalRef?.trim())
|
||||
throw new ForbiddenException('Exact-action approval is required');
|
||||
const context = this.context(user, correlationId);
|
||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||
await this.runtime.terminate(
|
||||
snapshot.identity.providerId,
|
||||
snapshot.identity.runtimeSessionId,
|
||||
body.approvalRef,
|
||||
context,
|
||||
);
|
||||
return { status: 'stopped', sessionId };
|
||||
}
|
||||
|
||||
@Post('sessions/:sessionId/recover')
|
||||
async recover(
|
||||
@Param('agentName') agentName: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
const context = this.context(user, correlationId);
|
||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||
await this.durable.recoverProviderSession(sessionId, {
|
||||
sessionId,
|
||||
content: '',
|
||||
idempotencyKey: `recovery:${context.correlationId}`,
|
||||
correlationId: context.correlationId,
|
||||
context,
|
||||
});
|
||||
return { status: 'recovered', sessionId };
|
||||
}
|
||||
|
||||
private context(
|
||||
user: AuthenticatedUserLike,
|
||||
correlationId?: string,
|
||||
): RuntimeProviderRequestContext {
|
||||
const requestCorrelationId = correlationId?.trim();
|
||||
// This non-simple request header is mandatory for mutations. Browser
|
||||
// cross-origin requests cannot set it without a CORS preflight, and the
|
||||
// gateway's allowlist rejects untrusted origins before the handler runs.
|
||||
if (!requestCorrelationId) {
|
||||
throw new ForbiddenException('X-Correlation-Id is required');
|
||||
}
|
||||
return {
|
||||
actorScope: scopeFromUser(user),
|
||||
channelId: 'cli',
|
||||
correlationId: requestCorrelationId,
|
||||
};
|
||||
}
|
||||
|
||||
private assertConfiguredAgent(agentName: string): void {
|
||||
const configured = process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||
if (!configured || configured !== agentName) {
|
||||
throw new ForbiddenException('Interaction agent is not configured for this request');
|
||||
}
|
||||
}
|
||||
|
||||
private assertSessionAgent(sessionAgentName: string, agentName: string): void {
|
||||
if (sessionAgentName !== agentName)
|
||||
throw new ForbiddenException('Interaction session identity mismatch');
|
||||
}
|
||||
|
||||
private requiredProvider(providerId: string): string {
|
||||
if (!providerId?.trim()) throw new ForbiddenException('Runtime provider is required');
|
||||
return providerId;
|
||||
}
|
||||
}
|
||||
@@ -107,8 +107,7 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
|
||||
* Interval is configurable via PROVIDER_HEALTH_INTERVAL env (seconds, default 60).
|
||||
*/
|
||||
private startHealthCheckScheduler(): void {
|
||||
const intervalSecs =
|
||||
parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS;
|
||||
const intervalSecs = this.effectiveHealthCheckIntervalSecs();
|
||||
const intervalMs = intervalSecs * 1000;
|
||||
|
||||
// Run an initial check immediately (non-blocking)
|
||||
@@ -176,6 +175,28 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
46
apps/gateway/src/agent/providers.controller.test.ts
Normal file
46
apps/gateway/src/agent/providers.controller.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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,7 +33,20 @@ export class ProvidersController {
|
||||
|
||||
@Get('health')
|
||||
health() {
|
||||
return { providers: this.providerService.getProvidersHealth() };
|
||||
return { providers: this.safeProviderHealth() };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')
|
||||
@@ -51,6 +64,13 @@ export class ProvidersController {
|
||||
return this.routingService.rank(criteria);
|
||||
}
|
||||
|
||||
private safeProviderHealth() {
|
||||
return this.providerService.getProvidersHealth().map(({ error, ...provider }) => ({
|
||||
...provider,
|
||||
...(error ? { errorCode: 'provider_unavailable' } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Credential CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
13
apps/gateway/src/agent/runtime-approval-denied.filter.ts
Normal file
13
apps/gateway/src/agent/runtime-approval-denied.filter.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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' });
|
||||
}
|
||||
}
|
||||
500
apps/gateway/src/agent/runtime-provider-registry.service.ts
Normal file
500
apps/gateway/src/agent/runtime-provider-registry.service.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
||||
import {
|
||||
createRuntimeAuditLogEntry,
|
||||
type LogService,
|
||||
type RuntimeAuditErrorCode,
|
||||
} from '@mosaicstack/log';
|
||||
import type {
|
||||
AgentRuntimeProvider,
|
||||
RuntimeAttachHandle,
|
||||
RuntimeAttachMode,
|
||||
RuntimeCapability,
|
||||
RuntimeCapabilitySet,
|
||||
RuntimeHealth,
|
||||
RuntimeMessage,
|
||||
RuntimeScope,
|
||||
RuntimeSession,
|
||||
RuntimeSessionTree,
|
||||
RuntimeStreamEvent,
|
||||
TransitionalCapabilityInventoryEntry,
|
||||
TransitionalCapabilityInventoryProvider,
|
||||
} from '@mosaicstack/types';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||
|
||||
export const AGENT_RUNTIME_PROVIDER_REGISTRY = Symbol('AGENT_RUNTIME_PROVIDER_REGISTRY');
|
||||
export const RUNTIME_PROVIDER_AUDIT_SINK = Symbol('RUNTIME_PROVIDER_AUDIT_SINK');
|
||||
export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER');
|
||||
|
||||
export type RuntimeProviderOperation =
|
||||
| RuntimeCapability
|
||||
| 'runtime.capabilities'
|
||||
| 'runtime.health'
|
||||
| 'runtime.transitional-capabilities';
|
||||
export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
|
||||
|
||||
/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */
|
||||
export interface RuntimeProviderRequestContext {
|
||||
actorScope: ActorTenantScope;
|
||||
channelId: string;
|
||||
correlationId: string;
|
||||
}
|
||||
|
||||
/** Metadata-only audit record. Message bodies, idempotency keys, and approval refs are excluded. */
|
||||
export interface RuntimeAuditEvent {
|
||||
providerId: string;
|
||||
operation: RuntimeProviderOperation;
|
||||
outcome: RuntimeProviderAuditOutcome;
|
||||
actorId: string;
|
||||
tenantId: string;
|
||||
channelId: string;
|
||||
correlationId: string;
|
||||
resourceId?: string;
|
||||
durationMs?: number;
|
||||
errorCode?: RuntimeAuditErrorCode;
|
||||
}
|
||||
|
||||
export interface RuntimeAuditSink {
|
||||
record(event: RuntimeAuditEvent): Promise<void>;
|
||||
}
|
||||
|
||||
/** Exact action shape that a durable approval implementation must consume once. */
|
||||
export interface RuntimeTerminationAction {
|
||||
providerId: string;
|
||||
sessionId: string;
|
||||
actorId: string;
|
||||
tenantId: string;
|
||||
channelId: string;
|
||||
correlationId: string;
|
||||
agentName: string;
|
||||
}
|
||||
|
||||
export interface RuntimeApprovalVerifier {
|
||||
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
|
||||
}
|
||||
|
||||
function isTransitionalInventoryProvider(
|
||||
provider: AgentRuntimeProvider,
|
||||
): provider is AgentRuntimeProvider & TransitionalCapabilityInventoryProvider {
|
||||
return (
|
||||
typeof (provider as Partial<TransitionalCapabilityInventoryProvider>)
|
||||
.transitionalCapabilityMatrix === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
function configuredAgentName(): string {
|
||||
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||
if (!agentName) throw new RuntimeApprovalDeniedError();
|
||||
return agentName;
|
||||
}
|
||||
|
||||
export class RuntimeApprovalDeniedError extends Error {
|
||||
constructor() {
|
||||
super('Runtime termination approval denied');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The default denies all runtime termination until a durable, exact-action
|
||||
* approval implementation is configured. This is safer than a permissive stub.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DenyRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
|
||||
async consume(_approvalRef: string, _action: RuntimeTerminationAction): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporary metadata-only audit sink. M1 observability can replace this token
|
||||
* with a durable audit writer without changing provider call sites.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RuntimeProviderAuditService implements RuntimeAuditSink {
|
||||
private readonly logger = new Logger(RuntimeProviderAuditService.name);
|
||||
|
||||
constructor(@Inject(LOG_SERVICE) private readonly logService: LogService) {}
|
||||
|
||||
async record(event: RuntimeAuditEvent): Promise<void> {
|
||||
const entry = createRuntimeAuditLogEntry(event);
|
||||
await this.logService.logs.ingest(entry);
|
||||
this.logger.log(JSON.stringify({ event: entry.content, metadata: entry.metadata }));
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RuntimeProviderService {
|
||||
private readonly logger = new Logger(RuntimeProviderService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(AGENT_RUNTIME_PROVIDER_REGISTRY)
|
||||
private readonly registry: AgentRuntimeProviderRegistry,
|
||||
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
||||
private readonly audit: RuntimeAuditSink,
|
||||
@Inject(RUNTIME_APPROVAL_VERIFIER)
|
||||
private readonly approvals: RuntimeApprovalVerifier,
|
||||
) {}
|
||||
|
||||
async capabilities(
|
||||
providerId: string,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): Promise<RuntimeCapabilitySet> {
|
||||
return this.execute(
|
||||
providerId,
|
||||
'runtime.capabilities',
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeCapabilitySet> =>
|
||||
provider.capabilities(scope),
|
||||
);
|
||||
}
|
||||
|
||||
async health(providerId: string, context: RuntimeProviderRequestContext): Promise<RuntimeHealth> {
|
||||
return this.execute(
|
||||
providerId,
|
||||
'runtime.health',
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeHealth> =>
|
||||
provider.health(scope),
|
||||
);
|
||||
}
|
||||
|
||||
async transitionalCapabilityMatrix(
|
||||
providerId: string,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): Promise<TransitionalCapabilityInventoryEntry[]> {
|
||||
return this.execute(
|
||||
providerId,
|
||||
'runtime.transitional-capabilities',
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
async (provider: AgentRuntimeProvider, scope: RuntimeScope) => {
|
||||
if (!isTransitionalInventoryProvider(provider)) {
|
||||
throw new NotFoundException('Runtime provider has no transitional capability inventory');
|
||||
}
|
||||
return provider.transitionalCapabilityMatrix(scope);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async listSessions(
|
||||
providerId: string,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): Promise<RuntimeSession[]> {
|
||||
return this.execute(
|
||||
providerId,
|
||||
'session.list',
|
||||
'session.list',
|
||||
undefined,
|
||||
context,
|
||||
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSession[]> =>
|
||||
provider.listSessions(scope),
|
||||
);
|
||||
}
|
||||
|
||||
async getSessionTree(
|
||||
providerId: string,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): Promise<RuntimeSessionTree[]> {
|
||||
return this.execute(
|
||||
providerId,
|
||||
'session.tree',
|
||||
'session.tree',
|
||||
undefined,
|
||||
context,
|
||||
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSessionTree[]> =>
|
||||
provider.getSessionTree(scope),
|
||||
);
|
||||
}
|
||||
|
||||
streamSession(
|
||||
providerId: string,
|
||||
sessionId: string,
|
||||
cursor: string | undefined,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): AsyncIterable<RuntimeStreamEvent> {
|
||||
return this.stream(
|
||||
providerId,
|
||||
'session.stream',
|
||||
'session.stream',
|
||||
sessionId,
|
||||
context,
|
||||
(provider: AgentRuntimeProvider, scope: RuntimeScope): AsyncIterable<RuntimeStreamEvent> =>
|
||||
provider.streamSession(sessionId, cursor, scope),
|
||||
);
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
providerId: string,
|
||||
sessionId: string,
|
||||
message: RuntimeMessage,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): Promise<void> {
|
||||
await this.execute(
|
||||
providerId,
|
||||
'session.send',
|
||||
'session.send',
|
||||
sessionId,
|
||||
context,
|
||||
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
|
||||
provider.sendMessage(sessionId, message, scope),
|
||||
);
|
||||
}
|
||||
|
||||
async attach(
|
||||
providerId: string,
|
||||
sessionId: string,
|
||||
mode: RuntimeAttachMode,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): Promise<RuntimeAttachHandle> {
|
||||
return this.execute(
|
||||
providerId,
|
||||
'session.attach',
|
||||
'session.attach',
|
||||
sessionId,
|
||||
context,
|
||||
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeAttachHandle> =>
|
||||
provider.attach(sessionId, mode, scope),
|
||||
);
|
||||
}
|
||||
|
||||
async detach(
|
||||
providerId: string,
|
||||
attachmentId: string,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): Promise<void> {
|
||||
await this.execute(
|
||||
providerId,
|
||||
'session.attach',
|
||||
'session.attach',
|
||||
attachmentId,
|
||||
context,
|
||||
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
|
||||
provider.detach(attachmentId, scope),
|
||||
);
|
||||
}
|
||||
|
||||
async terminate(
|
||||
providerId: string,
|
||||
sessionId: string,
|
||||
approvalRef: string,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): Promise<void> {
|
||||
await this.execute(
|
||||
providerId,
|
||||
'session.terminate',
|
||||
'session.terminate',
|
||||
sessionId,
|
||||
context,
|
||||
async (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> => {
|
||||
const approved = await this.approvals.consume(approvalRef, {
|
||||
providerId,
|
||||
sessionId,
|
||||
actorId: scope.actorId,
|
||||
tenantId: scope.tenantId,
|
||||
channelId: scope.channelId,
|
||||
correlationId: scope.correlationId,
|
||||
agentName: configuredAgentName(),
|
||||
});
|
||||
if (!approved) {
|
||||
throw new RuntimeApprovalDeniedError();
|
||||
}
|
||||
await provider.terminate(sessionId, approvalRef, scope);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async execute<T>(
|
||||
providerId: string,
|
||||
operation: RuntimeProviderOperation,
|
||||
requiredCapability: RuntimeCapability | undefined,
|
||||
resourceId: string | undefined,
|
||||
context: RuntimeProviderRequestContext,
|
||||
invoke: (provider: AgentRuntimeProvider, scope: RuntimeScope) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const scope = this.deriveScope(context);
|
||||
const startedAt = Date.now();
|
||||
await this.record(providerId, operation, 'requested', scope, resourceId);
|
||||
let invocationStarted = false;
|
||||
try {
|
||||
const provider = this.provider(providerId);
|
||||
if (requiredCapability) {
|
||||
await this.assertCapability(provider, requiredCapability, scope);
|
||||
}
|
||||
invocationStarted = true;
|
||||
const result = await invoke(provider, scope);
|
||||
await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt);
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (invocationStarted && !this.isAuthorizationDenied(error)) {
|
||||
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
|
||||
} else {
|
||||
await this.record(
|
||||
providerId,
|
||||
operation,
|
||||
'denied',
|
||||
scope,
|
||||
resourceId,
|
||||
durationMs,
|
||||
'policy_denied',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async *stream(
|
||||
providerId: string,
|
||||
operation: RuntimeProviderOperation,
|
||||
requiredCapability: RuntimeCapability,
|
||||
resourceId: string,
|
||||
context: RuntimeProviderRequestContext,
|
||||
invoke: (
|
||||
provider: AgentRuntimeProvider,
|
||||
scope: RuntimeScope,
|
||||
) => AsyncIterable<RuntimeStreamEvent>,
|
||||
): AsyncIterable<RuntimeStreamEvent> {
|
||||
const scope = this.deriveScope(context);
|
||||
const startedAt = Date.now();
|
||||
await this.record(providerId, operation, 'requested', scope, resourceId);
|
||||
let invocationStarted = false;
|
||||
try {
|
||||
const provider = this.provider(providerId);
|
||||
await this.assertCapability(provider, requiredCapability, scope);
|
||||
invocationStarted = true;
|
||||
for await (const event of invoke(provider, scope)) {
|
||||
yield event;
|
||||
}
|
||||
await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt);
|
||||
} catch (error: unknown) {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (invocationStarted) {
|
||||
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
|
||||
} else {
|
||||
await this.record(
|
||||
providerId,
|
||||
operation,
|
||||
'denied',
|
||||
scope,
|
||||
resourceId,
|
||||
durationMs,
|
||||
'policy_denied',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private isAuthorizationDenied(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof RuntimeApprovalDeniedError ||
|
||||
error instanceof ForbiddenException ||
|
||||
(typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: unknown }).code === 'forbidden')
|
||||
);
|
||||
}
|
||||
|
||||
private provider(providerId: string): AgentRuntimeProvider {
|
||||
try {
|
||||
return this.registry.require(providerId);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Runtime provider is not registered';
|
||||
throw new NotFoundException(message);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCapability(
|
||||
provider: AgentRuntimeProvider,
|
||||
requiredCapability: RuntimeCapability,
|
||||
scope: RuntimeScope,
|
||||
): Promise<void> {
|
||||
const capabilities = await provider.capabilities(scope);
|
||||
if (!capabilities.supported.includes(requiredCapability)) {
|
||||
throw new ForbiddenException(`Runtime provider capability denied: ${requiredCapability}`);
|
||||
}
|
||||
}
|
||||
|
||||
private deriveScope(context: RuntimeProviderRequestContext): RuntimeScope {
|
||||
const actorId = context.actorScope.userId.trim();
|
||||
const tenantId = context.actorScope.tenantId.trim();
|
||||
const channelId = context.channelId.trim();
|
||||
const correlationId = context.correlationId.trim();
|
||||
if (!actorId || !tenantId || !channelId || !correlationId) {
|
||||
throw new ForbiddenException(
|
||||
'Authenticated runtime actor scope and correlation are required',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ actorId, tenantId, channelId, correlationId });
|
||||
}
|
||||
|
||||
private async recordFailure(
|
||||
providerId: string,
|
||||
operation: RuntimeProviderOperation,
|
||||
scope: RuntimeScope,
|
||||
resourceId: string | undefined,
|
||||
durationMs: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.record(
|
||||
providerId,
|
||||
operation,
|
||||
'failed',
|
||||
scope,
|
||||
resourceId,
|
||||
durationMs,
|
||||
'provider_error',
|
||||
);
|
||||
} catch {
|
||||
this.logger.error(
|
||||
`Runtime provider failure audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async recordCompletion(
|
||||
providerId: string,
|
||||
operation: RuntimeProviderOperation,
|
||||
scope: RuntimeScope,
|
||||
resourceId: string | undefined,
|
||||
durationMs: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.record(providerId, operation, 'succeeded', scope, resourceId, durationMs);
|
||||
} catch {
|
||||
this.logger.error(
|
||||
`Runtime provider completion audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async record(
|
||||
providerId: string,
|
||||
operation: RuntimeProviderOperation,
|
||||
outcome: RuntimeProviderAuditOutcome,
|
||||
scope: RuntimeScope,
|
||||
resourceId: string | undefined,
|
||||
durationMs?: number,
|
||||
errorCode?: RuntimeAuditErrorCode,
|
||||
): Promise<void> {
|
||||
await this.audit.record({
|
||||
providerId,
|
||||
operation,
|
||||
outcome,
|
||||
actorId: scope.actorId,
|
||||
tenantId: scope.tenantId,
|
||||
channelId: scope.channelId,
|
||||
correlationId: scope.correlationId,
|
||||
...(resourceId ? { resourceId } : {}),
|
||||
...(durationMs !== undefined ? { durationMs } : {}),
|
||||
...(errorCode ? { errorCode } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||
import { AgentService } from './agent.service.js';
|
||||
|
||||
@Controller('api/sessions')
|
||||
@@ -18,23 +20,24 @@ export class SessionsController {
|
||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
const sessions = this.agentService.listSessions();
|
||||
list(@CurrentUser() user: AuthenticatedUserLike) {
|
||||
const sessions = this.agentService.listSessions(scopeFromUser(user));
|
||||
return { sessions, total: sessions.length };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
const info = this.agentService.getSessionInfo(id);
|
||||
findOne(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
|
||||
const info = this.agentService.getSessionInfo(id, scopeFromUser(user));
|
||||
if (!info) throw new NotFoundException('Session not found');
|
||||
return info;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async destroy(@Param('id') id: string) {
|
||||
const info = this.agentService.getSessionInfo(id);
|
||||
async destroy(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
|
||||
const scope = scopeFromUser(user);
|
||||
const info = this.agentService.getSessionInfo(id, scope);
|
||||
if (!info) throw new NotFoundException('Session not found');
|
||||
await this.agentService.destroySession(id);
|
||||
await this.agentService.destroySession(id, scope);
|
||||
}
|
||||
}
|
||||
|
||||
41
apps/gateway/src/agent/tools/memory-tools.test.ts
Normal file
41
apps/gateway/src/agent/tools/memory-tools.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createMemoryTools } from './memory-tools.js';
|
||||
|
||||
describe('createMemoryTools operator retrieval binding', () => {
|
||||
const memory = {
|
||||
insights: { searchByEmbedding: vi.fn(), create: vi.fn() },
|
||||
preferences: { findByUserAndCategory: vi.fn(), findByUser: vi.fn(), upsert: vi.fn() },
|
||||
};
|
||||
const scope = { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' };
|
||||
|
||||
it('uses the configured plugin with the server-derived scope for retrieval and capture', async () => {
|
||||
const plugin = {
|
||||
search: vi.fn(async () => []),
|
||||
capture: vi.fn(async () => ({ id: 'insight-1' })),
|
||||
};
|
||||
const tools = createMemoryTools(memory as never, null, 'owner-a', {
|
||||
plugin: plugin as never,
|
||||
scope,
|
||||
});
|
||||
|
||||
await tools
|
||||
.find((tool) => tool.name === 'memory_search')!
|
||||
.execute('call-1', { query: 'plans' }, undefined, undefined, {} as never);
|
||||
await tools
|
||||
.find((tool) => tool.name === 'memory_save_insight')!
|
||||
.execute(
|
||||
'call-2',
|
||||
{ content: 'secret', category: 'decision' },
|
||||
undefined,
|
||||
undefined,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(plugin.search).toHaveBeenCalledWith(scope, 'plans', 5);
|
||||
expect(plugin.capture).toHaveBeenCalledWith(scope, {
|
||||
content: 'secret',
|
||||
source: 'agent',
|
||||
category: 'decision',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import type { Memory } from '@mosaicstack/memory';
|
||||
import type { EmbeddingProvider } from '@mosaicstack/memory';
|
||||
import type {
|
||||
EmbeddingProvider,
|
||||
Memory,
|
||||
OperatorMemoryPlugin,
|
||||
OperatorMemoryScope,
|
||||
} from '@mosaicstack/memory';
|
||||
|
||||
/**
|
||||
* Create memory tools bound to the session's authenticated userId.
|
||||
@@ -13,8 +17,10 @@ import type { EmbeddingProvider } from '@mosaicstack/memory';
|
||||
export function createMemoryTools(
|
||||
memory: Memory,
|
||||
embeddingProvider: EmbeddingProvider | null,
|
||||
/** Authenticated user ID from the session. All memory operations are scoped to this user. */
|
||||
/** Authenticated user ID from the session. All preference operations are scoped to this user. */
|
||||
sessionUserId: string | undefined,
|
||||
/** Optional configured retrieval plugin, bound to a server-derived session scope. */
|
||||
operatorMemory?: { plugin: OperatorMemoryPlugin; scope: OperatorMemoryScope },
|
||||
): ToolDefinition[] {
|
||||
/** Return an error result when no session user is bound. */
|
||||
function noUserError() {
|
||||
@@ -46,6 +52,14 @@ export function createMemoryTools(
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
if (operatorMemory) {
|
||||
const results = await operatorMemory.plugin.search(operatorMemory.scope, query, limit ?? 5);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (!embeddingProvider) {
|
||||
return {
|
||||
content: [
|
||||
@@ -158,6 +172,18 @@ export function createMemoryTools(
|
||||
};
|
||||
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
||||
|
||||
if (operatorMemory) {
|
||||
const insight = await operatorMemory.plugin.capture(operatorMemory.scope, {
|
||||
content,
|
||||
source: 'agent',
|
||||
category: category ?? 'learning',
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(insight, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
let embedding: number[] | null = null;
|
||||
if (embeddingProvider) {
|
||||
embedding = await embeddingProvider.embed(content);
|
||||
|
||||
24
apps/gateway/src/auth/session-scope.ts
Normal file
24
apps/gateway/src/auth/session-scope.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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,7 +12,8 @@ describe('Chat controller source hardening', () => {
|
||||
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
||||
|
||||
expect(source).toContain('@UseGuards(AuthGuard)');
|
||||
expect(source).toContain('@CurrentUser() user: { id: string }');
|
||||
expect(source).toContain('@CurrentUser() user: AuthenticatedUserLike');
|
||||
expect(source).toContain('const scope = scopeFromUser(user);');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
Post,
|
||||
Body,
|
||||
Logger,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Inject,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
@@ -13,6 +15,7 @@ import { Throttle } from '@nestjs/throttler';
|
||||
import { AgentService } from '../agent/agent.service.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 { v4 as uuid } from 'uuid';
|
||||
import { ChatRequestDto } from './chat.dto.js';
|
||||
|
||||
@@ -32,16 +35,23 @@ export class ChatController {
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
async chat(
|
||||
@Body() body: ChatRequestDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
): Promise<ChatResponse> {
|
||||
const conversationId = body.conversationId ?? uuid();
|
||||
const scope = scopeFromUser(user);
|
||||
|
||||
try {
|
||||
let agentSession = this.agentService.getSession(conversationId);
|
||||
let agentSession = this.agentService.getSession(conversationId, scope);
|
||||
if (!agentSession) {
|
||||
agentSession = await this.agentService.createSession(conversationId);
|
||||
agentSession = await this.agentService.createSession(conversationId, {
|
||||
userId: scope.userId,
|
||||
tenantId: scope.tenantId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ForbiddenException) {
|
||||
throw new NotFoundException('Session not found');
|
||||
}
|
||||
this.logger.error(
|
||||
`Session creation failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
@@ -60,20 +70,27 @@ export class ChatController {
|
||||
reject(new Error('Agent response timed out'));
|
||||
}, 120_000);
|
||||
|
||||
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
||||
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
|
||||
responseText += event.assistantMessageEvent.delta;
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
const cleanup = this.agentService.onEvent(
|
||||
conversationId,
|
||||
(event: AgentSessionEvent) => {
|
||||
if (
|
||||
event.type === 'message_update' &&
|
||||
event.assistantMessageEvent.type === 'text_delta'
|
||||
) {
|
||||
responseText += event.assistantMessageEvent.delta;
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
scope,
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.agentService.prompt(conversationId, body.content);
|
||||
await this.agentService.prompt(conversationId, body.content, scope);
|
||||
await done;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import type { IncomingHttpHeaders } from 'node:http';
|
||||
import { fromNodeHeaders } from 'better-auth/node';
|
||||
|
||||
@@ -12,6 +13,19 @@ 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(
|
||||
headers: IncomingHttpHeaders,
|
||||
auth: SessionAuth,
|
||||
|
||||
74
apps/gateway/src/chat/chat.gateway-command-approval.spec.ts
Normal file
74
apps/gateway/src/chat/chat.gateway-command-approval.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
170
apps/gateway/src/chat/chat.gateway-redaction.spec.ts
Normal file
170
apps/gateway/src/chat/chat.gateway-redaction.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
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,4 +1,5 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Inject, Logger, Optional } from '@nestjs/common';
|
||||
import {
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
@@ -11,24 +12,47 @@ import {
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
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 { Brain } from '@mosaicstack/brain';
|
||||
import { redactSensitiveContent } from '@mosaicstack/log';
|
||||
import type {
|
||||
SetThinkingPayload,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandPayload,
|
||||
SystemReloadPayload,
|
||||
RoutingDecisionInfo,
|
||||
AbortPayload,
|
||||
} from '@mosaicstack/types';
|
||||
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
||||
import {
|
||||
RUNTIME_PROVIDER_AUDIT_SINK,
|
||||
RuntimeProviderService,
|
||||
type RuntimeAuditSink,
|
||||
} from '../agent/runtime-provider-registry.service.js';
|
||||
import { DurableSessionService } from '../agent/durable-session.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import {
|
||||
scopeFromUser,
|
||||
type ActorTenantScope,
|
||||
type AuthenticatedUserLike,
|
||||
} from '../auth/session-scope.js';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { CommandRegistryService } from '../commands/command-registry.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 { v4 as uuid } from 'uuid';
|
||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||
import { validateSocketSession } from './chat.gateway-auth.js';
|
||||
import { validateDiscordServiceToken, validateSocketSession } from './chat.gateway-auth.js';
|
||||
import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js';
|
||||
|
||||
/** Per-client state tracking streaming accumulation for persistence. */
|
||||
interface ClientSession {
|
||||
@@ -40,6 +64,8 @@ interface ClientSession {
|
||||
toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>;
|
||||
/** Tool calls in-flight (started but not ended yet). */
|
||||
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) */
|
||||
lastRoutingDecision?: RoutingDecisionInfo;
|
||||
}
|
||||
@@ -49,6 +75,38 @@ interface ClientSession {
|
||||
* Keyed by conversationId, value is the model name to use.
|
||||
*/
|
||||
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({
|
||||
cors: {
|
||||
@@ -62,6 +120,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
|
||||
private readonly logger = new Logger(ChatGateway.name);
|
||||
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(
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@@ -70,6 +133,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
||||
@Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService,
|
||||
@Optional()
|
||||
@Inject(CommandAuthorizationService)
|
||||
private readonly commandAuthorization: CommandAuthorizationService | null = null,
|
||||
@Optional()
|
||||
@Inject(RuntimeProviderService)
|
||||
private readonly runtimeRegistry: RuntimeProviderService | null = null,
|
||||
@Optional()
|
||||
@Inject(DurableSessionService)
|
||||
private readonly durableSessions: DurableSessionService | null = null,
|
||||
@Optional()
|
||||
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
||||
private readonly runtimeAudit: RuntimeAuditSink | null = null,
|
||||
) {}
|
||||
|
||||
afterInit(): void {
|
||||
@@ -77,6 +152,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
}
|
||||
|
||||
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);
|
||||
if (!session) {
|
||||
this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`);
|
||||
@@ -87,8 +169,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
client.data.user = session.user;
|
||||
client.data.session = session.session;
|
||||
this.logger.log(`Client connected: ${client.id}`);
|
||||
|
||||
// Broadcast command manifest to the newly connected client
|
||||
client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() });
|
||||
}
|
||||
|
||||
@@ -97,25 +177,84 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
const session = this.clientSessions.get(client.id);
|
||||
if (session) {
|
||||
session.cleanup();
|
||||
this.agentService.removeChannel(session.conversationId, `websocket:${client.id}`);
|
||||
this.agentService.removeChannel(
|
||||
session.conversationId,
|
||||
`websocket:${client.id}`,
|
||||
session.scope,
|
||||
);
|
||||
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')
|
||||
async handleMessage(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: ChatSocketMessageDto,
|
||||
@MessageBody() rawData: unknown,
|
||||
): 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 userId = (client.data.user as { id: string } | undefined)?.id;
|
||||
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_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(`Message from ${client.id} in conversation ${conversationId}`);
|
||||
this.logger.log(
|
||||
`Message from ${client.id} in conversation ${conversationId}${correlationId ? ` correlation=${correlationId}` : ''}`,
|
||||
);
|
||||
|
||||
// Ensure agent session exists for this conversation
|
||||
let sessionRoutingDecision: RoutingDecisionInfo | undefined;
|
||||
try {
|
||||
let agentSession = this.agentService.getSession(conversationId);
|
||||
let agentSession = this.agentService.getSession(conversationId, scope);
|
||||
if (!agentSession) {
|
||||
// When resuming an existing conversation, load prior messages to inject as context (M1-004)
|
||||
const conversationHistory = await this.loadConversationHistory(conversationId, userId);
|
||||
@@ -135,7 +274,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
let resolvedProvider = data.provider;
|
||||
let resolvedModelId = data.modelId;
|
||||
|
||||
const modelOverride = modelOverrides.get(conversationId);
|
||||
const modelOverride = modelOverrides.get(this.modelOverrideKey(conversationId, scope));
|
||||
if (modelOverride) {
|
||||
// /model override bypasses routing engine (M4-007)
|
||||
resolvedModelId = modelOverride;
|
||||
@@ -172,6 +311,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
modelId: resolvedModelId,
|
||||
agentConfigId: data.agentId,
|
||||
userId,
|
||||
tenantId: scope.tenantId,
|
||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||
});
|
||||
|
||||
@@ -210,9 +350,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
{
|
||||
conversationId,
|
||||
role: 'user',
|
||||
content: data.content,
|
||||
content: redactSensitiveContent(data.content).content,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
...(correlationId
|
||||
? {
|
||||
correlationId,
|
||||
discordMessageId: discordIngress?.messageId,
|
||||
discordUserId: discordIngress?.userId,
|
||||
}
|
||||
: {}),
|
||||
classifications: redactSensitiveContent(data.content).classifications,
|
||||
},
|
||||
},
|
||||
userId,
|
||||
@@ -232,9 +380,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
}
|
||||
|
||||
// Subscribe to agent events and relay to client
|
||||
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
||||
this.relayEvent(client, conversationId, event);
|
||||
});
|
||||
const cleanup = this.agentService.onEvent(
|
||||
conversationId,
|
||||
(event: AgentSessionEvent) => {
|
||||
this.relayEvent(client, conversationId, event);
|
||||
},
|
||||
scope,
|
||||
);
|
||||
|
||||
// Preserve routing decision from the existing client session if we didn't get a new one
|
||||
const prevClientSession = this.clientSessions.get(client.id);
|
||||
@@ -246,16 +398,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
assistantText: '',
|
||||
toolCalls: [],
|
||||
pendingToolCalls: new Map(),
|
||||
scope,
|
||||
lastRoutingDecision: routingDecisionToStore,
|
||||
});
|
||||
|
||||
// Track channel connection
|
||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`, scope);
|
||||
|
||||
// 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)
|
||||
{
|
||||
const agentSession = this.agentService.getSession(conversationId);
|
||||
const agentSession = this.agentService.getSession(conversationId, scope);
|
||||
if (agentSession) {
|
||||
const piSession = agentSession.piSession;
|
||||
client.emit('session:info', {
|
||||
@@ -271,11 +424,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
}
|
||||
|
||||
// Send acknowledgment
|
||||
client.emit('message:ack', { conversationId, messageId: uuid() });
|
||||
client.emit('message:ack', {
|
||||
conversationId,
|
||||
messageId: uuid(),
|
||||
...(correlationId
|
||||
? {
|
||||
correlationId,
|
||||
discordMessageId: discordIngress?.messageId,
|
||||
discordUserId: discordIngress?.userId,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
// Dispatch to agent
|
||||
try {
|
||||
await this.agentService.prompt(conversationId, data.content);
|
||||
await this.agentService.prompt(conversationId, data.content, scope);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
|
||||
@@ -293,7 +456,16 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: SetThinkingPayload,
|
||||
): void {
|
||||
const session = this.agentService.getSession(data.conversationId);
|
||||
const scope = this.getClientScope(client);
|
||||
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) {
|
||||
client.emit('error', {
|
||||
conversationId: data.conversationId,
|
||||
@@ -334,7 +506,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
const conversationId = data.conversationId;
|
||||
this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`);
|
||||
|
||||
const session = this.agentService.getSession(conversationId);
|
||||
const scope = this.getClientScope(client);
|
||||
if (!scope) {
|
||||
client.emit('error', { conversationId, error: 'Authenticated user scope is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = this.agentService.getSession(conversationId, scope);
|
||||
if (!session) {
|
||||
client.emit('error', {
|
||||
conversationId,
|
||||
@@ -363,11 +541,45 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: SlashCommandPayload,
|
||||
): Promise<void> {
|
||||
const userId = (client.data.user as { id: string } | undefined)?.id ?? 'unknown';
|
||||
const result = await this.commandExecutor.execute(payload, userId);
|
||||
const scope = this.getClientScope(client);
|
||||
if (!scope) {
|
||||
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);
|
||||
}
|
||||
|
||||
@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 {
|
||||
this.server.emit('system:reload', payload);
|
||||
this.logger.log('Broadcasted system:reload to all connected clients');
|
||||
@@ -380,18 +592,23 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
* 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.
|
||||
*/
|
||||
setModelOverride(conversationId: string, modelName: string | null): void {
|
||||
setModelOverride(
|
||||
conversationId: string,
|
||||
modelName: string | null,
|
||||
scope: ActorTenantScope,
|
||||
): void {
|
||||
const key = this.modelOverrideKey(conversationId, scope);
|
||||
if (modelName) {
|
||||
modelOverrides.set(conversationId, modelName);
|
||||
modelOverrides.set(key, 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
|
||||
this.agentService.updateSessionModel(conversationId, modelName);
|
||||
this.agentService.updateSessionModel(conversationId, modelName, scope);
|
||||
|
||||
// M5-005: Broadcast session:info to all clients subscribed to this conversation
|
||||
this.broadcastSessionInfo(conversationId);
|
||||
this.broadcastSessionInfo(conversationId, scope);
|
||||
} else {
|
||||
modelOverrides.delete(conversationId);
|
||||
modelOverrides.delete(key);
|
||||
this.logger.log(`Model override cleared: conversation=${conversationId}`);
|
||||
}
|
||||
}
|
||||
@@ -399,8 +616,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
/**
|
||||
* Return the active model override for a conversation, or undefined if none.
|
||||
*/
|
||||
getModelOverride(conversationId: string): string | undefined {
|
||||
return modelOverrides.get(conversationId);
|
||||
getModelOverride(conversationId: string, scope: ActorTenantScope): string | undefined {
|
||||
return modelOverrides.get(this.modelOverrideKey(conversationId, scope));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -409,9 +626,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
*/
|
||||
broadcastSessionInfo(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo },
|
||||
): void {
|
||||
const agentSession = this.agentService.getSession(conversationId);
|
||||
const agentSession = this.agentService.getSession(conversationId, scope);
|
||||
if (!agentSession) return;
|
||||
|
||||
const piSession = agentSession.piSession;
|
||||
@@ -428,7 +646,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
|
||||
// Emit to all clients currently subscribed to this conversation
|
||||
for (const [clientId, session] of this.clientSessions) {
|
||||
if (session.conversationId === conversationId) {
|
||||
if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) {
|
||||
const socket = this.server.sockets.sockets.get(clientId);
|
||||
if (socket?.connected) {
|
||||
socket.emit('session:info', payload);
|
||||
@@ -442,6 +660,233 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
* Creates it if absent — safe to call concurrently since a duplicate insert
|
||||
* 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> {
|
||||
try {
|
||||
const existing = await this.brain.conversations.findById(conversationId, userId);
|
||||
@@ -527,6 +972,127 @@ 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 {
|
||||
if (!client.connected) {
|
||||
this.logger.warn(
|
||||
@@ -544,13 +1110,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
cs.toolCalls = [];
|
||||
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 });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'agent_end': {
|
||||
// Gather usage stats from the Pi session
|
||||
const agentSession = this.agentService.getSession(conversationId);
|
||||
const activeClientSession = this.clientSessions.get(client.id);
|
||||
const agentSession = activeClientSession
|
||||
? this.agentService.getSession(conversationId, activeClientSession.scope)
|
||||
: undefined;
|
||||
const piSession = agentSession?.piSession;
|
||||
const stats = piSession?.getSessionStats();
|
||||
const contextUsage = piSession?.getContextUsage();
|
||||
@@ -569,6 +1142,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
}
|
||||
: undefined;
|
||||
|
||||
this.flushRedactedEgress(
|
||||
client,
|
||||
conversationId,
|
||||
'agent:text',
|
||||
this.textEgressBuffers,
|
||||
true,
|
||||
);
|
||||
this.flushRedactedEgress(
|
||||
client,
|
||||
conversationId,
|
||||
'agent:thinking',
|
||||
this.thinkingEgressBuffers,
|
||||
true,
|
||||
);
|
||||
client.emit('agent:end', {
|
||||
conversationId,
|
||||
usage: usagePayload,
|
||||
@@ -611,8 +1198,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
{
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: cs.assistantText,
|
||||
metadata,
|
||||
content: redactSensitiveContent(cs.assistantText).content,
|
||||
metadata: {
|
||||
...metadata,
|
||||
classifications: redactSensitiveContent(cs.assistantText).classifications,
|
||||
},
|
||||
},
|
||||
userId,
|
||||
)
|
||||
@@ -634,20 +1224,26 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
case 'message_update': {
|
||||
const assistantEvent = event.assistantMessageEvent;
|
||||
if (assistantEvent.type === 'text_delta') {
|
||||
// Accumulate assistant text for persistence
|
||||
// Keep raw stream material in memory only; persist and emit only redacted text.
|
||||
const cs = this.clientSessions.get(client.id);
|
||||
if (cs) {
|
||||
cs.assistantText += assistantEvent.delta;
|
||||
}
|
||||
client.emit('agent:text', {
|
||||
this.appendAndFlushRedactedEgress(
|
||||
client,
|
||||
conversationId,
|
||||
text: assistantEvent.delta,
|
||||
});
|
||||
'agent:text',
|
||||
this.textEgressBuffers,
|
||||
assistantEvent.delta,
|
||||
);
|
||||
} else if (assistantEvent.type === 'thinking_delta') {
|
||||
client.emit('agent:thinking', {
|
||||
this.appendAndFlushRedactedEgress(
|
||||
client,
|
||||
conversationId,
|
||||
text: assistantEvent.delta,
|
||||
});
|
||||
'agent:thinking',
|
||||
this.thinkingEgressBuffers,
|
||||
assistantEvent.delta,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
116
apps/gateway/src/commands/command-authorization.service.spec.ts
Normal file
116
apps/gateway/src/commands/command-authorization.service.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
268
apps/gateway/src/commands/command-authorization.service.ts
Normal file
268
apps/gateway/src/commands/command-authorization.service.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
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,6 +89,7 @@ function buildService(): CommandExecutorService {
|
||||
describe('CommandExecutorService — P8-012 commands', () => {
|
||||
let service: CommandExecutorService;
|
||||
const userId = 'user-123';
|
||||
const userScope = { userId, tenantId: userId };
|
||||
const conversationId = 'conv-456';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -99,31 +100,26 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /provider login — missing provider name
|
||||
it('/provider login with no provider name returns usage error', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Usage: /provider login');
|
||||
expect(result.command).toBe('provider');
|
||||
});
|
||||
|
||||
// /provider login anthropic — success with URL containing poll token
|
||||
it('/provider login <name> returns success with URL and poll token', async () => {
|
||||
// /provider login anthropic — no bearer token or auth URL reaches chat output
|
||||
it('/provider login <name> keeps its one-time token out of chat output', async () => {
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'provider',
|
||||
args: 'login anthropic',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('provider');
|
||||
expect(result.message).toContain('anthropic');
|
||||
expect(result.message).toContain('http');
|
||||
// data should contain loginUrl and pollToken
|
||||
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);
|
||||
expect(result.message).not.toContain('http');
|
||||
expect(result.message).not.toContain('token=');
|
||||
expect(result.data).toEqual({ provider: 'anthropic' });
|
||||
// Verify Valkey was called
|
||||
expect(mockRedis.set).toHaveBeenCalledOnce();
|
||||
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
|
||||
@@ -138,7 +134,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /provider with no args — returns usage
|
||||
it('/provider with no args returns usage message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Usage: /provider');
|
||||
});
|
||||
@@ -146,7 +142,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /provider list
|
||||
it('/provider list returns success', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('provider');
|
||||
});
|
||||
@@ -154,7 +150,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /provider logout with no name — usage error
|
||||
it('/provider logout with no name returns error', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Usage: /provider logout');
|
||||
});
|
||||
@@ -166,7 +162,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
args: 'unknown',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Unknown subcommand');
|
||||
});
|
||||
@@ -174,7 +170,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /mission status
|
||||
it('/mission status returns stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('mission');
|
||||
expect(result.message).toContain('Mission status');
|
||||
@@ -183,7 +179,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /mission with no args
|
||||
it('/mission with no args returns status stub', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'mission', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Mission status');
|
||||
});
|
||||
@@ -195,7 +191,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
args: 'set my-mission-123',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('my-mission-123');
|
||||
});
|
||||
@@ -203,7 +199,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /agent list
|
||||
it('/agent list returns stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('agent');
|
||||
expect(result.message).toContain('agent');
|
||||
@@ -212,7 +208,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /agent with no args
|
||||
it('/agent with no args returns usage', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'agent', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Usage: /agent');
|
||||
});
|
||||
@@ -224,7 +220,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
args: 'my-agent-id',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('my-agent-id');
|
||||
});
|
||||
@@ -232,7 +228,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /prdy
|
||||
it('/prdy returns PRD wizard message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('prdy');
|
||||
expect(result.message).toContain('mosaic prdy');
|
||||
@@ -241,7 +237,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /tools
|
||||
it('/tools returns tools stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'tools', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('tools');
|
||||
expect(result.message).toContain('tools');
|
||||
|
||||
112
apps/gateway/src/commands/command-executor-tess-security.spec.ts
Normal file
112
apps/gateway/src/commands/command-executor-tess-security.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
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,6 +3,7 @@ import type { QueueHandle } from '@mosaicstack/queue';
|
||||
import type { Brain } from '@mosaicstack/brain';
|
||||
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
@@ -10,6 +11,7 @@ import { ReloadService } from '../reload/reload.service.js';
|
||||
import { McpClientService } from '../mcp-client/mcp-client.service.js';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -32,10 +34,17 @@ export class CommandExecutorService {
|
||||
@Optional()
|
||||
@Inject(McpClientService)
|
||||
private readonly mcpClient: McpClientService | null,
|
||||
@Optional()
|
||||
@Inject(CommandAuthorizationService)
|
||||
private readonly authorization: CommandAuthorizationService | null = null,
|
||||
) {}
|
||||
|
||||
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
|
||||
async execute(
|
||||
payload: SlashCommandPayload,
|
||||
scope: ActorTenantScope,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
const { command, args, conversationId } = payload;
|
||||
const userId = scope.userId;
|
||||
|
||||
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
||||
if (!def) {
|
||||
@@ -47,14 +56,24 @@ 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 {
|
||||
switch (command) {
|
||||
case 'model':
|
||||
return await this.handleModel(args ?? null, conversationId);
|
||||
return await this.handleModel(args ?? null, conversationId, scope);
|
||||
case 'thinking':
|
||||
return await this.handleThinking(args ?? null, conversationId);
|
||||
case 'system':
|
||||
return await this.handleSystem(args ?? null, conversationId);
|
||||
return await this.handleSystem(args ?? null, conversationId, scope);
|
||||
case 'new':
|
||||
return {
|
||||
command,
|
||||
@@ -83,18 +102,17 @@ export class CommandExecutorService {
|
||||
success: true,
|
||||
message: 'Retry last message requested.',
|
||||
};
|
||||
case 'gc': {
|
||||
// Admin-only: system-wide GC sweep across all sessions
|
||||
const result = await this.sessionGC.sweepOrphans();
|
||||
case 'gc':
|
||||
// Global retention requires a separate, authorized and audited job.
|
||||
// Session cleanup is performed only through the session lifecycle.
|
||||
return {
|
||||
command: 'gc',
|
||||
success: true,
|
||||
message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`,
|
||||
success: false,
|
||||
message: 'Global GC is disabled pending an authorized retention job.',
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
case 'agent':
|
||||
return await this.handleAgent(args ?? null, conversationId, userId);
|
||||
return await this.handleAgent(args ?? null, conversationId, scope);
|
||||
case 'provider':
|
||||
return await this.handleProvider(args ?? null, userId, conversationId);
|
||||
case 'mission':
|
||||
@@ -143,13 +161,22 @@ 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(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!args || args.trim().length === 0) {
|
||||
// Show current override or usage hint
|
||||
const currentOverride = this.chatGateway?.getModelOverride(conversationId);
|
||||
const currentOverride = this.chatGateway?.getModelOverride(conversationId, scope);
|
||||
if (currentOverride) {
|
||||
return {
|
||||
command: 'model',
|
||||
@@ -171,7 +198,7 @@ export class CommandExecutorService {
|
||||
|
||||
// /model clear removes the override and re-enables automatic routing
|
||||
if (modelName === 'clear') {
|
||||
this.chatGateway?.setModelOverride(conversationId, null);
|
||||
this.chatGateway?.setModelOverride(conversationId, null, scope);
|
||||
return {
|
||||
command: 'model',
|
||||
conversationId,
|
||||
@@ -181,9 +208,9 @@ export class CommandExecutorService {
|
||||
}
|
||||
|
||||
// Set the sticky per-session override (M4-007)
|
||||
this.chatGateway?.setModelOverride(conversationId, modelName);
|
||||
this.chatGateway?.setModelOverride(conversationId, modelName, scope);
|
||||
|
||||
const session = this.agentService.getSession(conversationId);
|
||||
const session = this.agentService.getSession(conversationId, scope);
|
||||
if (!session) {
|
||||
return {
|
||||
command: 'model',
|
||||
@@ -224,10 +251,11 @@ export class CommandExecutorService {
|
||||
private async handleSystem(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!args || args.trim().length === 0) {
|
||||
// Clear the override when called with no args
|
||||
await this.systemOverride.clear(conversationId);
|
||||
await this.systemOverride.clear(conversationId, scope);
|
||||
return {
|
||||
command: 'system',
|
||||
conversationId,
|
||||
@@ -236,7 +264,7 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
|
||||
await this.systemOverride.set(conversationId, args.trim());
|
||||
await this.systemOverride.set(conversationId, args.trim(), scope);
|
||||
return {
|
||||
command: 'system',
|
||||
conversationId,
|
||||
@@ -248,8 +276,9 @@ export class CommandExecutorService {
|
||||
private async handleAgent(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
scope: ActorTenantScope,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
const userId = scope.userId;
|
||||
if (!args) {
|
||||
return {
|
||||
command: 'agent',
|
||||
@@ -338,11 +367,14 @@ export class CommandExecutorService {
|
||||
conversationId,
|
||||
agentConfig.id,
|
||||
agentConfig.name,
|
||||
scope,
|
||||
agentConfig.model ?? undefined,
|
||||
);
|
||||
|
||||
// Broadcast updated session:info so TUI TopBar reflects new agent/model
|
||||
this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name });
|
||||
this.chatGateway?.broadcastSessionInfo(conversationId, scope, {
|
||||
agentName: agentConfig.name,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
|
||||
@@ -403,22 +435,28 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
const pollToken = crypto.randomUUID();
|
||||
const key = `mosaic:auth:poll:${pollToken}`;
|
||||
// Store pending state in Valkey (TTL 5 minutes)
|
||||
const tokenDigest = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
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(
|
||||
key,
|
||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||
'EX',
|
||||
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 {
|
||||
command: 'provider',
|
||||
success: true,
|
||||
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`,
|
||||
message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`,
|
||||
conversationId,
|
||||
data: { loginUrl, pollToken, provider: providerName },
|
||||
data: { provider: providerName },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
let registry: CommandRegistryService;
|
||||
let executor: CommandExecutorService;
|
||||
const userId = 'user-integ-001';
|
||||
const userScope = { userId, tenantId: userId };
|
||||
const conversationId = 'conv-integ-001';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -170,28 +171,26 @@ describe('CommandExecutorService — integration', () => {
|
||||
// Unknown command returns error
|
||||
it('unknown command returns success:false with descriptive message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'nonexistent', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('nonexistent');
|
||||
expect(result.command).toBe('nonexistent');
|
||||
});
|
||||
|
||||
// /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg)
|
||||
it('/gc calls SessionGCService.sweepOrphans without arguments', async () => {
|
||||
it('/gc refuses an unaudited global sweep', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'gc', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('GC sweep complete');
|
||||
expect(result.message).toContain('3 orphaned sessions');
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(mockSessionGC.sweepOrphans).not.toHaveBeenCalled();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('disabled pending an authorized retention job');
|
||||
});
|
||||
|
||||
// /system with args calls SystemOverrideService.set
|
||||
it('/system with text calls SystemOverrideService.set', async () => {
|
||||
const override = 'You are a helpful assistant.';
|
||||
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('override set');
|
||||
});
|
||||
@@ -199,8 +198,8 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /system with no args clears the override
|
||||
it('/system with no args calls SystemOverrideService.clear', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'system', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('cleared');
|
||||
});
|
||||
@@ -212,7 +211,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
args: 'claude-3-opus',
|
||||
conversationId,
|
||||
};
|
||||
const result = await executor.execute(payload, userId);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('model');
|
||||
expect(result.message).toContain('claude-3-opus');
|
||||
@@ -221,7 +220,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /thinking with valid level returns success
|
||||
it('/thinking with valid level returns success', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('high');
|
||||
});
|
||||
@@ -229,7 +228,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /thinking with invalid level returns usage message
|
||||
it('/thinking with invalid level returns usage message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Usage:');
|
||||
});
|
||||
@@ -237,7 +236,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /new command returns success
|
||||
it('/new returns success', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'new', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('new');
|
||||
});
|
||||
@@ -245,7 +244,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /reload without reloadService returns failure
|
||||
it('/reload without ReloadService returns failure', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'reload', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('ReloadService');
|
||||
});
|
||||
@@ -255,7 +254,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
for (const cmd of stubCommands) {
|
||||
it(`/${cmd} returns success (stub)`, async () => {
|
||||
const payload: SlashCommandPayload = { command: cmd, conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe(cmd);
|
||||
});
|
||||
|
||||
@@ -3,8 +3,10 @@ import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import { ChatModule } from '../chat/chat.module.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
import { ReloadModule } from '../reload/reload.module.js';
|
||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import { CommandRuntimeApprovalVerifier } from './runtime-approval-verifier.js';
|
||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
|
||||
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
@@ -24,9 +26,16 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
inject: [COMMANDS_QUEUE_HANDLE],
|
||||
},
|
||||
CommandRegistryService,
|
||||
CommandAuthorizationService,
|
||||
CommandRuntimeApprovalVerifier,
|
||||
CommandExecutorService,
|
||||
],
|
||||
exports: [
|
||||
CommandRegistryService,
|
||||
CommandAuthorizationService,
|
||||
CommandRuntimeApprovalVerifier,
|
||||
CommandExecutorService,
|
||||
],
|
||||
exports: [CommandRegistryService, CommandExecutorService],
|
||||
})
|
||||
export class CommandsModule implements OnApplicationShutdown {
|
||||
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
|
||||
23
apps/gateway/src/commands/runtime-approval-verifier.ts
Normal file
23
apps/gateway/src/commands/runtime-approval-verifier.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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,10 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InMemoryInteractionCoordinationPort } from '@mosaicstack/coord';
|
||||
import { CoordService } from './coord.service.js';
|
||||
import { CoordController } from './coord.controller.js';
|
||||
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
||||
import {
|
||||
COORDINATION_CONFIG,
|
||||
COORDINATION_PORT,
|
||||
InteractionCoordinationService,
|
||||
} from './interaction-coordination.service.js';
|
||||
|
||||
@Module({
|
||||
providers: [CoordService],
|
||||
controllers: [CoordController],
|
||||
exports: [CoordService],
|
||||
providers: [
|
||||
CoordService,
|
||||
{
|
||||
provide: COORDINATION_PORT,
|
||||
useFactory: (): InMemoryInteractionCoordinationPort =>
|
||||
new InMemoryInteractionCoordinationPort(),
|
||||
},
|
||||
{
|
||||
provide: COORDINATION_CONFIG,
|
||||
useFactory: () => ({
|
||||
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
|
||||
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
|
||||
}),
|
||||
},
|
||||
InteractionCoordinationService,
|
||||
],
|
||||
controllers: [CoordController, InteractionCoordinationController],
|
||||
exports: [CoordService, InteractionCoordinationService],
|
||||
})
|
||||
export class CoordModule {}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
||||
|
||||
const user = { id: 'operator-1', tenantId: 'tenant-a' };
|
||||
|
||||
describe('InteractionCoordinationController', () => {
|
||||
it('derives actor and tenant from the authenticated user rather than handoff input', async () => {
|
||||
const coordination = {
|
||||
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
||||
observe: vi.fn(),
|
||||
result: vi.fn(),
|
||||
};
|
||||
const controller = new InteractionCoordinationController(coordination as never);
|
||||
|
||||
await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1');
|
||||
|
||||
expect(coordination.handoff).toHaveBeenCalledWith(
|
||||
{ idempotencyKey: 'request-1', summary: 'Implement' },
|
||||
expect.objectContaining({
|
||||
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
||||
channelId: 'cli',
|
||||
correlationId: 'corr-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a correlation header before invoking the coordination service', async () => {
|
||||
const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() };
|
||||
const controller = new InteractionCoordinationController(coordination as never);
|
||||
|
||||
await expect(
|
||||
controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined),
|
||||
).rejects.toThrow('X-Correlation-Id is required');
|
||||
expect(coordination.handoff).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Headers,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||
import type {
|
||||
InteractionCoordinationObservationDto,
|
||||
InteractionCoordinationResponseDto,
|
||||
InteractionCoordinationResultDto,
|
||||
CreateHandoffDto,
|
||||
} from './interaction-coordination.dto.js';
|
||||
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
||||
|
||||
/** Authenticated interaction-plane boundary for the handoff/observe/result-only interaction coordination contract. */
|
||||
@Controller('api/coord/mos')
|
||||
@UseGuards(AuthGuard)
|
||||
export class InteractionCoordinationController {
|
||||
constructor(
|
||||
@Inject(InteractionCoordinationService)
|
||||
private readonly coordination: InteractionCoordinationService,
|
||||
) {}
|
||||
|
||||
@Post('handoff')
|
||||
async handoff(
|
||||
@Body() request: CreateHandoffDto,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
): Promise<InteractionCoordinationResponseDto> {
|
||||
return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) };
|
||||
}
|
||||
|
||||
@Get(':handoffId/observe')
|
||||
async observe(
|
||||
@Param('handoffId') handoffId: string,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
): Promise<InteractionCoordinationObservationDto> {
|
||||
return {
|
||||
observation: await this.coordination.observe(handoffId, this.context(user, correlationId)),
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':handoffId/result')
|
||||
async result(
|
||||
@Param('handoffId') handoffId: string,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
): Promise<InteractionCoordinationResultDto> {
|
||||
return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) };
|
||||
}
|
||||
|
||||
private context(
|
||||
user: AuthenticatedUserLike,
|
||||
correlationId?: string,
|
||||
): RuntimeProviderRequestContext {
|
||||
const requestCorrelationId = correlationId?.trim();
|
||||
if (!requestCorrelationId) throw new ForbiddenException('X-Correlation-Id is required');
|
||||
return {
|
||||
actorScope: scopeFromUser(user),
|
||||
channelId: 'cli',
|
||||
correlationId: requestCorrelationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
25
apps/gateway/src/coord/interaction-coordination.dto.ts
Normal file
25
apps/gateway/src/coord/interaction-coordination.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type {
|
||||
CoordinationObservation,
|
||||
CoordinationResult,
|
||||
HandoffReceipt,
|
||||
} from '@mosaicstack/coord';
|
||||
|
||||
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
|
||||
export interface CreateHandoffDto {
|
||||
idempotencyKey: string;
|
||||
summary: string;
|
||||
context?: string;
|
||||
missionId?: string;
|
||||
}
|
||||
|
||||
export interface InteractionCoordinationResponseDto {
|
||||
receipt: HandoffReceipt;
|
||||
}
|
||||
|
||||
export interface InteractionCoordinationObservationDto {
|
||||
observation: CoordinationObservation;
|
||||
}
|
||||
|
||||
export interface InteractionCoordinationResultDto {
|
||||
result: CoordinationResult;
|
||||
}
|
||||
217
apps/gateway/src/coord/interaction-coordination.service.test.ts
Normal file
217
apps/gateway/src/coord/interaction-coordination.service.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
InMemoryInteractionCoordinationPort,
|
||||
type InteractionCoordinationPort,
|
||||
type Handoff,
|
||||
} from '@mosaicstack/coord';
|
||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||
import {
|
||||
InteractionCoordinationService,
|
||||
type InteractionCoordinationConfig,
|
||||
type InteractionCoordinationGatewayError,
|
||||
} from './interaction-coordination.service.js';
|
||||
|
||||
const context: RuntimeProviderRequestContext = {
|
||||
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
||||
channelId: 'cli',
|
||||
correlationId: 'corr-1',
|
||||
};
|
||||
|
||||
const config: InteractionCoordinationConfig = {
|
||||
interactionAgentId: 'Nova',
|
||||
orchestrationAgentId: 'Conductor',
|
||||
};
|
||||
|
||||
function service(
|
||||
port: InteractionCoordinationPort = new InMemoryInteractionCoordinationPort(),
|
||||
options: {
|
||||
config?: InteractionCoordinationConfig;
|
||||
handoffIdFactory?: () => string;
|
||||
} = {},
|
||||
): InteractionCoordinationService {
|
||||
return new InteractionCoordinationService(
|
||||
port,
|
||||
options.config ?? config,
|
||||
options.handoffIdFactory ?? (() => 'handoff-1'),
|
||||
);
|
||||
}
|
||||
|
||||
describe('InteractionCoordinationService authority boundary', (): void => {
|
||||
it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise<void> => {
|
||||
const adapter = new InMemoryInteractionCoordinationPort();
|
||||
const coordination = service(adapter);
|
||||
|
||||
await expect(
|
||||
coordination.handoff(
|
||||
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||
context,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
handoffId: 'handoff-1',
|
||||
targetAgentId: 'Conductor',
|
||||
status: 'queued',
|
||||
correlationId: 'corr-1',
|
||||
});
|
||||
|
||||
adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request');
|
||||
adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator');
|
||||
|
||||
const followUpContext = { ...context, correlationId: 'corr-2' };
|
||||
await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({
|
||||
targetAgentId: 'Conductor',
|
||||
status: 'completed',
|
||||
});
|
||||
await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({
|
||||
targetAgentId: 'Conductor',
|
||||
status: 'completed',
|
||||
summary: 'Merged by orchestrator',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise<void> => {
|
||||
const adapter = new InMemoryInteractionCoordinationPort();
|
||||
const handoff = vi.spyOn(adapter, 'handoff');
|
||||
const coordination = service(adapter, {
|
||||
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordination.handoff(
|
||||
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||
context,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'unconfigured_requester',
|
||||
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||
expect(handoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
|
||||
const adapter = new InMemoryInteractionCoordinationPort();
|
||||
const handoff = vi.spyOn(adapter, 'handoff');
|
||||
const coordination = service(adapter, {
|
||||
config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordination.handoff(
|
||||
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||
context,
|
||||
),
|
||||
).rejects.toThrow('Interaction and orchestration identities must differ');
|
||||
expect(handoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies cross-tenant observe and result before calling the adapter', async (): Promise<void> => {
|
||||
const adapter = new InMemoryInteractionCoordinationPort();
|
||||
const observe = vi.spyOn(adapter, 'observe');
|
||||
const result = vi.spyOn(adapter, 'result');
|
||||
const coordination = service(adapter);
|
||||
await coordination.handoff(
|
||||
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||
context,
|
||||
);
|
||||
|
||||
const otherTenant = {
|
||||
...context,
|
||||
actorScope: { ...context.actorScope, tenantId: 'tenant-b' },
|
||||
};
|
||||
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||
code: 'cross_tenant_forbidden',
|
||||
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||
code: 'cross_tenant_forbidden',
|
||||
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||
expect(observe).not.toHaveBeenCalled();
|
||||
expect(result).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scopes idempotency by actor and joins concurrent retries without duplicate delivery', async (): Promise<void> => {
|
||||
let handoffSequence = 0;
|
||||
let release: (() => void) | undefined;
|
||||
const delivered = new Promise<void>((resolve: () => void): void => {
|
||||
release = resolve;
|
||||
});
|
||||
const adapter: InteractionCoordinationPort = {
|
||||
handoff: vi.fn(async (handoff: Handoff) => {
|
||||
await delivered;
|
||||
return {
|
||||
handoffId: handoff.handoffId,
|
||||
targetAgentId: handoff.targetAgentId,
|
||||
status: 'queued' as const,
|
||||
correlationId: handoff.scope.correlationId,
|
||||
};
|
||||
}),
|
||||
observe: vi.fn(),
|
||||
result: vi.fn(),
|
||||
};
|
||||
const coordination = service(adapter, {
|
||||
handoffIdFactory: (): string => `handoff-${++handoffSequence}`,
|
||||
});
|
||||
const request = { idempotencyKey: 'request-1', summary: 'Implement the requested feature' };
|
||||
|
||||
const first = coordination.handoff(request, context);
|
||||
const retry = coordination.handoff(request, context);
|
||||
expect(adapter.handoff).toHaveBeenCalledTimes(1);
|
||||
release?.();
|
||||
await expect(Promise.all([first, retry])).resolves.toEqual([
|
||||
expect.objectContaining({ handoffId: 'handoff-1' }),
|
||||
expect.objectContaining({ handoffId: 'handoff-1' }),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
coordination.handoff(request, {
|
||||
...context,
|
||||
actorScope: { ...context.actorScope, userId: 'operator-2' },
|
||||
}),
|
||||
).resolves.toMatchObject({ handoffId: 'handoff-2' });
|
||||
expect(adapter.handoff).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise<void> => {
|
||||
const adapter = new InMemoryInteractionCoordinationPort();
|
||||
const handoff = vi.spyOn(adapter, 'handoff');
|
||||
const coordination = service(adapter);
|
||||
await coordination.handoff(
|
||||
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||
context,
|
||||
);
|
||||
|
||||
await expect(
|
||||
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
|
||||
).rejects.toMatchObject({
|
||||
code: 'handoff_conflict',
|
||||
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||
await expect(
|
||||
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_request',
|
||||
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||
await expect(
|
||||
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_request',
|
||||
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||
expect(handoff).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
|
||||
const adapter: InteractionCoordinationPort = {
|
||||
handoff: vi.fn(async (handoff: Handoff) => ({
|
||||
handoffId: handoff.handoffId,
|
||||
targetAgentId: 'Unexpected',
|
||||
status: 'accepted' as const,
|
||||
correlationId: handoff.scope.correlationId,
|
||||
})),
|
||||
observe: vi.fn(),
|
||||
result: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
service(adapter).handoff(
|
||||
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||
context,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'target_drift' });
|
||||
});
|
||||
});
|
||||
303
apps/gateway/src/coord/interaction-coordination.service.ts
Normal file
303
apps/gateway/src/coord/interaction-coordination.service.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
InteractionCoordinationClient,
|
||||
type CoordinationObservation,
|
||||
type CoordinationResult,
|
||||
type CoordinationScope,
|
||||
type InteractionCoordinationIdentity,
|
||||
type InteractionCoordinationPort,
|
||||
type HandoffReceipt,
|
||||
} from '@mosaicstack/coord';
|
||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||
import type { CreateHandoffDto } from './interaction-coordination.dto.js';
|
||||
|
||||
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
||||
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
||||
|
||||
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
||||
const MAX_TRACKED_HANDOFFS = 1_000;
|
||||
const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
|
||||
const MAX_SUMMARY_LENGTH = 2_048;
|
||||
const MAX_CONTEXT_LENGTH = 8_192;
|
||||
const MAX_MISSION_ID_LENGTH = 128;
|
||||
|
||||
export interface InteractionCoordinationConfig {
|
||||
interactionAgentId?: string;
|
||||
orchestrationAgentId?: string;
|
||||
}
|
||||
|
||||
interface HandoffOwner {
|
||||
actorId: string;
|
||||
tenantId: string;
|
||||
requesterAgentId: string;
|
||||
correlationId: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface NormalizedHandoffRequest {
|
||||
idempotencyKey: string;
|
||||
summary: string;
|
||||
context?: string;
|
||||
missionId?: string;
|
||||
}
|
||||
|
||||
interface TrackedHandoff {
|
||||
request: NormalizedHandoffRequest;
|
||||
receipt: Promise<HandoffReceipt>;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway authority boundary for the interaction agent. It derives requester,
|
||||
* actor, and tenant from trusted server configuration and authentication; no
|
||||
* channel request can name a target or gain orchestrator-owned orchestration verbs.
|
||||
*/
|
||||
@Injectable()
|
||||
export class InteractionCoordinationService {
|
||||
private readonly owners = new Map<string, HandoffOwner>();
|
||||
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
|
||||
|
||||
constructor(
|
||||
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
||||
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||
) {}
|
||||
|
||||
async handoff(
|
||||
request: CreateHandoffDto,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): Promise<HandoffReceipt> {
|
||||
this.pruneExpiredTracking();
|
||||
const normalized = this.normalizeRequest(request);
|
||||
const scope = this.scope(context);
|
||||
const idempotencyKey = this.idempotencyKey(normalized.idempotencyKey, scope);
|
||||
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
|
||||
if (existing !== undefined) {
|
||||
if (!sameRequest(existing.request, normalized)) {
|
||||
throw new InteractionCoordinationGatewayError(
|
||||
'handoff_conflict',
|
||||
'Handoff idempotency key is already bound to different immutable input',
|
||||
);
|
||||
}
|
||||
return existing.receipt;
|
||||
}
|
||||
|
||||
const pending = this.deliverHandoff(this.handoffIdFactory(), normalized, scope);
|
||||
const tracked: TrackedHandoff = {
|
||||
request: normalized,
|
||||
receipt: pending,
|
||||
expiresAt: this.expiresAt(),
|
||||
};
|
||||
this.handoffsByIdempotencyKey.set(idempotencyKey, tracked);
|
||||
this.enforceTrackingLimit(this.handoffsByIdempotencyKey);
|
||||
try {
|
||||
return await pending;
|
||||
} catch (error: unknown) {
|
||||
if (this.handoffsByIdempotencyKey.get(idempotencyKey) === tracked) {
|
||||
this.handoffsByIdempotencyKey.delete(idempotencyKey);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async observe(
|
||||
handoffId: string,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): Promise<CoordinationObservation> {
|
||||
this.pruneExpiredTracking();
|
||||
const scope = this.scope(context);
|
||||
const owner = this.ownerFor(handoffId, scope);
|
||||
return this.client().observe(handoffId, { ...scope, correlationId: owner.correlationId });
|
||||
}
|
||||
|
||||
async result(
|
||||
handoffId: string,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): Promise<CoordinationResult> {
|
||||
this.pruneExpiredTracking();
|
||||
const scope = this.scope(context);
|
||||
const owner = this.ownerFor(handoffId, scope);
|
||||
return this.client().result(handoffId, { ...scope, correlationId: owner.correlationId });
|
||||
}
|
||||
|
||||
private async deliverHandoff(
|
||||
handoffId: string,
|
||||
request: NormalizedHandoffRequest,
|
||||
scope: CoordinationScope,
|
||||
): Promise<HandoffReceipt> {
|
||||
const receipt = await this.client((): string => handoffId).handoff(request, scope);
|
||||
const owner: HandoffOwner = {
|
||||
actorId: scope.actorId,
|
||||
tenantId: scope.tenantId,
|
||||
requesterAgentId: scope.requesterAgentId,
|
||||
correlationId: scope.correlationId,
|
||||
expiresAt: this.expiresAt(),
|
||||
};
|
||||
const existing = this.owners.get(receipt.handoffId);
|
||||
if (existing !== undefined && !sameOwner(existing, owner)) {
|
||||
throw new InteractionCoordinationGatewayError(
|
||||
'handoff_conflict',
|
||||
'Handoff ID is already bound to a different authenticated scope',
|
||||
);
|
||||
}
|
||||
this.owners.set(receipt.handoffId, owner);
|
||||
this.enforceTrackingLimit(this.owners);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
private client(handoffIdFactory?: () => string): InteractionCoordinationClient {
|
||||
return new InteractionCoordinationClient(this.identity(), this.port, handoffIdFactory);
|
||||
}
|
||||
|
||||
private identity(): InteractionCoordinationIdentity {
|
||||
const interactionAgentId = this.config.interactionAgentId?.trim();
|
||||
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
|
||||
if (!interactionAgentId) {
|
||||
throw new InteractionCoordinationGatewayError(
|
||||
'unconfigured_requester',
|
||||
'Interaction agent identity is not configured',
|
||||
);
|
||||
}
|
||||
if (!orchestrationAgentId) {
|
||||
throw new InteractionCoordinationGatewayError(
|
||||
'unconfigured_target',
|
||||
'Orchestration agent identity is not configured',
|
||||
);
|
||||
}
|
||||
return { interactionAgentId, orchestrationAgentId };
|
||||
}
|
||||
|
||||
private scope(context: RuntimeProviderRequestContext): CoordinationScope {
|
||||
const identity = this.identity();
|
||||
return Object.freeze({
|
||||
actorId: context.actorScope.userId,
|
||||
tenantId: context.actorScope.tenantId,
|
||||
correlationId: context.correlationId,
|
||||
requesterAgentId: identity.interactionAgentId,
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeRequest(request: CreateHandoffDto): NormalizedHandoffRequest {
|
||||
if (typeof request !== 'object' || request === null) {
|
||||
throw new InteractionCoordinationGatewayError(
|
||||
'invalid_request',
|
||||
'Handoff request is invalid',
|
||||
);
|
||||
}
|
||||
const idempotencyKey = this.requiredString(
|
||||
request.idempotencyKey,
|
||||
'idempotency key',
|
||||
MAX_IDEMPOTENCY_KEY_LENGTH,
|
||||
);
|
||||
const summary = this.requiredString(request.summary, 'summary', MAX_SUMMARY_LENGTH);
|
||||
const context = this.optionalString(request.context, 'context', MAX_CONTEXT_LENGTH);
|
||||
const missionId = this.optionalString(request.missionId, 'mission ID', MAX_MISSION_ID_LENGTH);
|
||||
return Object.freeze({
|
||||
idempotencyKey,
|
||||
summary,
|
||||
...(context === undefined ? {} : { context }),
|
||||
...(missionId === undefined ? {} : { missionId }),
|
||||
});
|
||||
}
|
||||
|
||||
private idempotencyKey(requestKey: string, scope: CoordinationScope): string {
|
||||
return `${scope.tenantId}\u0000${scope.actorId}\u0000${scope.requesterAgentId}\u0000${requestKey}`;
|
||||
}
|
||||
|
||||
private requiredString(value: unknown, field: string, maximumLength: number): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new InteractionCoordinationGatewayError(
|
||||
'invalid_request',
|
||||
`Handoff ${field} must be a string`,
|
||||
);
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (normalized.length === 0 || normalized.length > maximumLength) {
|
||||
throw new InteractionCoordinationGatewayError(
|
||||
'invalid_request',
|
||||
`Handoff ${field} is invalid`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private optionalString(value: unknown, field: string, maximumLength: number): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return this.requiredString(value, field, maximumLength);
|
||||
}
|
||||
|
||||
private expiresAt(): number {
|
||||
return Date.now() + HANDOFF_TRACKING_TTL_MS;
|
||||
}
|
||||
|
||||
private pruneExpiredTracking(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, tracked] of this.handoffsByIdempotencyKey) {
|
||||
if (tracked.expiresAt <= now) this.handoffsByIdempotencyKey.delete(key);
|
||||
}
|
||||
for (const [key, owner] of this.owners) {
|
||||
if (owner.expiresAt <= now) this.owners.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private enforceTrackingLimit<T>(entries: Map<string, T>): void {
|
||||
while (entries.size > MAX_TRACKED_HANDOFFS) {
|
||||
const oldest = entries.keys().next().value;
|
||||
if (typeof oldest !== 'string') return;
|
||||
entries.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
|
||||
const owner = this.owners.get(handoffId);
|
||||
if (owner === undefined) {
|
||||
throw new InteractionCoordinationGatewayError('not_found', 'Handoff was not found');
|
||||
}
|
||||
if (
|
||||
owner.tenantId !== scope.tenantId ||
|
||||
owner.actorId !== scope.actorId ||
|
||||
owner.requesterAgentId !== scope.requesterAgentId
|
||||
) {
|
||||
throw new InteractionCoordinationGatewayError(
|
||||
'cross_tenant_forbidden',
|
||||
'Handoff is outside the authenticated scope',
|
||||
);
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
}
|
||||
|
||||
export type InteractionCoordinationGatewayErrorCode =
|
||||
| 'cross_tenant_forbidden'
|
||||
| 'handoff_conflict'
|
||||
| 'invalid_request'
|
||||
| 'not_found'
|
||||
| 'unconfigured_requester'
|
||||
| 'unconfigured_target';
|
||||
|
||||
function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean {
|
||||
return (
|
||||
left.actorId === right.actorId &&
|
||||
left.tenantId === right.tenantId &&
|
||||
left.requesterAgentId === right.requesterAgentId
|
||||
);
|
||||
}
|
||||
|
||||
function sameRequest(left: NormalizedHandoffRequest, right: NormalizedHandoffRequest): boolean {
|
||||
return (
|
||||
left.idempotencyKey === right.idempotencyKey &&
|
||||
left.summary === right.summary &&
|
||||
left.context === right.context &&
|
||||
left.missionId === right.missionId
|
||||
);
|
||||
}
|
||||
|
||||
export class InteractionCoordinationGatewayError extends Error {
|
||||
constructor(
|
||||
readonly code: InteractionCoordinationGatewayErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = InteractionCoordinationGatewayError.name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
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,3 +11,13 @@ export {
|
||||
type FederationClientErrorCode,
|
||||
type FederationClientErrorOptions,
|
||||
} 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';
|
||||
|
||||
261
apps/gateway/src/federation/client/query-source.service.ts
Normal file
261
apps/gateway/src/federation/client/query-source.service.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 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,26 +4,35 @@ import { CaService } from './ca.service.js';
|
||||
import { EnrollmentController } from './enrollment.controller.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
import { FederationController } from './federation.controller.js';
|
||||
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
||||
import { GrantsService } from './grants.service.js';
|
||||
import { FederationClientService } from './client/index.js';
|
||||
import { FederationAuthGuard } from './server/index.js';
|
||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
||||
import { ListController } from './server/verbs/list.controller.js';
|
||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [EnrollmentController, FederationController],
|
||||
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
||||
providers: [
|
||||
AdminGuard,
|
||||
CaService,
|
||||
EnrollmentService,
|
||||
GrantsService,
|
||||
FederationClientService,
|
||||
QuerySourceService,
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
],
|
||||
exports: [
|
||||
CaService,
|
||||
EnrollmentService,
|
||||
GrantsService,
|
||||
FederationClientService,
|
||||
QuerySourceService,
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
],
|
||||
})
|
||||
export class FederationModule {}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 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,4 +10,22 @@
|
||||
*/
|
||||
|
||||
export { FederationAuthGuard } from './federation-auth.guard.js';
|
||||
export { FederationScopeService } from './scope.service.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';
|
||||
|
||||
272
apps/gateway/src/federation/server/scope.service.ts
Normal file
272
apps/gateway/src/federation/server/scope.service.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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[],
|
||||
};
|
||||
}
|
||||
}
|
||||
408
apps/gateway/src/federation/server/verbs/list-query.service.ts
Normal file
408
apps/gateway/src/federation/server/verbs/list-query.service.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
147
apps/gateway/src/federation/server/verbs/list.controller.ts
Normal file
147
apps/gateway/src/federation/server/verbs/list.controller.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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,6 +3,7 @@ import { Logger } from '@nestjs/common';
|
||||
import type { QueueHandle } from '@mosaicstack/queue';
|
||||
import type { LogService } from '@mosaicstack/log';
|
||||
import { SessionGCService } from './session-gc.service.js';
|
||||
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
||||
|
||||
type MockRedis = {
|
||||
scan: ReturnType<typeof vi.fn>;
|
||||
@@ -12,7 +13,12 @@ type MockRedis = {
|
||||
describe('SessionGCService', () => {
|
||||
let service: SessionGCService;
|
||||
let mockRedis: MockRedis;
|
||||
let mockLogService: { logs: { promoteToWarm: ReturnType<typeof vi.fn> } };
|
||||
let mockLogService: {
|
||||
logs: {
|
||||
promoteSessionToWarm: ReturnType<typeof vi.fn>;
|
||||
promoteToWarm: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper: build a scan mock that returns all provided keys in a single
|
||||
@@ -30,6 +36,7 @@ describe('SessionGCService', () => {
|
||||
|
||||
mockLogService = {
|
||||
logs: {
|
||||
promoteSessionToWarm: vi.fn().mockResolvedValue(0),
|
||||
promoteToWarm: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
};
|
||||
@@ -59,54 +66,76 @@ describe('SessionGCService', () => {
|
||||
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 () => {
|
||||
const result = await service.collect('test-session-id');
|
||||
expect(result.sessionId).toBe('test-session-id');
|
||||
});
|
||||
|
||||
it('fullCollect() deletes all session keys', async () => {
|
||||
mockRedis.scan = makeScanMock(['mosaic:session:abc:system', 'mosaic:session:xyz:foo']);
|
||||
const result = await service.fullCollect();
|
||||
expect(mockRedis.del).toHaveBeenCalled();
|
||||
expect(result.valkeyKeys).toBe(2);
|
||||
it('collect() demotes logs only for the requested session', async () => {
|
||||
await service.collect('owned-session');
|
||||
|
||||
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
|
||||
'owned-session',
|
||||
expect.any(Date),
|
||||
);
|
||||
expect(mockLogService.logs.promoteToWarm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fullCollect() with no keys returns 0 valkeyKeys', async () => {
|
||||
mockRedis.scan = makeScanMock([]);
|
||||
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);
|
||||
it('does not expose automatic global GC entry points', () => {
|
||||
expect('fullCollect' in service).toBe(false);
|
||||
expect('sweepOrphans' in service).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { QueueHandle } from '@mosaicstack/queue';
|
||||
import type { LogService } from '@mosaicstack/log';
|
||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||
@@ -13,49 +13,18 @@ export interface GCResult {
|
||||
};
|
||||
}
|
||||
|
||||
export interface GCSweepResult {
|
||||
orphanedSessions: number;
|
||||
totalCleaned: GCResult[];
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface FullGCResult {
|
||||
valkeyKeys: number;
|
||||
logsDemoted: number;
|
||||
jobsPurged: number;
|
||||
tempFilesRemoved: number;
|
||||
duration: number;
|
||||
/** Escape Redis glob metacharacters so a session identifier is always literal. */
|
||||
function escapeRedisGlobLiteral(value: string): string {
|
||||
return value.replace(/[\\*?\[\]]/g, '\\$&');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SessionGCService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SessionGCService.name);
|
||||
|
||||
export class SessionGCService {
|
||||
constructor(
|
||||
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||
@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).
|
||||
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
||||
@@ -79,86 +48,20 @@ export class SessionGCService implements OnModuleInit {
|
||||
const result: GCResult = { sessionId, cleaned: {} };
|
||||
|
||||
// 1. Valkey: delete all session-scoped keys
|
||||
const pattern = `mosaic:session:${sessionId}:*`;
|
||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||
const valkeyKeys = await this.scanKeys(pattern);
|
||||
if (valkeyKeys.length > 0) {
|
||||
await this.redis.del(...valkeyKeys);
|
||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||
}
|
||||
|
||||
// 2. PG: demote hot-tier agent_logs for this session to warm
|
||||
const cutoff = new Date(); // demote all hot logs for this session
|
||||
const logsDemoted = await this.logService.logs.promoteToWarm(cutoff);
|
||||
// 2. PG: demote hot-tier agent logs for this session only.
|
||||
const cutoff = new Date();
|
||||
const logsDemoted = await this.logService.logs.promoteSessionToWarm(sessionId, cutoff);
|
||||
if (logsDemoted > 0) {
|
||||
result.cleaned.logsDemoted = logsDemoted;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
12
apps/gateway/src/health/health.controller.test.ts
Normal file
12
apps/gateway/src/health/health.controller.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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,4 +6,10 @@ export class HealthController {
|
||||
check(): { status: string } {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
/** Readiness intentionally exposes no configuration, provider, or credential details. */
|
||||
@Get('ready')
|
||||
ready(): { status: string } {
|
||||
return { status: 'ready' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@ import {
|
||||
type OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { SummarizationService } from './summarization.service.js';
|
||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||
import {
|
||||
QueueService,
|
||||
QUEUE_SUMMARIZATION,
|
||||
QUEUE_GC,
|
||||
QUEUE_SUMMARIZATION,
|
||||
QUEUE_TIER_MANAGEMENT,
|
||||
} from '../queue/queue.service.js';
|
||||
import type { Worker } from 'bullmq';
|
||||
@@ -23,14 +22,12 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
constructor(
|
||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||
@Inject(QueueService) private readonly queueService: QueueService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||
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
|
||||
await this.queueService.addRepeatableJob(
|
||||
@@ -56,15 +53,12 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
this.registeredWorkers.push(tierWorker);
|
||||
|
||||
// M6-004: GC repeatable job
|
||||
await this.queueService.addRepeatableJob(QUEUE_GC, 'session-gc', {}, gcSchedule);
|
||||
const gcWorker = this.queueService.registerWorker(QUEUE_GC, async () => {
|
||||
await this.sessionGC.sweepOrphans();
|
||||
});
|
||||
this.registeredWorkers.push(gcWorker);
|
||||
// Retire any repeatable global GC schedule created by older deployments.
|
||||
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
||||
await this.queueService.removeRepeatableJobs(QUEUE_GC, 'session-gc');
|
||||
|
||||
this.logger.log(
|
||||
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`,
|
||||
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}"`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import { Logger } from '@nestjs/common';
|
||||
import { fromNodeHeaders } from 'better-auth/node';
|
||||
import type { Auth } from '@mosaicstack/auth';
|
||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import type { McpService } from './mcp.service.js';
|
||||
import {
|
||||
createMcpActorContext,
|
||||
deriveMcpToolScopesForUser,
|
||||
type McpService,
|
||||
} from './mcp.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
|
||||
/**
|
||||
@@ -67,14 +71,25 @@ async function handleMcpRequest(
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = result.user.id;
|
||||
const authUser = result.user as {
|
||||
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 ─────────────────────────────────────────────────────
|
||||
const sessionId = req.raw.headers['mcp-session-id'];
|
||||
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
||||
// Existing session request
|
||||
const transport = mcpService.getSession(sessionId);
|
||||
const transport = mcpService.getSession(sessionId, actor);
|
||||
if (!transport) {
|
||||
logger.warn(`MCP session not found: ${sessionId}`);
|
||||
reply.raw.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
@@ -112,8 +127,10 @@ async function handleMcpRequest(
|
||||
}
|
||||
|
||||
// Create new session and handle this initializing request
|
||||
const { transport } = mcpService.createSession(userId);
|
||||
logger.log(`New MCP session created for user ${userId}`);
|
||||
const { transport } = mcpService.createSession(actor);
|
||||
logger.log(
|
||||
`New MCP session created for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
|
||||
);
|
||||
|
||||
await transport.handleRequest(req.raw, reply.raw, body);
|
||||
}
|
||||
|
||||
461
apps/gateway/src/mcp/mcp.service.spec.ts
Normal file
461
apps/gateway/src/mcp/mcp.service.spec.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
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,11 +10,216 @@ import { MEMORY } from '../memory/memory.tokens.js';
|
||||
import { EmbeddingService } from '../memory/embedding.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 {
|
||||
server: McpServer;
|
||||
transport: StreamableHTTPServerTransport;
|
||||
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;
|
||||
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()
|
||||
@@ -33,13 +238,18 @@ export class McpService implements OnModuleDestroy {
|
||||
* Creates a new MCP session with its own server + transport pair.
|
||||
* Returns the transport for use by the controller.
|
||||
*/
|
||||
createSession(userId: string): { sessionId: string; transport: StreamableHTTPServerTransport } {
|
||||
createSession(actor: McpActorContext): {
|
||||
sessionId: string;
|
||||
transport: StreamableHTTPServerTransport;
|
||||
} {
|
||||
const sessionId = randomUUID();
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => sessionId,
|
||||
onsessioninitialized: (id) => {
|
||||
this.logger.log(`MCP session initialized: ${id} for user ${userId}`);
|
||||
this.logger.log(
|
||||
`MCP session initialized: ${id} for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,7 +258,7 @@ export class McpService implements OnModuleDestroy {
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
this.registerTools(server, userId);
|
||||
this.registerTools(server, actor);
|
||||
|
||||
transport.onclose = () => {
|
||||
this.logger.log(`MCP session closed: ${sessionId}`);
|
||||
@@ -61,31 +271,126 @@ export class McpService implements OnModuleDestroy {
|
||||
);
|
||||
});
|
||||
|
||||
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), userId });
|
||||
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), actor });
|
||||
return { sessionId, transport };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the transport for an existing session, or null if not found.
|
||||
* Returns the transport for an existing session only when it belongs to the
|
||||
* currently authenticated MCP actor. Guessed or cross-tenant session IDs grant
|
||||
* no authority.
|
||||
*/
|
||||
getSession(sessionId: string): StreamableHTTPServerTransport | null {
|
||||
return this.sessions.get(sessionId)?.transport ?? null;
|
||||
getSession(sessionId: string, actor: McpActorContext): StreamableHTTPServerTransport | null {
|
||||
const entry = this.sessions.get(sessionId);
|
||||
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.
|
||||
*/
|
||||
private registerTools(server: McpServer, _userId: string): void {
|
||||
registerTools(server: McpServer, actor: McpActorContext): void {
|
||||
// ─── Brain: Project tools ────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
'brain_list_projects',
|
||||
{
|
||||
description: 'List all projects in the brain.',
|
||||
inputSchema: z.object({}),
|
||||
inputSchema: strictObject({}),
|
||||
},
|
||||
async () => {
|
||||
const projects = await this.brain.projects.findAll();
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_list_projects', params);
|
||||
const projects = filterProjectsForActor(
|
||||
actor,
|
||||
(await this.brain.projects.findAll()) as ProjectLike[],
|
||||
);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
|
||||
};
|
||||
@@ -96,17 +401,21 @@ export class McpService implements OnModuleDestroy {
|
||||
'brain_get_project',
|
||||
{
|
||||
description: 'Get a project by ID.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: strictObject({
|
||||
id: z.string().describe('Project ID (UUID)'),
|
||||
}),
|
||||
},
|
||||
async ({ id }) => {
|
||||
const project = await this.brain.projects.findById(id);
|
||||
async ({ id, ...params }) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_get_project', params);
|
||||
const project = (await this.brain.projects.findById(id)) as ProjectLike | undefined;
|
||||
const authorizedProject = project ? filterProjectsForActor(actor, [project])[0] : undefined;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`,
|
||||
text: authorizedProject
|
||||
? JSON.stringify(authorizedProject, null, 2)
|
||||
: `Project not found: ${id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -119,20 +428,23 @@ export class McpService implements OnModuleDestroy {
|
||||
'brain_list_tasks',
|
||||
{
|
||||
description: 'List tasks, optionally filtered by project, mission, or status.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: strictObject({
|
||||
projectId: z.string().optional().describe('Filter by project ID'),
|
||||
missionId: z.string().optional().describe('Filter by mission ID'),
|
||||
status: z.string().optional().describe('Filter by status'),
|
||||
}),
|
||||
},
|
||||
async ({ projectId, missionId, status }) => {
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_list_tasks', params);
|
||||
const { projectId, missionId, status } = params;
|
||||
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
let tasks;
|
||||
if (projectId) tasks = await this.brain.tasks.findByProject(projectId);
|
||||
else if (missionId) tasks = await this.brain.tasks.findByMission(missionId);
|
||||
else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus);
|
||||
else tasks = await this.brain.tasks.findAll();
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
||||
const scopedTasks = await this.filterTasksForActor(actor, tasks as TaskLike[]);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(scopedTasks, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -140,7 +452,7 @@ export class McpService implements OnModuleDestroy {
|
||||
'brain_create_task',
|
||||
{
|
||||
description: 'Create a new task in the brain.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: strictObject({
|
||||
title: z.string().describe('Task title'),
|
||||
description: z.string().optional().describe('Task description'),
|
||||
projectId: z.string().optional().describe('Project ID'),
|
||||
@@ -149,6 +461,8 @@ export class McpService implements OnModuleDestroy {
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_create_task', params);
|
||||
await this.assertTaskCreateScopeAuthorized(actor, params);
|
||||
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||
const task = await this.brain.tasks.create({
|
||||
...params,
|
||||
@@ -162,7 +476,7 @@ export class McpService implements OnModuleDestroy {
|
||||
'brain_update_task',
|
||||
{
|
||||
description: 'Update an existing task.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: strictObject({
|
||||
id: z.string().describe('Task ID'),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
@@ -171,9 +485,17 @@ export class McpService implements OnModuleDestroy {
|
||||
.optional()
|
||||
.describe('not-started, in-progress, blocked, done, cancelled'),
|
||||
priority: z.string().optional(),
|
||||
projectId: z.string().optional().describe('Project ID'),
|
||||
missionId: z.string().optional().describe('Mission ID'),
|
||||
}),
|
||||
},
|
||||
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 Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||
const task = await this.brain.tasks.update(id, {
|
||||
@@ -198,14 +520,19 @@ export class McpService implements OnModuleDestroy {
|
||||
'brain_list_missions',
|
||||
{
|
||||
description: 'List all missions, optionally filtered by project.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: strictObject({
|
||||
projectId: z.string().optional().describe('Filter by project ID'),
|
||||
}),
|
||||
},
|
||||
async ({ projectId }) => {
|
||||
const missions = projectId
|
||||
? await this.brain.missions.findByProject(projectId)
|
||||
: await this.brain.missions.findAll();
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_list_missions', params);
|
||||
const { projectId } = params;
|
||||
const missions = await this.filterMissionsForActor(
|
||||
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) }] };
|
||||
},
|
||||
);
|
||||
@@ -213,13 +540,12 @@ export class McpService implements OnModuleDestroy {
|
||||
server.registerTool(
|
||||
'brain_list_conversations',
|
||||
{
|
||||
description: 'List conversations for a user.',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID'),
|
||||
}),
|
||||
description: 'List conversations for the authenticated MCP actor.',
|
||||
inputSchema: strictObject({}),
|
||||
},
|
||||
async ({ userId }) => {
|
||||
const conversations = await this.brain.conversations.findAll(userId);
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_list_conversations', params);
|
||||
const conversations = await this.brain.conversations.findAll(actor.userId);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
|
||||
};
|
||||
@@ -232,14 +558,15 @@ export class McpService implements OnModuleDestroy {
|
||||
'memory_search',
|
||||
{
|
||||
description:
|
||||
'Search across stored insights and knowledge using natural language. Returns semantically similar results.',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID to search memory for'),
|
||||
'Search stored insights and knowledge for the authenticated MCP actor using natural language.',
|
||||
inputSchema: strictObject({
|
||||
query: z.string().describe('Natural language search query'),
|
||||
limit: z.number().optional().describe('Max results (default 5)'),
|
||||
}),
|
||||
},
|
||||
async ({ userId, query, limit }) => {
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'memory_search', params);
|
||||
const { query, limit } = params;
|
||||
if (!this.embeddings.available) {
|
||||
return {
|
||||
content: [
|
||||
@@ -251,7 +578,11 @@ export class McpService implements OnModuleDestroy {
|
||||
};
|
||||
}
|
||||
const embedding = await this.embeddings.embed(query);
|
||||
const results = await this.memory.insights.searchByEmbedding(userId, embedding, limit ?? 5);
|
||||
const results = await this.memory.insights.searchByEmbedding(
|
||||
actor.userId,
|
||||
embedding,
|
||||
limit ?? 5,
|
||||
);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] };
|
||||
},
|
||||
);
|
||||
@@ -259,20 +590,21 @@ export class McpService implements OnModuleDestroy {
|
||||
server.registerTool(
|
||||
'memory_get_preferences',
|
||||
{
|
||||
description: 'Retrieve stored preferences for a user.',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID'),
|
||||
description: 'Retrieve stored preferences for the authenticated MCP actor.',
|
||||
inputSchema: strictObject({
|
||||
category: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Filter by category: communication, coding, workflow, appearance, general'),
|
||||
}),
|
||||
},
|
||||
async ({ userId, category }) => {
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'memory_get_preferences', params);
|
||||
const { category } = params;
|
||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||
const prefs = category
|
||||
? await this.memory.preferences.findByUserAndCategory(userId, category as Cat)
|
||||
: await this.memory.preferences.findByUser(userId);
|
||||
? await this.memory.preferences.findByUserAndCategory(actor.userId, category as Cat)
|
||||
: await this.memory.preferences.findByUser(actor.userId);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] };
|
||||
},
|
||||
);
|
||||
@@ -281,9 +613,8 @@ export class McpService implements OnModuleDestroy {
|
||||
'memory_save_preference',
|
||||
{
|
||||
description:
|
||||
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID'),
|
||||
'Store a learned preference for the authenticated MCP actor (e.g., "prefers tables over paragraphs").',
|
||||
inputSchema: strictObject({
|
||||
key: z.string().describe('Preference key'),
|
||||
value: z.string().describe('Preference value (JSON string)'),
|
||||
category: z
|
||||
@@ -292,7 +623,9 @@ export class McpService implements OnModuleDestroy {
|
||||
.describe('Category: communication, coding, workflow, appearance, general'),
|
||||
}),
|
||||
},
|
||||
async ({ userId, key, value, category }) => {
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'memory_save_preference', params);
|
||||
const { key, value, category } = params;
|
||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
@@ -301,7 +634,7 @@ export class McpService implements OnModuleDestroy {
|
||||
parsedValue = value;
|
||||
}
|
||||
const pref = await this.memory.preferences.upsert({
|
||||
userId,
|
||||
userId: actor.userId,
|
||||
key,
|
||||
value: parsedValue,
|
||||
category: (category as Cat) ?? 'general',
|
||||
@@ -315,9 +648,8 @@ export class McpService implements OnModuleDestroy {
|
||||
'memory_save_insight',
|
||||
{
|
||||
description:
|
||||
'Store a learned insight, decision, or knowledge extracted from the current interaction.',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID'),
|
||||
'Store a learned insight, decision, or knowledge for the authenticated MCP actor.',
|
||||
inputSchema: strictObject({
|
||||
content: z.string().describe('The insight or knowledge to store'),
|
||||
category: z
|
||||
.string()
|
||||
@@ -325,11 +657,13 @@ export class McpService implements OnModuleDestroy {
|
||||
.describe('Category: decision, learning, preference, fact, pattern, general'),
|
||||
}),
|
||||
},
|
||||
async ({ userId, content, category }) => {
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'memory_save_insight', params);
|
||||
const { content, category } = params;
|
||||
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
||||
const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null;
|
||||
const insight = await this.memory.insights.create({
|
||||
userId,
|
||||
userId: actor.userId,
|
||||
content,
|
||||
embedding,
|
||||
source: 'agent',
|
||||
@@ -346,16 +680,11 @@ export class McpService implements OnModuleDestroy {
|
||||
{
|
||||
description:
|
||||
'Get the current orchestration mission status including milestones, tasks, and active session.',
|
||||
inputSchema: z.object({
|
||||
projectPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Project path. Defaults to gateway working directory.'),
|
||||
}),
|
||||
inputSchema: strictObject({}),
|
||||
},
|
||||
async ({ projectPath }) => {
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const status = await this.coordService.getMissionStatus(resolvedPath);
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'coord_mission_status', params);
|
||||
const status = await this.coordService.getMissionStatus(process.cwd());
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -371,16 +700,11 @@ export class McpService implements OnModuleDestroy {
|
||||
'coord_list_tasks',
|
||||
{
|
||||
description: 'List all tasks from the orchestration TASKS.md file.',
|
||||
inputSchema: z.object({
|
||||
projectPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Project path. Defaults to gateway working directory.'),
|
||||
}),
|
||||
inputSchema: strictObject({}),
|
||||
},
|
||||
async ({ projectPath }) => {
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const tasks = await this.coordService.listTasks(resolvedPath);
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'coord_list_tasks', params);
|
||||
const tasks = await this.coordService.listTasks(process.cwd());
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
||||
},
|
||||
);
|
||||
@@ -389,17 +713,14 @@ export class McpService implements OnModuleDestroy {
|
||||
'coord_task_detail',
|
||||
{
|
||||
description: 'Get detailed status for a specific orchestration task.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: strictObject({
|
||||
taskId: z.string().describe('Task ID (e.g. P2-005)'),
|
||||
projectPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Project path. Defaults to gateway working directory.'),
|
||||
}),
|
||||
},
|
||||
async ({ taskId, projectPath }) => {
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const detail = await this.coordService.getTaskStatus(resolvedPath, taskId);
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'coord_task_detail', params);
|
||||
const { taskId } = params;
|
||||
const detail = await this.coordService.getTaskStatus(process.cwd(), taskId);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
createMemory,
|
||||
type Memory,
|
||||
createMemoryAdapter,
|
||||
createOperatorMemoryPlugin,
|
||||
type MemoryAdapter,
|
||||
type MemoryConfig,
|
||||
type OperatorMemoryPlugin,
|
||||
} from '@mosaicstack/memory';
|
||||
import type { Db } from '@mosaicstack/db';
|
||||
import type { StorageAdapter } from '@mosaicstack/storage';
|
||||
@@ -14,6 +16,9 @@ import { DB, STORAGE_ADAPTER } from '../database/database.module.js';
|
||||
import { MEMORY } from './memory.tokens.js';
|
||||
import { MemoryController } from './memory.controller.js';
|
||||
import { EmbeddingService } from './embedding.service.js';
|
||||
import { redactSensitiveContent } from '@mosaicstack/log';
|
||||
|
||||
export const OPERATOR_MEMORY_PLUGIN = 'OPERATOR_MEMORY_PLUGIN';
|
||||
|
||||
export const MEMORY_ADAPTER = 'MEMORY_ADAPTER';
|
||||
|
||||
@@ -38,9 +43,24 @@ function buildMemoryConfig(config: MosaicConfig, storageAdapter: StorageAdapter)
|
||||
createMemoryAdapter(buildMemoryConfig(config, storageAdapter)),
|
||||
inject: [MOSAIC_CONFIG, STORAGE_ADAPTER],
|
||||
},
|
||||
{
|
||||
provide: OPERATOR_MEMORY_PLUGIN,
|
||||
useFactory: (adapter: MemoryAdapter): OperatorMemoryPlugin | null => {
|
||||
const instanceId = process.env['MOSAIC_OPERATOR_MEMORY_INSTANCE_ID']?.trim();
|
||||
const namespace = process.env['MOSAIC_OPERATOR_MEMORY_NAMESPACE']?.trim();
|
||||
if (!instanceId || !namespace) return null;
|
||||
return createOperatorMemoryPlugin({
|
||||
adapter,
|
||||
instanceId,
|
||||
namespace,
|
||||
redact: (content) => redactSensitiveContent(content).content,
|
||||
});
|
||||
},
|
||||
inject: [MEMORY_ADAPTER],
|
||||
},
|
||||
EmbeddingService,
|
||||
],
|
||||
controllers: [MemoryController],
|
||||
exports: [MEMORY, MEMORY_ADAPTER, EmbeddingService],
|
||||
exports: [MEMORY, MEMORY_ADAPTER, OPERATOR_MEMORY_PLUGIN, EmbeddingService],
|
||||
})
|
||||
export class MemoryModule {}
|
||||
|
||||
443
apps/gateway/src/plugin/discord-ingress.security.spec.ts
Normal file
443
apps/gateway/src/plugin/discord-ingress.security.spec.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
40
apps/gateway/src/plugin/discord-replay-protector.ts
Normal file
40
apps/gateway/src/plugin/discord-replay-protector.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 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 OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { DiscordPlugin } from '@mosaicstack/discord-plugin';
|
||||
import { DiscordPlugin, parseDiscordInteractionBindings } from '@mosaicstack/discord-plugin';
|
||||
import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
|
||||
import { PluginService } from './plugin.service.js';
|
||||
import type { IChannelPlugin } from './plugin.interface.js';
|
||||
@@ -50,19 +50,44 @@ class TelegramChannelPluginAdapter implements IChannelPlugin {
|
||||
|
||||
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[] {
|
||||
const plugins: IChannelPlugin[] = [];
|
||||
const discordToken = process.env['DISCORD_BOT_TOKEN'];
|
||||
const discordGuildId = process.env['DISCORD_GUILD_ID'];
|
||||
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 (!discordServiceToken || !discordServiceUserId) {
|
||||
throw new Error(
|
||||
'DISCORD_SERVICE_TOKEN and DISCORD_SERVICE_USER_ID are required when DISCORD_BOT_TOKEN is configured',
|
||||
);
|
||||
}
|
||||
plugins.push(
|
||||
new DiscordChannelPluginAdapter(
|
||||
new DiscordPlugin({
|
||||
token: discordToken,
|
||||
guildId: discordGuildId,
|
||||
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,9 +1,13 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
|
||||
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
|
||||
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>
|
||||
`mosaic:session:${sessionId}:system:fragments`;
|
||||
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
||||
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
||||
const SESSION_SYSTEM_KEY = (sessionId: string, scope: ActorTenantScope) =>
|
||||
`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
|
||||
|
||||
interface OverrideFragment {
|
||||
@@ -20,9 +24,9 @@ export class SystemOverrideService {
|
||||
this.handle = createQueue();
|
||||
}
|
||||
|
||||
async set(sessionId: string, override: string): Promise<void> {
|
||||
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
||||
// Load existing fragments
|
||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId));
|
||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
||||
const fragments: OverrideFragment[] = existing
|
||||
? (JSON.parse(existing) as OverrideFragment[])
|
||||
: [];
|
||||
@@ -37,11 +41,11 @@ export class SystemOverrideService {
|
||||
// Store both: fragments array and condensed result
|
||||
const pipeline = this.handle.redis.pipeline();
|
||||
pipeline.setex(
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||
SYSTEM_OVERRIDE_TTL_SECONDS,
|
||||
JSON.stringify(fragments),
|
||||
);
|
||||
pipeline.setex(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
|
||||
pipeline.setex(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
|
||||
await pipeline.exec();
|
||||
|
||||
this.logger.debug(
|
||||
@@ -49,21 +53,21 @@ export class SystemOverrideService {
|
||||
);
|
||||
}
|
||||
|
||||
async get(sessionId: string): Promise<string | null> {
|
||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
|
||||
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
||||
}
|
||||
|
||||
async renew(sessionId: string): Promise<void> {
|
||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
const pipeline = this.handle.redis.pipeline();
|
||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
async clear(sessionId: string): Promise<void> {
|
||||
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
await this.handle.redis.del(
|
||||
SESSION_SYSTEM_KEY(sessionId),
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
||||
SESSION_SYSTEM_KEY(sessionId, scope),
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||
);
|
||||
this.logger.debug(`Cleared system override for session ${sessionId}`);
|
||||
}
|
||||
|
||||
@@ -162,6 +162,23 @@ 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
|
||||
* exponential backoff.
|
||||
|
||||
@@ -67,10 +67,11 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c
|
||||
|
||||
## Workstreams
|
||||
|
||||
| # | 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 |
|
||||
| W2+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
||||
| # | 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 |
|
||||
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
|
||||
| W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
||||
|
||||
### Likely Additional Workstreams (Not Yet Declared)
|
||||
|
||||
|
||||
96
docs/PRD.md
96
docs/PRD.md
@@ -79,6 +79,102 @@ 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
|
||||
|
||||
### High-Level System Diagram
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
|
||||
## Workstream Rollup
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -86,3 +87,7 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
||||
## #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,6 +232,10 @@ 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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -91,22 +91,22 @@ Goal: Two federated gateways exchange real data over mTLS. Inbound requests pass
|
||||
>
|
||||
> **Tracking issue:** #462.
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------------ | ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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 | 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 | 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 | 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 | 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-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 | 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 | 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-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-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. |
|
||||
| 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-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-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-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-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-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-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-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-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-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-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.
|
||||
|
||||
@@ -118,6 +118,10 @@ 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).
|
||||
|
||||
**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)
|
||||
|
||||
_Deferred. Issue #463._
|
||||
|
||||
114
docs/fleet/FLEET-LAUNCH.md
Normal file
114
docs/fleet/FLEET-LAUNCH.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# 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.
|
||||
79
docs/fleet/NORTH_STAR.md
Normal file
79
docs/fleet/NORTH_STAR.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# 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.
|
||||
215
docs/fleet/NORTH_STAR.yaml
Normal file
215
docs/fleet/NORTH_STAR.yaml
Normal file
@@ -0,0 +1,215 @@
|
||||
# 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.
|
||||
138
docs/fleet/backlog-conventions.md
Normal file
138
docs/fleet/backlog-conventions.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 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
|
||||
```
|
||||
@@ -353,6 +353,25 @@ re-evaluate if isolation or write-volume demands it.
|
||||
- **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
|
||||
|
||||
@@ -293,13 +293,28 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
|
||||
|
||||
### Plugins
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------------- | -------------------------------------------------------------------------- |
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
|
||||
| `DISCORD_GUILD_ID` | Discord guild/server ID |
|
||||
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) |
|
||||
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
|
||||
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
|
||||
| Variable | Description |
|
||||
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata |
|
||||
| `DISCORD_GUILD_ID` | Discord guild/server ID |
|
||||
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) |
|
||||
| `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
|
||||
|
||||
|
||||
60
docs/scratchpads/462-fed-m3-04-scope-service.md
Normal file
60
docs/scratchpads/462-fed-m3-04-scope-service.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 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.
|
||||
33
docs/scratchpads/561-python-is-python3.md
Normal file
33
docs/scratchpads/561-python-is-python3.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 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.
|
||||
54
docs/scratchpads/633-comms-block-runbook.md
Normal file
54
docs/scratchpads/633-comms-block-runbook.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# #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.
|
||||
25
docs/scratchpads/672-fleet-personas-timeout.md
Normal file
25
docs/scratchpads/672-fleet-personas-timeout.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# 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.
|
||||
49
docs/scratchpads/703-wrapper-interactive-auth.md
Normal file
49
docs/scratchpads/703-wrapper-interactive-auth.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# #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
|
||||
43
docs/scratchpads/747-wsa-dehardcode.md
Normal file
43
docs/scratchpads/747-wsa-dehardcode.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# #747 — De-hardcode orchestrator and interaction agent names
|
||||
|
||||
## Objective
|
||||
|
||||
Replace branded Mos/Tess symbols, filenames, DI tokens, and error prose with role-neutral orchestrator/interaction vocabulary without changing env-driven runtime identity behavior. Add optional roster `alias` and `provider` fields and show aliases in `mosaic fleet ps` with name fallback.
|
||||
|
||||
## Scope and constraints
|
||||
|
||||
- Requirements: `/home/hermes/agent-work/reviews/747-wsa-dehardcode-brief.md`.
|
||||
- Branch: `feat/747-dehardcode-orchestrator-interaction-names` from `main` at `e72388b2`.
|
||||
- Keep `MOSAIC_AGENT_NAME` and `MOSAIC_ORCHESTRATOR_AGENT_NAME` unchanged.
|
||||
- Sample/test data may retain operator display names.
|
||||
- No behavior change beyond optional roster metadata and alias display.
|
||||
- Budget: no explicit cap; conservative mechanical-rename scope only.
|
||||
- TDD: optional and skipped because this is a mechanical rename with existing focused coverage; add focused alias/schema regression coverage before completion.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Rename coordination and durable-session files and symbols using canonical vocabulary.
|
||||
2. Scrub branded symbol names and error prose in the assigned source trees while preserving allowed sample data.
|
||||
3. Extend roster schema with optional `alias` and `provider`; update fleet roster typing/rendering and focused tests.
|
||||
4. Run grep-clean verification, build, typecheck, lint/format, focused coord/durable-session/fleet tests, and roster validation.
|
||||
5. Commit, queue-guard, push, open a Gitea PR closing #747, and report to the coordinator.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-07-13: Task resumed from coordinator brief; repository clean at `e72388b2`.
|
||||
- Renamed coordination and durable-session files, exports, gateway DI symbols, DTOs, services, repositories, and tests.
|
||||
- Replaced branded authority/error prose while preserving the existing `/api/coord/mos` compatibility route and env-variable identity inputs.
|
||||
- Added optional roster `alias`/`provider` support and alias-first `fleet ps` display with canonical-name fallback.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm typecheck`: passed (42 tasks).
|
||||
- `pnpm build`: passed (23 tasks).
|
||||
- `pnpm lint`: passed (23 tasks).
|
||||
- `pnpm format:check`: passed.
|
||||
- Coordination tests: 7 passed.
|
||||
- Agent durable-session/runtime tests: 23 passed.
|
||||
- Gateway coordination/durable-session/integration tests: 20 passed.
|
||||
- Full `fleet.spec.ts`: 192 passed, including alias/provider parsing and alias display.
|
||||
- JSON Schema 2020 validation: legacy minimal roster and extended alias/provider roster passed; `alias` and `provider` remain absent from `required`.
|
||||
- Grep verification: no branded symbol/type/file/DI names or error prose remain in assigned source trees; one allowed `Tess Owner` test-data display name remains.
|
||||
52
docs/scratchpads/FED-M3-05-list-verb.md
Normal file
52
docs/scratchpads/FED-M3-05-list-verb.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 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.
|
||||
65
docs/scratchpads/FED-M3-07-capabilities.md
Normal file
65
docs/scratchpads/FED-M3-07-capabilities.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# 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 |
|
||||
67
docs/scratchpads/FED-M3-09-query-source.md
Normal file
67
docs/scratchpads/FED-M3-09-query-source.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 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 |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user