Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b21c84f231 | ||
|
|
33ca4b2a6a | ||
|
|
694f1a4199 | ||
|
|
472dcee7ed | ||
|
|
633acd2d2a | ||
|
|
6a8ce66702 | ||
|
|
9cd9409089 | ||
|
|
13c70a7a10 | ||
|
|
dd6357e670 | ||
|
|
709a23d08c | ||
|
|
239a2a93f1 | ||
|
|
ea1f058022 | ||
|
|
1fde450ff1 | ||
|
|
c136baa052 | ||
|
|
4f7f6b3281 | ||
|
|
77edb0dea2 | ||
|
|
3676180ae8 | ||
|
|
0e938b66ed | ||
|
|
f0fef26eb7 | ||
|
|
c9bccd4aae | ||
|
|
8ef2e5b91d | ||
|
|
4cab6c09fe | ||
|
|
239fc6d03c | ||
|
|
d085182dc1 | ||
|
|
e949fa3767 | ||
|
|
f1761c91be | ||
|
|
8109f72cf7 | ||
|
|
a0be592d84 | ||
|
|
f4a24b693e | ||
|
|
e4dffb7c18 | ||
|
|
f840843908 | ||
|
|
aacb11b0b9 | ||
|
|
ce6bda18f2 | ||
|
|
aca28405be | ||
|
|
c1eb0659c4 | ||
|
|
b79708fdc7 | ||
|
|
ebe415132e | ||
|
|
f16f206a0a | ||
|
|
a186922e3a | ||
|
|
43513c28f7 | ||
|
|
b6c12bdfcb | ||
|
|
fb9f9cda5a | ||
|
|
400a21ca18 | ||
|
|
4cefa5cd88 | ||
|
|
ddf8616716 | ||
|
|
30a694358d | ||
|
|
e01dfa0cd7 | ||
|
|
6c4a2eb626 | ||
|
|
9185b0cce4 | ||
|
|
8925a502ae | ||
|
|
0e4eb1445c | ||
|
|
592d60425f | ||
|
|
a43f343efd | ||
|
|
88ef9d4fa5 | ||
|
|
bda308efd9 | ||
|
|
20718b5a27 | ||
|
|
29db24210c | ||
|
|
a6085eea37 | ||
|
|
e00cc475a2 | ||
|
|
7d84e4ee03 | ||
|
|
4aaf41dd1a | ||
|
|
bf32f29acd | ||
|
|
1655b1579a | ||
|
|
e478a359eb | ||
|
|
76e4242cb1 | ||
|
|
a45f53071a | ||
|
|
00eb216480 | ||
|
|
d46a2d675a | ||
|
|
8c27024d0e | ||
|
|
48bb19310d | ||
|
|
406e40584d | ||
|
|
677aeb0c93 | ||
|
|
caebf9ef70 | ||
|
|
bd0ef2ab25 | ||
|
|
2d5a8c81ec | ||
|
|
884d527cc8 | ||
|
|
b2e005f2b4 | ||
|
|
87daa12976 | ||
|
|
b82a51da80 | ||
|
|
90cf286a09 | ||
|
|
0aef432052 | ||
|
|
41a16cc916 | ||
|
|
e16c08aa9f | ||
|
|
a34e92cf39 | ||
|
|
a4861c221f | ||
|
|
46d68e1ff4 | ||
|
|
c3496334a5 | ||
|
|
6f29d00149 | ||
|
|
068d0f9b1c | ||
|
|
13cd673d50 | ||
|
|
b4753a75cd | ||
|
|
dc67590a96 | ||
|
|
baf4306f51 | ||
|
|
12677a928d |
@@ -109,6 +109,16 @@ steps:
|
|||||||
# `apk add` guarantees openssl is present on PR pipelines too (and is a
|
# `apk add` guarantees openssl is present on PR pipelines too (and is a
|
||||||
# fast no-op once the rebuilt image already ships it).
|
# fast no-op once the rebuilt image already ships it).
|
||||||
- apk add --no-cache openssl
|
- apk add --no-cache openssl
|
||||||
|
# Pi runtime (Invariant R): invariant_r_unittest.py hard-requires an
|
||||||
|
# installed `pi` binary at exactly this measured version — the test
|
||||||
|
# boots Pi's real tool registry to prove the read-only carve-out
|
||||||
|
# resolves to real, unshadowed builtins, and fails loud (by design)
|
||||||
|
# when the runtime is absent or drifts. The canonical Pi is
|
||||||
|
# @earendil-works/[email protected] exactly (@mariozechner/* is
|
||||||
|
# embedded-legacy). Step-level install because ci-base image publishes
|
||||||
|
# are currently blocked on registry auth; fold into Dockerfile.ci once
|
||||||
|
# that is fixed, keeping this as a fast no-op guard.
|
||||||
|
- npm install -g @earendil-works/[email protected]
|
||||||
# postgresql-client (pg_isready) is baked into ci-base.
|
# postgresql-client (pg_isready) is baked into ci-base.
|
||||||
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
||||||
- |
|
- |
|
||||||
|
|||||||
@@ -11,48 +11,87 @@
|
|||||||
|
|
||||||
## Project Context
|
## Project Context
|
||||||
|
|
||||||
Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo with NestJS gateway, Next.js web dashboard, Pi SDK agent runtime, and plugin architecture for Discord/Telegram.
|
Mosaic Stack is a self-hosted, multi-user AI agent platform. It is a TypeScript monorepo with a NestJS gateway, Next.js dashboard, Pi SDK agent runtime, and Discord/Telegram plugin architecture.
|
||||||
|
|
||||||
## Package Map
|
### Stack
|
||||||
|
|
||||||
| Package | Purpose | Key Dependencies |
|
- **API:** NestJS with Fastify (`apps/gateway`)
|
||||||
| ------------------ | ------------------------------- | -------------------------------- |
|
- **Web:** Next.js 16 with React 19 (`apps/web`)
|
||||||
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
- **ORM and database:** Drizzle ORM, PostgreSQL 17, and pgvector (`packages/db`)
|
||||||
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
- **Authentication:** BetterAuth (`packages/auth`)
|
||||||
| `packages/types` | Shared TypeScript contracts | class-validator |
|
- **Agent runtime:** Pi SDK (`apps/gateway`, `packages/mosaic`)
|
||||||
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
|
- **Queue:** Valkey 8 (`packages/queue`)
|
||||||
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
- **Build:** pnpm workspaces and Turborepo
|
||||||
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
|
- **CI:** Woodpecker CI
|
||||||
| `packages/queue` | Valkey task queue + MCP | ioredis |
|
- **Observability:** OpenTelemetry and Jaeger
|
||||||
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
|
||||||
| `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander |
|
|
||||||
| `plugins/discord` | Discord channel plugin | discord.js |
|
|
||||||
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
|
||||||
|
|
||||||
## Architecture Rules
|
### Package Map
|
||||||
|
|
||||||
1. Gateway is the single API surface — all clients connect through it
|
| Package | Purpose | Key Dependencies |
|
||||||
2. Pi SDK is ESM-only — gateway and CLI must use ESM
|
| ------------------ | ----------------------------- | -------------------------------- |
|
||||||
3. Socket.IO typed events defined in `@mosaicstack/types` enforce compile-time contracts
|
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
||||||
4. OTEL auto-instrumentation loads before NestJS bootstrap
|
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
||||||
5. BetterAuth manages auth tables; schema defined in `@mosaicstack/db`
|
| `packages/types` | Shared TypeScript contracts | class-validator |
|
||||||
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
|
| `packages/db` | Drizzle schema and migrations | drizzle-orm, postgres |
|
||||||
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
|
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
||||||
|
| `packages/brain` | Structured data layer | @mosaicstack/db |
|
||||||
|
| `packages/queue` | Valkey task queue and MCP | ioredis |
|
||||||
|
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
||||||
|
| `packages/mosaic` | Unified `mosaic` CLI and TUI | Ink, Pi SDK, commander |
|
||||||
|
| `plugins/discord` | Discord channel plugin | discord.js |
|
||||||
|
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
||||||
|
|
||||||
|
## Architecture and Code Conventions
|
||||||
|
|
||||||
|
1. Gateway is the single API surface; all clients connect through it.
|
||||||
|
2. Pi SDK is ESM-only; gateway and CLI code must remain ESM.
|
||||||
|
3. Use `"type": "module"`, NodeNext module resolution, and `.js` extensions in imports.
|
||||||
|
4. Keep typed Socket.IO events in `@mosaicstack/types` to enforce client/server contracts.
|
||||||
|
5. Import OTEL tracing before NestJS bootstrap (`import './tracing.js'`).
|
||||||
|
6. Use explicit `@Inject()` decorators in NestJS because tsx/esbuild does not emit decorator metadata.
|
||||||
|
7. Keep DTOs in `*.dto.ts` files at module boundaries.
|
||||||
|
8. BetterAuth owns authentication tables; their schema is defined in `@mosaicstack/db`.
|
||||||
|
9. Create a task-specific scratchpad for non-trivial work.
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
|
Requirements: Node.js 20+, pnpm 10.6.2, and Docker Compose when optional local services are needed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d # Infrastructure
|
pnpm install --frozen-lockfile
|
||||||
pnpm install # Dependencies
|
pnpm preflight
|
||||||
pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
|
||||||
|
# Optional local queue service only; do not start the full Compose stack.
|
||||||
|
docker compose up -d valkey
|
||||||
```
|
```
|
||||||
|
|
||||||
## Repo-Specific Notes
|
The pre-push hook requires:
|
||||||
|
|
||||||
- DTOs in `*.dto.ts` files at module boundaries
|
```bash
|
||||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||||
- NodeNext module resolution in all tsconfigs
|
```
|
||||||
- Scratchpads are mandatory for non-trivial tasks
|
|
||||||
|
Software delivery also requires the applicable tests. Common repository commands are:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm typecheck # TypeScript checks across the workspace
|
||||||
|
pnpm lint # ESLint across the workspace
|
||||||
|
pnpm test # Checkout tests and package Vitest suites
|
||||||
|
pnpm format:check # Prettier check
|
||||||
|
pnpm build # Build all packages and applications
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database and Local Runtime Safety
|
||||||
|
|
||||||
|
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
|
||||||
|
- PostgreSQL execution is held until KBN-101-00, KBN-101-03, and KBN-101-05 land.
|
||||||
|
- Do not invoke a migration runner, initialization SQL, or the Compose PostgreSQL service from this checkout.
|
||||||
|
- Do not start Gateway/Web or run root `pnpm dev` as a local PGlite route. The current dotenv loader can inherit a daemon PostgreSQL DSN; KBN-101-02 must make that path fail closed first.
|
||||||
|
- Migration artifact generation is offline and does not authorize PostgreSQL access:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mosaicstack/db db:generate
|
||||||
|
```
|
||||||
|
|
||||||
## docs/TASKS.md — Schema (CANONICAL)
|
## docs/TASKS.md — Schema (CANONICAL)
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,5 @@
|
|||||||
# CLAUDE.md — Mosaic Stack
|
# Claude Compatibility Pointer
|
||||||
|
|
||||||
## Project
|
@AGENTS.md
|
||||||
|
|
||||||
Self-hosted, multi-user AI agent platform. TypeScript monorepo.
|
Do not add project guidance here. Keep `AGENTS.md` authoritative so every agent runtime receives the same instructions.
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **API**: NestJS + Fastify adapter (`apps/gateway`)
|
|
||||||
- **Web**: Next.js 16 + React 19 (`apps/web`)
|
|
||||||
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
|
|
||||||
- **Auth**: BetterAuth (`packages/auth`)
|
|
||||||
- **Agent**: Pi SDK (`packages/agent`, `packages/mosaic`)
|
|
||||||
- **Queue**: Valkey 8 (`packages/queue`)
|
|
||||||
- **Build**: pnpm workspaces + Turborepo
|
|
||||||
- **CI**: Woodpecker CI
|
|
||||||
- **Observability**: OpenTelemetry → Jaeger
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm typecheck # TypeScript check (all packages)
|
|
||||||
pnpm lint # ESLint (all packages)
|
|
||||||
pnpm format:check # Prettier check
|
|
||||||
pnpm test # Vitest (all packages)
|
|
||||||
pnpm build # Build all packages
|
|
||||||
|
|
||||||
# Database
|
|
||||||
pnpm --filter @mosaicstack/db db:generate # Offline migration artifact generation only
|
|
||||||
# PostgreSQL execution is held until KBN-101-00/-03/-05 land. Do not invoke a runner,
|
|
||||||
# init SQL, or Compose PostgreSQL service from this checkout.
|
|
||||||
|
|
||||||
# Dev: local PGlite data-layer work needs no PostgreSQL. Optional local queue service only:
|
|
||||||
docker compose up -d valkey
|
|
||||||
# Do not start Gateway/Web or root pnpm dev as a local PGlite route: the current unguarded dotenv
|
|
||||||
# loader can inherit a daemon PostgreSQL DSN. KBN-101-02 must make that state fail closed first.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
|
||||||
- NodeNext module resolution
|
|
||||||
- Explicit `@Inject()` decorators in NestJS (tsx/esbuild doesn't support emitDecoratorMetadata)
|
|
||||||
- DTOs in `*.dto.ts` files at module boundaries
|
|
||||||
- OTEL tracing imported before NestJS bootstrap (`import './tracing.js'`)
|
|
||||||
- All three gates must pass before push: typecheck, lint, format:check
|
|
||||||
|
|||||||
@@ -48,9 +48,13 @@ mosaic wizard # Full guided setup (gateway install → verify)
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Node.js ≥ 20
|
- Node.js ≥ 22
|
||||||
- npm (for global @mosaicstack/mosaic install)
|
- npm (for global @mosaicstack/mosaic install)
|
||||||
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
- One or more runtimes:
|
||||||
|
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||||
|
- [Codex](https://github.com/openai/codex)
|
||||||
|
- [OpenCode](https://opencode.ai)
|
||||||
|
- [Pi](https://pi.dev)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -200,7 +204,7 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js ≥ 20
|
- Node.js ≥ 22
|
||||||
- pnpm 10.6+
|
- pnpm 10.6+
|
||||||
- Docker & Docker Compose
|
- Docker & Docker Compose
|
||||||
|
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ describe('ConversationsController — search endpoint', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
brain = createMockBrain({ searchResults });
|
brain = createMockBrain({ searchResults });
|
||||||
controller = new ConversationsController(brain as never);
|
controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns matching messages for a valid search query', async () => {
|
it('returns matching messages for a valid search query', async () => {
|
||||||
@@ -479,7 +479,7 @@ describe('ConversationsController — search endpoint', () => {
|
|||||||
describe('ConversationsController — message CRUD', () => {
|
describe('ConversationsController — message CRUD', () => {
|
||||||
it('listMessages returns 404 when conversation is not owned by user', async () => {
|
it('listMessages returns 404 when conversation is not owned by user', async () => {
|
||||||
const brain = createMockBrain({ conversation: undefined });
|
const brain = createMockBrain({ conversation: undefined });
|
||||||
const controller = new ConversationsController(brain as never);
|
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||||
|
|
||||||
await expect(controller.listMessages(CONV_ID, { id: USER_ID })).rejects.toBeInstanceOf(
|
await expect(controller.listMessages(CONV_ID, { id: USER_ID })).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -489,7 +489,7 @@ describe('ConversationsController — message CRUD', () => {
|
|||||||
it('listMessages returns the messages for an owned conversation', async () => {
|
it('listMessages returns the messages for an owned conversation', async () => {
|
||||||
const msgs = [makeMessage('user', 'Test message'), makeMessage('assistant', 'Test reply')];
|
const msgs = [makeMessage('user', 'Test message'), makeMessage('assistant', 'Test reply')];
|
||||||
const brain = createMockBrain({ conversation: makeConversation(), messages: msgs });
|
const brain = createMockBrain({ conversation: makeConversation(), messages: msgs });
|
||||||
const controller = new ConversationsController(brain as never);
|
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||||
|
|
||||||
const result = await controller.listMessages(CONV_ID, { id: USER_ID });
|
const result = await controller.listMessages(CONV_ID, { id: USER_ID });
|
||||||
|
|
||||||
@@ -500,7 +500,7 @@ describe('ConversationsController — message CRUD', () => {
|
|||||||
|
|
||||||
it('addMessage returns the persisted message', async () => {
|
it('addMessage returns the persisted message', async () => {
|
||||||
const brain = createMockBrain({ conversation: makeConversation() });
|
const brain = createMockBrain({ conversation: makeConversation() });
|
||||||
const controller = new ConversationsController(brain as never);
|
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||||
|
|
||||||
const result = await controller.addMessage(
|
const result = await controller.addMessage(
|
||||||
CONV_ID,
|
CONV_ID,
|
||||||
|
|||||||
@@ -35,6 +35,25 @@ function payload(content: string, messageId: string, correlationId: string): Dis
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chat runtime router must never be exercised on the Discord approval/stop control paths —
|
||||||
|
* those paths run entirely through the command-authorization, runtime-provider and durable-session
|
||||||
|
* dependencies. Placed in the gateway's chat-runtime-router slot (the former direct `AgentService`
|
||||||
|
* slot) so any accidental chat-runtime dispatch throws loudly instead of silently passing. Because
|
||||||
|
* approval/stop never resolve a chat runtime, this fixture is never triggered and the integration
|
||||||
|
* stays a GREEN cross-surface control.
|
||||||
|
*/
|
||||||
|
function failIfUsedChatRuntimeRouter() {
|
||||||
|
return {
|
||||||
|
onModuleInit: () => {
|
||||||
|
throw new Error('chat runtime router must not initialise on the Discord control path');
|
||||||
|
},
|
||||||
|
get active(): never {
|
||||||
|
throw new Error('chat runtime must not be resolved on the Discord approval/stop path');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function authorization(): CommandAuthorizationService {
|
function authorization(): CommandAuthorizationService {
|
||||||
const entries = new Map<string, string>();
|
const entries = new Map<string, string>();
|
||||||
return new CommandAuthorizationService(
|
return new CommandAuthorizationService(
|
||||||
@@ -113,7 +132,7 @@ describe('interaction Discord/CLI durable-session integration', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const gateway = new ChatGateway(
|
const gateway = new ChatGateway(
|
||||||
{} as never,
|
failIfUsedChatRuntimeRouter() as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ describe('Resource ownership checks', () => {
|
|||||||
// The repo enforces ownership via the WHERE clause; it returns undefined when the
|
// The repo enforces ownership via the WHERE clause; it returns undefined when the
|
||||||
// conversation does not belong to the requesting user.
|
// conversation does not belong to the requesting user.
|
||||||
brain.conversations.findById.mockResolvedValue(undefined);
|
brain.conversations.findById.mockResolvedValue(undefined);
|
||||||
const controller = new ConversationsController(brain as never);
|
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||||
|
|
||||||
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Test, type TestingModule } from '@nestjs/testing';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
|
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
|
||||||
@@ -12,10 +14,25 @@ vi.mock('../routing/routing-engine.service.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { SessionsController } from '../sessions.controller.js';
|
import { SessionsController } from '../sessions.controller.js';
|
||||||
|
import { AgentService } from '../agent.service.js';
|
||||||
import { ChatController } from '../../chat/chat.controller.js';
|
import { ChatController } from '../../chat/chat.controller.js';
|
||||||
import { ChatGateway } from '../../chat/chat.gateway.js';
|
import { ChatGateway } from '../../chat/chat.gateway.js';
|
||||||
import type { AgentSession } from '../agent.service.js';
|
import type { AgentSession } from '../agent.service.js';
|
||||||
import type { SessionInfoDto } from '../session.dto.js';
|
import type { SessionInfoDto } from '../session.dto.js';
|
||||||
|
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
|
||||||
|
import { AuthGuard } from '../../auth/auth.guard.js';
|
||||||
|
import { AUTH } from '../../auth/auth.tokens.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 { RoutingEngineService } from '../routing/routing-engine.service.js';
|
||||||
|
import { ChatRuntimeRouter } from '../../chat/chat-runtime-router.js';
|
||||||
|
import { EmbeddedChatRuntime } from '../../chat/embedded-chat.runtime.js';
|
||||||
|
import { ownConversation } from '../../chat/chat-runtime.js';
|
||||||
|
import type { LegacyRuntimeStream } from '../../chat/chat-runtime.js';
|
||||||
|
import { HarnessChatRuntime } from '../../chat/harness-chat.runtime.js';
|
||||||
|
import { HarnessRegistry } from '../../harness/harness.registry.js';
|
||||||
|
import { HARNESS_CONVERSATION_SERVICE_UNAVAILABLE } from '../../harness/harness.tokens.js';
|
||||||
|
|
||||||
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
|
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
|
||||||
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
|
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
|
||||||
@@ -74,6 +91,12 @@ function makeAgentSession(owner = USER_A): AgentSession {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shape-complete, non-throwing AgentService fake scoped so that USER_B (a foreign owner guessing
|
||||||
|
* USER_A's conversation id) is never granted the session. Because every method exists and no method
|
||||||
|
* throws for a wrong shape, production runs to its real ownership decision — the RED never comes from
|
||||||
|
* a `getSession is not a function` TypeError, only from a router-boundary/scope assertion mismatch.
|
||||||
|
*/
|
||||||
function makeScopedAgentService() {
|
function makeScopedAgentService() {
|
||||||
const foreign = makeAgentSession(USER_A);
|
const foreign = makeAgentSession(USER_A);
|
||||||
return {
|
return {
|
||||||
@@ -87,7 +110,7 @@ function makeScopedAgentService() {
|
|||||||
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
|
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
|
||||||
scope?.userId === USER_B.id ? undefined : foreign,
|
scope?.userId === USER_B.id ? undefined : foreign,
|
||||||
),
|
),
|
||||||
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
|
createSession: vi.fn().mockRejectedValue(new NotFoundException('Session scope mismatch')),
|
||||||
onEvent: vi.fn(() => vi.fn()),
|
onEvent: vi.fn(() => vi.fn()),
|
||||||
addChannel: vi.fn(),
|
addChannel: vi.fn(),
|
||||||
removeChannel: vi.fn(),
|
removeChannel: vi.fn(),
|
||||||
@@ -96,6 +119,201 @@ function makeScopedAgentService() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScopedAgentService = ReturnType<typeof makeScopedAgentService>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A structurally-complete harness conversation service that throws if any method is invoked.
|
||||||
|
* Fronted behind the legacy runtime's harness slot: the legacy path must never reach it.
|
||||||
|
*/
|
||||||
|
const failIfUsedConversationService = {
|
||||||
|
attach: () => {
|
||||||
|
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||||
|
},
|
||||||
|
detach: () => {
|
||||||
|
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||||
|
},
|
||||||
|
send: () => {
|
||||||
|
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribeFrom: async function* () {
|
||||||
|
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||||
|
},
|
||||||
|
} as unknown as HarnessConversationService;
|
||||||
|
|
||||||
|
/** A structurally-complete, non-sentinel conversation service used to satisfy the pi-rpc readiness gate. */
|
||||||
|
const boundConversationService = {
|
||||||
|
attach: () => Promise.reject(new Error('unused')),
|
||||||
|
detach: () => Promise.reject(new Error('unused')),
|
||||||
|
send: () => Promise.reject(new Error('unused')),
|
||||||
|
|
||||||
|
subscribeFrom: async function* () {
|
||||||
|
throw new Error('unused');
|
||||||
|
},
|
||||||
|
} as unknown as HarnessConversationService;
|
||||||
|
|
||||||
|
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
for (const id of adapterIds) {
|
||||||
|
registry.register({
|
||||||
|
id,
|
||||||
|
describe: () => Promise.reject(new Error('unused')),
|
||||||
|
catalog: () => Promise.reject(new Error('unused')),
|
||||||
|
create: () => Promise.reject(new Error('unused')),
|
||||||
|
resume: () => Promise.reject(new Error('unused')),
|
||||||
|
} as HarnessAdapter);
|
||||||
|
}
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the real legacy-mode {@link ChatRuntimeRouter} fronting a real {@link EmbeddedChatRuntime}
|
||||||
|
* that holds the scoped AgentService fake. This is the ONLY path server-derived scope may travel to
|
||||||
|
* reach an AgentService: controller/gateway → ChatRuntimeRouter → EmbeddedChatRuntime → AgentService.
|
||||||
|
* The `embeddedAgentService` handed here is a SEPARATE instance from the directly-injected fake, so a
|
||||||
|
* call landing on it proves the router-delegation redesign is live rather than the old direct path.
|
||||||
|
*/
|
||||||
|
function legacyRouterFronting(agentService: unknown): ChatRuntimeRouter {
|
||||||
|
const embedded = new EmbeddedChatRuntime(agentService as never);
|
||||||
|
const harness = new HarnessChatRuntime(failIfUsedConversationService);
|
||||||
|
const router = new ChatRuntimeRouter(
|
||||||
|
new HarnessRegistry(),
|
||||||
|
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
embedded,
|
||||||
|
harness,
|
||||||
|
'legacy',
|
||||||
|
);
|
||||||
|
router.onModuleInit();
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The AgentService method names the controller/gateway must NEVER drive on the runtime at the
|
||||||
|
* delegation boundary. An AgentService-shaped router shim (a method-for-method mirror) would record
|
||||||
|
* one of these instead of the frozen legacy op, so asserting their ABSENCE from the observed runtime
|
||||||
|
* call set defeats the shim on INVOCATION evidence — never satisfiable by dead source text.
|
||||||
|
*/
|
||||||
|
const FORBIDDEN_AGENT_OPS = [
|
||||||
|
'getSession',
|
||||||
|
'createSession',
|
||||||
|
'onEvent',
|
||||||
|
'addChannel',
|
||||||
|
'prompt',
|
||||||
|
'setThinking',
|
||||||
|
'abort',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a real {@link ChatRuntimeRouter} in a call-recording Proxy. Every property access that yields
|
||||||
|
* an OWN/inherited callable is returned as a thin wrapper that appends the method name to `calls` at
|
||||||
|
* INVOCATION time and forwards to the real method (bound to the real target, so the router's internal
|
||||||
|
* delegation to the embedded runtime runs untouched below this boundary). Non-function and MISSING
|
||||||
|
* properties are returned verbatim via Reflect.get — the observer NEVER fabricates a value, returns a
|
||||||
|
* canned outcome, or delegates a not-yet-implemented named op, so it cannot itself become a shim.
|
||||||
|
*
|
||||||
|
* The result is a RUNTIME call set of exactly the methods the controller/gateway invoke ON the router
|
||||||
|
* at the delegation seam. Only an actual call can enter it; a dead method, comment, or string in the
|
||||||
|
* production source cannot. This replaces the earlier `source.toContain('<frozen op>')` proof — which
|
||||||
|
* a dead declaration could satisfy while production still executed a shim — with invocation evidence.
|
||||||
|
*/
|
||||||
|
function makeRecordingRouter(target: ChatRuntimeRouter, calls: string[]): ChatRuntimeRouter {
|
||||||
|
return new Proxy(target, {
|
||||||
|
get(t, prop) {
|
||||||
|
const value = Reflect.get(t, prop);
|
||||||
|
if (typeof value === 'function' && typeof prop === 'string') {
|
||||||
|
return (...args: unknown[]) => {
|
||||||
|
calls.push(prop);
|
||||||
|
return (value as (...a: unknown[]) => unknown).apply(t, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
}) as ChatRuntimeRouter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real Nest DI dual-provider fixture (mirrors the blessed group-3 pattern in chat-security.test.ts).
|
||||||
|
*
|
||||||
|
* BOTH an `AgentService` provider (the FORBIDDEN direct dependency) and a `ChatRuntimeRouter` provider
|
||||||
|
* (fronting a real EmbeddedChatRuntime over a SEPARATE scoped AgentService) are registered. Production
|
||||||
|
* resolves whichever its constructor declares:
|
||||||
|
* - RED today: the controller/gateway `@Inject(AgentService)` → the direct fake is consulted, the
|
||||||
|
* router (and its embedded fake) is never reached.
|
||||||
|
* - GREEN later: the controller/gateway inject `ChatRuntimeRouter` → the direct fake is never
|
||||||
|
* touched (stays at zero) and scope is observed inside the embedded fake behind the router.
|
||||||
|
* The SAME test body reds today and greens later; a method-for-method AgentService shim on the router
|
||||||
|
* records a FORBIDDEN op (and never the frozen legacy op) in the observed runtime call set, and
|
||||||
|
* restoring the direct injection cannot satisfy the "direct fake at zero" / "embedded fake observed
|
||||||
|
* scope" / "frozen op invoked on the router" anchors. The router is wrapped by {@link
|
||||||
|
* makeRecordingRouter} so those anchors are runtime invocation evidence, not source substrings.
|
||||||
|
*/
|
||||||
|
function buildRestModule(
|
||||||
|
directAgentService: ScopedAgentService,
|
||||||
|
embeddedAgentService: ScopedAgentService,
|
||||||
|
routerCalls: string[],
|
||||||
|
): Promise<TestingModule> {
|
||||||
|
return (
|
||||||
|
Test.createTestingModule({
|
||||||
|
controllers: [ChatController],
|
||||||
|
providers: [
|
||||||
|
{ provide: AgentService, useValue: directAgentService },
|
||||||
|
{
|
||||||
|
provide: ChatRuntimeRouter,
|
||||||
|
useFactory: () =>
|
||||||
|
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
// ChatController's @UseGuards(AuthGuard) is resolved during instance loading; AuthGuard injects
|
||||||
|
// AUTH, an HTTP-only concern never exercised by a direct handler call. Stub it so the graph
|
||||||
|
// resolves and the test reds on BEHAVIOUR, not on a DI collection error.
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGatewayModule(
|
||||||
|
directAgentService: ScopedAgentService,
|
||||||
|
embeddedAgentService: ScopedAgentService,
|
||||||
|
routerCalls: string[],
|
||||||
|
): Promise<TestingModule> {
|
||||||
|
const brain = {
|
||||||
|
conversations: {
|
||||||
|
// The sender OWNS this durable conversation, so the browser-send admission gate lets the turn
|
||||||
|
// reach the router seam. Foreignness is asserted downstream at the in-memory agent session
|
||||||
|
// (getSession({USER_B}) -> undefined), not at durable admission — the admission-rejection
|
||||||
|
// property has its own dedicated coverage.
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: CONVERSATION_ID, userId: USER_B.id }),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
addMessage: vi.fn().mockResolvedValue({ id: 'persisted-turn' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
ChatGateway,
|
||||||
|
{ provide: AgentService, useValue: directAgentService },
|
||||||
|
{ provide: AUTH, useValue: { api: { getSession: vi.fn().mockResolvedValue(null) } } },
|
||||||
|
{ provide: BRAIN, useValue: brain },
|
||||||
|
{ provide: CommandRegistryService, useValue: { getManifest: vi.fn().mockReturnValue([]) } },
|
||||||
|
{ provide: CommandExecutorService, useValue: { execute: vi.fn() } },
|
||||||
|
{
|
||||||
|
provide: RoutingEngineService,
|
||||||
|
useValue: {
|
||||||
|
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ChatRuntimeRouter,
|
||||||
|
useFactory: () =>
|
||||||
|
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
}
|
||||||
|
|
||||||
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
|
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
|
||||||
it('requires explicit owner+tenant scope on protected session operations', () => {
|
it('requires explicit owner+tenant scope on protected session operations', () => {
|
||||||
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
|
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
|
||||||
@@ -152,50 +370,66 @@ describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
|
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding (router-delegated legacy runtime)', () => {
|
||||||
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
|
// TESS test A — REST /api/chat send. The genuine RED is the router-delegation redesign, not a slot
|
||||||
const agentService = makeScopedAgentService();
|
// swap: the forbidden directly-injected AgentService must go UNtouched while the server-derived
|
||||||
const controller = new ChatController(agentService as never);
|
// scope is observed inside the real ChatRuntimeRouter → EmbeddedChatRuntime → AgentService path.
|
||||||
|
it('routes a REST send through completeLegacyRestTurn and never the directly-injected AgentService', async () => {
|
||||||
|
const directAgentService = makeScopedAgentService(); // FORBIDDEN direct dependency
|
||||||
|
const embeddedAgentService = makeScopedAgentService(); // reached ONLY via router → embedded delegation
|
||||||
|
const routerCalls: string[] = []; // runtime call set observed AT the controller → router seam
|
||||||
|
const moduleRef = await buildRestModule(directAgentService, embeddedAgentService, routerCalls);
|
||||||
|
try {
|
||||||
|
const controller = moduleRef.get(ChatController, { strict: false });
|
||||||
|
|
||||||
await expect(
|
// Foreign ownership is denied (never resolves) — a control that holds today AND at GREEN.
|
||||||
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
|
await expect(
|
||||||
).rejects.toMatchObject({ status: 404 });
|
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
|
||||||
|
).rejects.toBeDefined();
|
||||||
|
|
||||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
// Soft anchors so EVERY anchor is evaluated under each mutation, not just the first to fail.
|
||||||
userId: USER_B.id,
|
|
||||||
tenantId: USER_B.tenantId,
|
// RUNTIME anchor A1 — delegation: the controller must INVOKE the frozen legacy op on the router.
|
||||||
});
|
// Only an actual call enters routerCalls; a dead method/comment/string cannot. RED today (the
|
||||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
// controller @Inject(AgentService) and never calls the router). GREEN once it drives the op.
|
||||||
|
expect
|
||||||
|
.soft(routerCalls, 'controller must invoke completeLegacyRestTurn on the router')
|
||||||
|
.toContain('completeLegacyRestTurn');
|
||||||
|
// RUNTIME anchor A2 — nondelegation: the controller must not drive any AgentService-shaped op on
|
||||||
|
// the router. An AgentService-shaped router shim records one of these → RED, defeating the shim
|
||||||
|
// on invocation evidence (not source text). A dead named method added alongside the shim does not
|
||||||
|
// help: it is never invoked, so it never enters routerCalls while a forbidden op still does.
|
||||||
|
for (const op of FORBIDDEN_AGENT_OPS) {
|
||||||
|
expect
|
||||||
|
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
|
||||||
|
.not.toContain(op);
|
||||||
|
}
|
||||||
|
// RUNTIME anchor A3 — the forbidden directly-injected AgentService stays at zero (fails today;
|
||||||
|
// restoring the direct injection keeps it failing).
|
||||||
|
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
|
||||||
|
// RUNTIME anchor A4 — server-derived scope observed INSIDE the separate embedded fake behind the
|
||||||
|
// router (fails today; the router path is never taken).
|
||||||
|
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||||
|
userId: USER_B.id,
|
||||||
|
tenantId: USER_B.tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zero foreign mutation on either path (holds today and at GREEN).
|
||||||
|
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
|
||||||
|
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Defense-in-depth (NOT load-bearing; the runtime anchors above carry the anti-mask): the
|
||||||
|
// controller no longer declares the direct embedded AgentService dependency. A negative source
|
||||||
|
// check cannot be satisfied by dead text — it only fails when the injection is present.
|
||||||
|
const controllerSource = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
||||||
|
expect.soft(controllerSource).not.toContain('@Inject(AgentService)');
|
||||||
|
} finally {
|
||||||
|
await moduleRef.close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
|
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding (router-delegated legacy runtime)', () => {
|
||||||
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() {
|
function makeSocket() {
|
||||||
return {
|
return {
|
||||||
id: 'socket-b',
|
id: 'socket-b',
|
||||||
@@ -206,57 +440,519 @@ describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () =>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
|
// TESS test B — WebSocket send/attach.
|
||||||
const { gateway, agentService } = makeGateway();
|
it('routes a WebSocket send through prepareLegacySocketTurn and never the directly-injected AgentService', async () => {
|
||||||
const socket = makeSocket();
|
const directAgentService = makeScopedAgentService();
|
||||||
|
const embeddedAgentService = makeScopedAgentService();
|
||||||
|
const routerCalls: string[] = [];
|
||||||
|
const moduleRef = await buildGatewayModule(
|
||||||
|
directAgentService,
|
||||||
|
embeddedAgentService,
|
||||||
|
routerCalls,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const gateway = moduleRef.get(ChatGateway, { strict: false });
|
||||||
|
const socket = makeSocket();
|
||||||
|
|
||||||
await gateway.handleMessage(socket as never, {
|
await Promise.resolve(
|
||||||
conversationId: CONVERSATION_ID,
|
gateway.handleMessage(socket as never, {
|
||||||
content: 'attach to foreign session',
|
conversationId: CONVERSATION_ID,
|
||||||
});
|
content: 'attach to foreign session',
|
||||||
|
}),
|
||||||
|
).catch(() => undefined);
|
||||||
|
|
||||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
// RUNTIME anchor B1 — delegation: the gateway must invoke the frozen socket op on the router.
|
||||||
userId: USER_B.id,
|
expect
|
||||||
tenantId: USER_B.tenantId,
|
.soft(routerCalls, 'gateway must invoke prepareLegacySocketTurn on the router')
|
||||||
});
|
.toContain('prepareLegacySocketTurn');
|
||||||
expect(agentService.onEvent).not.toHaveBeenCalled();
|
// RUNTIME anchor B2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
|
||||||
expect(agentService.addChannel).not.toHaveBeenCalled();
|
for (const op of FORBIDDEN_AGENT_OPS) {
|
||||||
|
expect
|
||||||
|
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
|
||||||
|
.not.toContain(op);
|
||||||
|
}
|
||||||
|
// RED anchor B3 — forbidden direct AgentService untouched (fails today, gateway injects it).
|
||||||
|
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
|
||||||
|
// RED anchor B4 — scope observed inside router → embedded delegation (fails today, never reached).
|
||||||
|
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||||
|
userId: USER_B.id,
|
||||||
|
tenantId: USER_B.tenantId,
|
||||||
|
});
|
||||||
|
// Foreign session gets zero lease/listener/channel/prompt on EITHER path (holds today and GREEN).
|
||||||
|
expect.soft(directAgentService.onEvent).not.toHaveBeenCalled();
|
||||||
|
expect.soft(directAgentService.addChannel).not.toHaveBeenCalled();
|
||||||
|
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
|
||||||
|
expect.soft(embeddedAgentService.onEvent).not.toHaveBeenCalled();
|
||||||
|
expect.soft(embeddedAgentService.addChannel).not.toHaveBeenCalled();
|
||||||
|
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
|
||||||
|
expect
|
||||||
|
.soft(socket.emit)
|
||||||
|
.toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Defense-in-depth (NOT load-bearing): gateway no longer declares the direct dependency.
|
||||||
|
const gatewaySource = readFileSync(resolve('src/chat/chat.gateway.ts'), 'utf8');
|
||||||
|
expect.soft(gatewaySource).not.toContain('@Inject(AgentService)');
|
||||||
|
} finally {
|
||||||
|
await moduleRef.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// TESS test C — WebSocket set:thinking.
|
||||||
|
it('routes set:thinking through setLegacyThinking and never the directly-injected AgentService', async () => {
|
||||||
|
const directAgentService = makeScopedAgentService();
|
||||||
|
const embeddedAgentService = makeScopedAgentService();
|
||||||
|
const routerCalls: string[] = [];
|
||||||
|
const moduleRef = await buildGatewayModule(
|
||||||
|
directAgentService,
|
||||||
|
embeddedAgentService,
|
||||||
|
routerCalls,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const gateway = moduleRef.get(ChatGateway, { strict: false });
|
||||||
|
const socket = makeSocket();
|
||||||
|
|
||||||
|
await Promise.resolve(
|
||||||
|
gateway.handleSetThinking(socket as never, {
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
level: 'high',
|
||||||
|
}),
|
||||||
|
).catch(() => undefined);
|
||||||
|
|
||||||
|
// RUNTIME anchor C1 — delegation: the gateway must invoke the frozen thinking op on the router.
|
||||||
|
expect
|
||||||
|
.soft(routerCalls, 'gateway must invoke setLegacyThinking on the router')
|
||||||
|
.toContain('setLegacyThinking');
|
||||||
|
// RUNTIME anchor C2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
|
||||||
|
for (const op of FORBIDDEN_AGENT_OPS) {
|
||||||
|
expect
|
||||||
|
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
|
||||||
|
.not.toContain(op);
|
||||||
|
}
|
||||||
|
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
|
||||||
|
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||||
|
userId: USER_B.id,
|
||||||
|
tenantId: USER_B.tenantId,
|
||||||
|
});
|
||||||
|
expect
|
||||||
|
.soft(socket.emit)
|
||||||
|
.toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await moduleRef.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// TESS test D — WebSocket abort.
|
||||||
|
it('routes abort through abortLegacyTurn and never the directly-injected AgentService', async () => {
|
||||||
|
const directAgentService = makeScopedAgentService();
|
||||||
|
const embeddedAgentService = makeScopedAgentService();
|
||||||
|
const routerCalls: string[] = [];
|
||||||
|
const moduleRef = await buildGatewayModule(
|
||||||
|
directAgentService,
|
||||||
|
embeddedAgentService,
|
||||||
|
routerCalls,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const gateway = moduleRef.get(ChatGateway, { strict: false });
|
||||||
|
const socket = makeSocket();
|
||||||
|
|
||||||
|
await Promise.resolve(
|
||||||
|
gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID }),
|
||||||
|
).catch(() => undefined);
|
||||||
|
|
||||||
|
// RUNTIME anchor D1 — delegation: the gateway must invoke the frozen abort op on the router.
|
||||||
|
expect
|
||||||
|
.soft(routerCalls, 'gateway must invoke abortLegacyTurn on the router')
|
||||||
|
.toContain('abortLegacyTurn');
|
||||||
|
// RUNTIME anchor D2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
|
||||||
|
for (const op of FORBIDDEN_AGENT_OPS) {
|
||||||
|
expect
|
||||||
|
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
|
||||||
|
.not.toContain(op);
|
||||||
|
}
|
||||||
|
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
|
||||||
|
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||||
|
userId: USER_B.id,
|
||||||
|
tenantId: USER_B.tenantId,
|
||||||
|
});
|
||||||
|
expect
|
||||||
|
.soft(socket.emit)
|
||||||
|
.toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await moduleRef.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// TESS test E (genuine, unchanged) — pi-rpc browser-legacy refusal.
|
||||||
|
it('rejects a browser legacy raw message in pi-rpc mode with a fixed typed unsupported and executes nothing', async () => {
|
||||||
|
// pi-rpc: the harness runtime is live. The browser legacy `message` path is unsupported and
|
||||||
|
// must be refused with a fixed typed code, touching neither the embedded AgentService nor the
|
||||||
|
// harness conversation service.
|
||||||
|
const agentService = makeScopedAgentService();
|
||||||
|
const embedded = new EmbeddedChatRuntime(agentService as never);
|
||||||
|
const harnessConversation = {
|
||||||
|
attach: vi.fn(),
|
||||||
|
detach: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
subscribeFrom: vi.fn(),
|
||||||
|
};
|
||||||
|
const harness = new HarnessChatRuntime(harnessConversation as never);
|
||||||
|
const router = new ChatRuntimeRouter(
|
||||||
|
registryWith(['pi']),
|
||||||
|
boundConversationService,
|
||||||
|
embedded,
|
||||||
|
harness,
|
||||||
|
'pi-rpc',
|
||||||
|
);
|
||||||
|
router.onModuleInit();
|
||||||
|
|
||||||
|
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 gateway = new ChatGateway(
|
||||||
|
router as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{ getManifest: vi.fn().mockReturnValue([]) } as never,
|
||||||
|
{ execute: vi.fn() } as never,
|
||||||
|
{ resolve: vi.fn() } as never,
|
||||||
|
);
|
||||||
|
const socket = {
|
||||||
|
id: 'socket-b',
|
||||||
|
connected: true,
|
||||||
|
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
|
||||||
|
emit: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.resolve(
|
||||||
|
gateway.handleMessage(socket as never, {
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
content: 'route me',
|
||||||
|
}),
|
||||||
|
).catch(() => undefined);
|
||||||
|
|
||||||
|
expect(socket.emit).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
expect.objectContaining({ code: 'runtime_unsupported' }),
|
||||||
|
);
|
||||||
|
expect(agentService.getSession).not.toHaveBeenCalled();
|
||||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||||
expect(socket.emit).toHaveBeenCalledWith(
|
expect(harnessConversation.attach).not.toHaveBeenCalled();
|
||||||
'error',
|
expect(harnessConversation.send).not.toHaveBeenCalled();
|
||||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task-5 AMEND — embedded runtime lease lifecycle (G1) + ownership collapse (G5).
|
||||||
|
// These drive the real EmbeddedChatRuntime directly over a shape-complete AgentService
|
||||||
|
// fake (every touched method exists, so a RED can only come from behavior, never a
|
||||||
|
// `getSession is not a function` TypeError). Ownership context is minted through the
|
||||||
|
// real `ownConversation` factory — the only sanctioned way to reach a port op.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EMBEDDED_SCOPE = { userId: USER_A.id, tenantId: USER_A.tenantId };
|
||||||
|
const CONVERSATION_UNAVAILABLE_RESULT = {
|
||||||
|
ok: false,
|
||||||
|
code: 'conversation_unavailable',
|
||||||
|
retryable: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** A stream sink; `channelId` is server-derived, `onEvent` records nothing here. */
|
||||||
|
function makeStream(): LegacyRuntimeStream {
|
||||||
|
return { channelId: 'websocket:test-1', onEvent: vi.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getSession → undefined (session missing), createSession → rejects with `err`. Exercises the
|
||||||
|
* `resolveOrCreate` collapse branch. `prompt` exists so its ABSENCE from the call record proves
|
||||||
|
* the turn short-circuited before any dispatch.
|
||||||
|
*/
|
||||||
|
function makeCollapsingAgentService(err: Error) {
|
||||||
|
return {
|
||||||
|
getSession: vi.fn(() => undefined),
|
||||||
|
createSession: vi.fn().mockRejectedValue(err),
|
||||||
|
onEvent: vi.fn(() => vi.fn()),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
recordTokenUsage: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** getSession → a live owned session, so `resolveOrCreate` succeeds and a lease is built. */
|
||||||
|
function makeLeaseAgentService() {
|
||||||
|
const session = makeAgentSession(USER_A);
|
||||||
|
const unsubscribe = vi.fn();
|
||||||
|
const svc = {
|
||||||
|
getSession: vi.fn(() => session),
|
||||||
|
createSession: vi.fn(),
|
||||||
|
onEvent: vi.fn(() => unsubscribe),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
recordTokenUsage: vi.fn(),
|
||||||
|
};
|
||||||
|
return { svc, unsubscribe, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getSession → a live owned session (REST resolveOrCreate succeeds), onEvent returns a `detach`
|
||||||
|
* spy, and `prompt` REJECTS with a non-timeout error. Drives the REST-turn catch path so the single
|
||||||
|
* idempotent teardown must clear the 120s timeout and detach the listener exactly once.
|
||||||
|
*/
|
||||||
|
function makeRejectingPromptAgentService() {
|
||||||
|
const session = makeAgentSession(USER_A);
|
||||||
|
const detach = vi.fn();
|
||||||
|
const svc = {
|
||||||
|
getSession: vi.fn(() => session),
|
||||||
|
createSession: vi.fn(),
|
||||||
|
onEvent: vi.fn(() => detach),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt: vi.fn().mockRejectedValue(new Error('agent backend exploded')),
|
||||||
|
recordTokenUsage: vi.fn(),
|
||||||
|
};
|
||||||
|
return { svc, detach };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TESS Task-5 embedded ownership collapse (missing and foreign are indistinguishable, never throw)', () => {
|
||||||
|
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
|
||||||
|
|
||||||
|
it('collapses a foreign (Forbidden) create to conversation_unavailable and never throws', async () => {
|
||||||
|
const svc = makeCollapsingAgentService(new ForbiddenException('foreign owner'));
|
||||||
|
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||||
|
|
||||||
|
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'take over' });
|
||||||
|
|
||||||
|
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
|
||||||
|
expect(svc.prompt).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses a missing (NotFound) create to conversation_unavailable and never throws', async () => {
|
||||||
|
const svc = makeCollapsingAgentService(new NotFoundException('no such conversation'));
|
||||||
|
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||||
|
|
||||||
|
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'hello' });
|
||||||
|
|
||||||
|
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
|
||||||
|
expect(svc.prompt).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the IDENTICAL collapse for foreign and missing so neither can be distinguished', async () => {
|
||||||
|
const foreign = new EmbeddedChatRuntime(
|
||||||
|
makeCollapsingAgentService(new ForbiddenException('foreign owner')) as never,
|
||||||
);
|
);
|
||||||
|
const missing = new EmbeddedChatRuntime(
|
||||||
|
makeCollapsingAgentService(new NotFoundException('no such conversation')) as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const foreignResult = await foreign.completeLegacyRestTurn(ctx, { content: 'x' });
|
||||||
|
const missingResult = await missing.completeLegacyRestTurn(ctx, { content: 'x' });
|
||||||
|
|
||||||
|
expect(foreignResult).toEqual(missingResult);
|
||||||
|
expect(foreignResult).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TESS Task-5 embedded socket lease lifecycle (one-shot dispatch, idempotent dispose, partial-setup rollback)', () => {
|
||||||
|
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
|
||||||
|
|
||||||
|
it('dispatches the turn exactly once; a second dispatch is a no-op turn_already_dispatched', async () => {
|
||||||
|
const { svc } = makeLeaseAgentService();
|
||||||
|
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||||
|
|
||||||
it('does not mutate thinking level on another owner/tenant session', () => {
|
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'first' }, makeStream());
|
||||||
const { gateway, agentService } = makeGateway();
|
expect(prepared.ok).toBe(true);
|
||||||
const socket = makeSocket();
|
if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed');
|
||||||
|
const lease = prepared.value;
|
||||||
|
|
||||||
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
|
const first = await lease.dispatch();
|
||||||
|
expect(first).toEqual({ ok: true, value: undefined });
|
||||||
|
expect(svc.prompt).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
const second = await lease.dispatch();
|
||||||
userId: USER_B.id,
|
expect(second).toEqual({ ok: false, code: 'turn_already_dispatched', retryable: false });
|
||||||
tenantId: USER_B.tenantId,
|
// Zero additional effect — the second dispatch must not prompt again.
|
||||||
|
expect(svc.prompt).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disposes once; a second dispose is a silent no-op that never re-detaches or destroys the session', async () => {
|
||||||
|
const { svc, unsubscribe, session } = makeLeaseAgentService();
|
||||||
|
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||||
|
|
||||||
|
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
|
||||||
|
expect(prepared.ok).toBe(true);
|
||||||
|
if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed');
|
||||||
|
const lease = prepared.value;
|
||||||
|
|
||||||
|
await lease.dispose();
|
||||||
|
await lease.dispose();
|
||||||
|
|
||||||
|
// Listener + channel torn down exactly once across two dispose calls.
|
||||||
|
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||||
|
expect(svc.removeChannel).toHaveBeenCalledTimes(1);
|
||||||
|
// Disposal never terminates the underlying session or process.
|
||||||
|
expect(session.piSession.abort).not.toHaveBeenCalled();
|
||||||
|
expect(session.piSession.dispose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back the acquired listener and returns a total safe failure when channel attach fails mid-setup', async () => {
|
||||||
|
const { svc, unsubscribe } = makeLeaseAgentService();
|
||||||
|
svc.addChannel = vi.fn(() => {
|
||||||
|
throw new Error('channel attach failed');
|
||||||
});
|
});
|
||||||
expect(socket.emit).toHaveBeenCalledWith(
|
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||||
'error',
|
|
||||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
// Must NOT throw out of the port — a partial setup collapses to a total safe failure.
|
||||||
);
|
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
|
||||||
|
expect(prepared.ok).toBe(false);
|
||||||
|
// Exactly what was acquired (the event listener) is rolled back.
|
||||||
|
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TESS Task-5 embedded REST turn teardown (a prompt rejection frees the timer + listener exactly once)', () => {
|
||||||
|
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
|
||||||
|
|
||||||
|
it('clears the 120s timeout and detaches the listener exactly once when prompt() rejects, leaving no timer to reject the abandoned done-promise later (Task 5 finding 6)', async () => {
|
||||||
|
const { svc, detach } = makeRejectingPromptAgentService();
|
||||||
|
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||||
|
|
||||||
|
// A rejected `done` promise firing after completeLegacyRestTurn has already returned would
|
||||||
|
// surface as an unhandledRejection — the leak this test fences. Capture any that escape.
|
||||||
|
const unhandled: unknown[] = [];
|
||||||
|
const onUnhandled = (reason: unknown): void => {
|
||||||
|
unhandled.push(reason);
|
||||||
|
};
|
||||||
|
process.on('unhandledRejection', onUnhandled);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const result = await runtime.completeLegacyRestTurn(ctx, {
|
||||||
|
content: 'trigger a backend failure',
|
||||||
|
});
|
||||||
|
|
||||||
|
// The rejection collapses to a total safe failure (not a timeout) — never throws out of the port.
|
||||||
|
expect(result).toEqual({ ok: false, code: 'operation_failed', retryable: false });
|
||||||
|
// The single idempotent dispose ran in the catch: listener detached exactly once.
|
||||||
|
expect(detach).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// dispose() cleared the REST timeout, so advancing far past it (120s) fires nothing: no second
|
||||||
|
// detach, and — the actual leak — no live timer left to reject the now-abandoned `done` promise.
|
||||||
|
vi.advanceTimersByTime(600_000);
|
||||||
|
expect(detach).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
// Let any scheduled rejection surface on a real macrotask, then confirm none did.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
process.off('unhandledRejection', onUnhandled);
|
||||||
|
expect(unhandled).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('bounds a hung prompt: when prompt() never settles and no agent_end arrives, the 120s timeout ends the turn with a timeout result and exactly one teardown, no unhandledRejection (Task 5 finding 6 — pending-prompt timeout)', async () => {
|
||||||
|
const session = makeAgentSession(USER_A);
|
||||||
|
const detach = vi.fn();
|
||||||
|
const svc = {
|
||||||
|
getSession: vi.fn(() => session),
|
||||||
|
createSession: vi.fn(),
|
||||||
|
onEvent: vi.fn(() => detach),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
// The prompt never resolves or rejects — a hung agent backend. Under the pre-fix sequential
|
||||||
|
// `await prompt()` the timer could never even be observed, so the turn hung forever.
|
||||||
|
prompt: vi.fn(() => new Promise<void>(() => undefined)),
|
||||||
|
recordTokenUsage: vi.fn(),
|
||||||
|
};
|
||||||
|
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||||
|
|
||||||
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
|
const unhandled: unknown[] = [];
|
||||||
const { gateway, agentService } = makeGateway();
|
const onUnhandled = (reason: unknown): void => {
|
||||||
const socket = makeSocket();
|
unhandled.push(reason);
|
||||||
|
};
|
||||||
|
process.on('unhandledRejection', onUnhandled);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
|
||||||
|
content: 'a prompt that never returns',
|
||||||
|
});
|
||||||
|
// No agent_end, prompt still pending: only the 120s timeout can end the turn. Promise.all
|
||||||
|
// installed a handler on `done` synchronously, so the timer bounds the turn while prompt hangs.
|
||||||
|
await vi.advanceTimersByTimeAsync(200_000);
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
|
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
|
||||||
|
// The single idempotent dispose ran on the timeout path: listener detached exactly once.
|
||||||
|
expect(detach).toHaveBeenCalledTimes(1);
|
||||||
|
// Advancing far past the deadline fires nothing more: dispose cleared the timer.
|
||||||
|
vi.advanceTimersByTime(600_000);
|
||||||
|
expect(detach).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
process.off('unhandledRejection', onUnhandled);
|
||||||
|
expect(unhandled).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
it('when the 120s timeout fires while prompt() is still pending, returns timeout with one teardown, and a later prompt rejection surfaces no unhandledRejection (Task 5 finding 6 — timeout/prompt race)', async () => {
|
||||||
userId: USER_B.id,
|
const session = makeAgentSession(USER_A);
|
||||||
tenantId: USER_B.tenantId,
|
const detach = vi.fn();
|
||||||
|
let rejectPrompt: (reason: unknown) => void = () => undefined;
|
||||||
|
const prompting = new Promise<void>((_resolve, reject) => {
|
||||||
|
rejectPrompt = reject;
|
||||||
});
|
});
|
||||||
expect(socket.emit).toHaveBeenCalledWith(
|
const svc = {
|
||||||
'error',
|
getSession: vi.fn(() => session),
|
||||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
createSession: vi.fn(),
|
||||||
);
|
onEvent: vi.fn(() => detach),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt: vi.fn(() => prompting),
|
||||||
|
recordTokenUsage: vi.fn(),
|
||||||
|
};
|
||||||
|
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||||
|
|
||||||
|
const unhandled: unknown[] = [];
|
||||||
|
const onUnhandled = (reason: unknown): void => {
|
||||||
|
unhandled.push(reason);
|
||||||
|
};
|
||||||
|
process.on('unhandledRejection', onUnhandled);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
|
||||||
|
content: 'prompt settles after the deadline',
|
||||||
|
});
|
||||||
|
// The timeout wins the race while prompt is still pending.
|
||||||
|
await vi.advanceTimersByTimeAsync(200_000);
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
|
||||||
|
expect(detach).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The prompt now rejects LATE — after the turn already returned its timeout result. Because
|
||||||
|
// Promise.all installed a rejection handler on `prompting` synchronously (the fix), this late
|
||||||
|
// rejection is already observed and must not escape as an unhandledRejection.
|
||||||
|
rejectPrompt(new Error('late backend failure'));
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
process.off('unhandledRejection', onUnhandled);
|
||||||
|
expect(unhandled).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* to avoid real I/O — they verify the complete classify → match → decide path.
|
* to avoid real I/O — they verify the complete classify → match → decide path.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
||||||
import { RoutingEngineService } from './routing-engine.service.js';
|
import { RoutingEngineService } from './routing-engine.service.js';
|
||||||
import { DEFAULT_ROUTING_RULES } from '../routing/default-rules.js';
|
import { DEFAULT_ROUTING_RULES } from '../routing/default-rules.js';
|
||||||
import type { RoutingRule } from './routing.types.js';
|
import type { RoutingRule } from './routing.types.js';
|
||||||
@@ -17,7 +18,7 @@ import type { RoutingRule } from './routing.types.js';
|
|||||||
/** Build a RoutingEngineService backed by the given rule set and health map. */
|
/** Build a RoutingEngineService backed by the given rule set and health map. */
|
||||||
function makeService(
|
function makeService(
|
||||||
rules: RoutingRule[],
|
rules: RoutingRule[],
|
||||||
healthMap: Record<string, { status: string }>,
|
healthMap: Record<string, { status: ProviderHealthStatus }>,
|
||||||
): RoutingEngineService {
|
): RoutingEngineService {
|
||||||
const mockDb = {
|
const mockDb = {
|
||||||
select: vi.fn().mockReturnValue({
|
select: vi.fn().mockReturnValue({
|
||||||
@@ -67,11 +68,11 @@ function defaultRules(): RoutingRule[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A health map where anthropic, openai, and zai are all healthy. */
|
/** A health map where anthropic, openai, and zai are all healthy. */
|
||||||
const allHealthy: Record<string, { status: string }> = {
|
const allHealthy: Record<string, { status: ProviderHealthStatus }> = {
|
||||||
anthropic: { status: 'up' },
|
anthropic: { status: 'healthy' },
|
||||||
openai: { status: 'up' },
|
openai: { status: 'healthy' },
|
||||||
zai: { status: 'up' },
|
zai: { status: 'healthy' },
|
||||||
ollama: { status: 'up' },
|
ollama: { status: 'healthy' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── M4-013 E2E tests ─────────────────────────────────────────────────────────
|
// ─── M4-013 E2E tests ─────────────────────────────────────────────────────────
|
||||||
@@ -212,10 +213,10 @@ describe('M4-013: routing end-to-end pipeline', () => {
|
|||||||
// Let's use a simple coding message to target Simple coding → Codex (openai)
|
// Let's use a simple coding message to target Simple coding → Codex (openai)
|
||||||
const message = 'implement a sort function';
|
const message = 'implement a sort function';
|
||||||
|
|
||||||
const unhealthyHealth = {
|
const unhealthyHealth: Record<string, { status: ProviderHealthStatus }> = {
|
||||||
anthropic: { status: 'down' },
|
anthropic: { status: 'down' },
|
||||||
openai: { status: 'up' },
|
openai: { status: 'healthy' },
|
||||||
zai: { status: 'up' },
|
zai: { status: 'healthy' },
|
||||||
ollama: { status: 'down' },
|
ollama: { status: 'down' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { routingRules, type Db, and, asc, eq, or } from '@mosaicstack/db';
|
import { routingRules, type Db, and, asc, eq, or } from '@mosaicstack/db';
|
||||||
|
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
||||||
import { DB } from '../../database/database.module.js';
|
import { DB } from '../../database/database.module.js';
|
||||||
import { ProviderService } from '../provider.service.js';
|
import { ProviderService } from '../provider.service.js';
|
||||||
import { classifyTask } from './task-classifier.js';
|
import { classifyTask } from './task-classifier.js';
|
||||||
@@ -49,7 +50,7 @@ export class RoutingEngineService {
|
|||||||
async resolve(
|
async resolve(
|
||||||
message: string,
|
message: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
availableProviders?: Record<string, { status: string }>,
|
availableProviders?: Record<string, { status: ProviderHealthStatus }>,
|
||||||
): Promise<RoutingDecision> {
|
): Promise<RoutingDecision> {
|
||||||
const classification = classifyTask(message);
|
const classification = classifyTask(message);
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
@@ -69,9 +70,8 @@ export class RoutingEngineService {
|
|||||||
if (!this.matchConditions(rule, classification)) continue;
|
if (!this.matchConditions(rule, classification)) continue;
|
||||||
|
|
||||||
const providerStatus = health[rule.action.provider]?.status;
|
const providerStatus = health[rule.action.provider]?.status;
|
||||||
const isHealthy = providerStatus === 'up' || providerStatus === 'ok';
|
|
||||||
|
|
||||||
if (!isHealthy) {
|
if (!this.isRoutable(providerStatus)) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Rule "${rule.name}" matched but provider "${rule.action.provider}" is unhealthy (status: ${providerStatus ?? 'unknown'})`,
|
`Rule "${rule.name}" matched but provider "${rule.action.provider}" is unhealthy (status: ${providerStatus ?? 'unknown'})`,
|
||||||
);
|
);
|
||||||
@@ -111,6 +111,10 @@ export class RoutingEngineService {
|
|||||||
|
|
||||||
// ─── Private helpers ───────────────────────────────────────────────────────
|
// ─── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private isRoutable(status: ProviderHealthStatus | undefined): boolean {
|
||||||
|
return status === 'healthy' || status === 'degraded';
|
||||||
|
}
|
||||||
|
|
||||||
private evaluateCondition(
|
private evaluateCondition(
|
||||||
condition: RoutingCondition,
|
condition: RoutingCondition,
|
||||||
classification: TaskClassification,
|
classification: TaskClassification,
|
||||||
@@ -186,11 +190,12 @@ export class RoutingEngineService {
|
|||||||
* Walk the fallback chain and return the first healthy provider/model pair.
|
* Walk the fallback chain and return the first healthy provider/model pair.
|
||||||
* If none are healthy, return the first entry unconditionally (last resort).
|
* If none are healthy, return the first entry unconditionally (last resort).
|
||||||
*/
|
*/
|
||||||
private applyFallbackChain(health: Record<string, { status: string }>): RoutingDecision {
|
private applyFallbackChain(
|
||||||
|
health: Record<string, { status: ProviderHealthStatus }>,
|
||||||
|
): RoutingDecision {
|
||||||
for (const candidate of FALLBACK_CHAIN) {
|
for (const candidate of FALLBACK_CHAIN) {
|
||||||
const providerStatus = health[candidate.provider]?.status;
|
const providerStatus = health[candidate.provider]?.status;
|
||||||
const isHealthy = providerStatus === 'up' || providerStatus === 'ok';
|
if (this.isRoutable(providerStatus)) {
|
||||||
if (isHealthy) {
|
|
||||||
this.logger.debug(`Fallback resolved: ${candidate.provider}/${candidate.model}`);
|
this.logger.debug(`Fallback resolved: ${candidate.provider}/${candidate.model}`);
|
||||||
return {
|
return {
|
||||||
provider: candidate.provider,
|
provider: candidate.provider,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
||||||
import { RoutingEngineService } from './routing-engine.service.js';
|
import { RoutingEngineService } from './routing-engine.service.js';
|
||||||
import type { RoutingRule, TaskClassification } from './routing.types.js';
|
import type { RoutingRule, TaskClassification } from './routing.types.js';
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ function makeClassification(overrides: Partial<TaskClassification> = {}): TaskCl
|
|||||||
/** Build a minimal RoutingEngineService with mocked DB and ProviderService. */
|
/** Build a minimal RoutingEngineService with mocked DB and ProviderService. */
|
||||||
function makeService(
|
function makeService(
|
||||||
rules: RoutingRule[] = [],
|
rules: RoutingRule[] = [],
|
||||||
healthMap: Record<string, { status: string }> = {},
|
healthMap: Record<string, { status: ProviderHealthStatus }> = {},
|
||||||
): RoutingEngineService {
|
): RoutingEngineService {
|
||||||
const mockDb = {
|
const mockDb = {
|
||||||
select: vi.fn().mockReturnValue({
|
select: vi.fn().mockReturnValue({
|
||||||
@@ -217,7 +218,10 @@ describe('RoutingEngineService.resolve — priority ordering', () => {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });
|
const service = makeService(rules, {
|
||||||
|
anthropic: { status: 'healthy' },
|
||||||
|
openai: { status: 'healthy' },
|
||||||
|
});
|
||||||
|
|
||||||
const decision = await service.resolve('implement a function');
|
const decision = await service.resolve('implement a function');
|
||||||
expect(decision.ruleName).toBe('high priority');
|
expect(decision.ruleName).toBe('high priority');
|
||||||
@@ -241,7 +245,10 @@ describe('RoutingEngineService.resolve — priority ordering', () => {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });
|
const service = makeService(rules, {
|
||||||
|
anthropic: { status: 'healthy' },
|
||||||
|
openai: { status: 'healthy' },
|
||||||
|
});
|
||||||
|
|
||||||
const decision = await service.resolve('implement a function');
|
const decision = await service.resolve('implement a function');
|
||||||
expect(decision.ruleName).toBe('coding rule');
|
expect(decision.ruleName).toBe('coding rule');
|
||||||
@@ -270,7 +277,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
|||||||
|
|
||||||
const service = makeService(rules, {
|
const service = makeService(rules, {
|
||||||
anthropic: { status: 'down' }, // primary is unhealthy
|
anthropic: { status: 'down' }, // primary is unhealthy
|
||||||
openai: { status: 'up' },
|
openai: { status: 'healthy' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const decision = await service.resolve('implement a function');
|
const decision = await service.resolve('implement a function');
|
||||||
@@ -290,7 +297,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const service2 = makeService(unhealthyRules, {
|
const service2 = makeService(unhealthyRules, {
|
||||||
anthropic: { status: 'up' },
|
anthropic: { status: 'healthy' },
|
||||||
openai: { status: 'down' },
|
openai: { status: 'down' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -306,7 +313,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
|||||||
|
|
||||||
const service = makeService(rules, {
|
const service = makeService(rules, {
|
||||||
anthropic: { status: 'down' }, // Sonnet is on anthropic — down
|
anthropic: { status: 'down' }, // Sonnet is on anthropic — down
|
||||||
ollama: { status: 'up' }, // Haiku is also on anthropic — use Ollama as next
|
ollama: { status: 'healthy' }, // Haiku is also on anthropic — use Ollama as next
|
||||||
});
|
});
|
||||||
|
|
||||||
const decision = await service.resolve('hello there');
|
const decision = await service.resolve('hello there');
|
||||||
@@ -345,7 +352,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', ()
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const service = makeService(rules, { anthropic: { status: 'up' } });
|
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
||||||
|
|
||||||
const decision = await service.resolve('completely unrelated message xyz');
|
const decision = await service.resolve('completely unrelated message xyz');
|
||||||
expect(decision.ruleName).toBe('catch-all');
|
expect(decision.ruleName).toBe('catch-all');
|
||||||
@@ -369,7 +376,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', ()
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const service = makeService(rules, { anthropic: { status: 'up' } });
|
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
||||||
|
|
||||||
const codingDecision = await service.resolve('implement a function');
|
const codingDecision = await service.resolve('implement a function');
|
||||||
expect(codingDecision.ruleName).toBe('specific coding rule');
|
expect(codingDecision.ruleName).toBe('specific coding rule');
|
||||||
@@ -401,7 +408,7 @@ describe('RoutingEngineService.resolve — disabled rules', () => {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const service = makeService(rules, { anthropic: { status: 'up' } });
|
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
||||||
|
|
||||||
const decision = await service.resolve('implement a function');
|
const decision = await service.resolve('implement a function');
|
||||||
expect(decision.ruleName).toBe('enabled fallback');
|
expect(decision.ruleName).toBe('enabled fallback');
|
||||||
@@ -452,9 +459,45 @@ describe('RoutingEngineService.resolve — availableProviders override', () => {
|
|||||||
ps: unknown,
|
ps: unknown,
|
||||||
) => RoutingEngineService)(mockDb, mockProviderService);
|
) => RoutingEngineService)(mockDb, mockProviderService);
|
||||||
|
|
||||||
const preSupplied = { anthropic: { status: 'up' } };
|
const preSupplied: Record<string, { status: ProviderHealthStatus }> = {
|
||||||
|
anthropic: { status: 'healthy' },
|
||||||
|
};
|
||||||
await service.resolve('implement a function', undefined, preSupplied);
|
await service.resolve('implement a function', undefined, preSupplied);
|
||||||
|
|
||||||
expect(mockHealthCheckAll).not.toHaveBeenCalled();
|
expect(mockHealthCheckAll).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── resolve — canonical ProviderHealthStatus values ──────────────────────────
|
||||||
|
|
||||||
|
describe('RoutingEngineService.resolve — canonical health status routing', () => {
|
||||||
|
it('routes healthy and degraded providers by rule, and falls through to fallback when down', async () => {
|
||||||
|
const codingRule = makeRule({
|
||||||
|
name: 'coding rule',
|
||||||
|
priority: 1,
|
||||||
|
conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
|
||||||
|
action: { provider: 'openai', model: 'gpt-4o' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// healthy → selected by its own rule, not the fallback chain
|
||||||
|
const healthyService = makeService([codingRule], { openai: { status: 'healthy' } });
|
||||||
|
const healthyDecision = await healthyService.resolve('implement a function');
|
||||||
|
expect(healthyDecision.ruleName).toBe('coding rule');
|
||||||
|
expect(healthyDecision.provider).toBe('openai');
|
||||||
|
|
||||||
|
// down → rule is skipped as unroutable, falls through to the fallback chain
|
||||||
|
const downService = makeService([codingRule], {
|
||||||
|
openai: { status: 'down' },
|
||||||
|
anthropic: { status: 'healthy' },
|
||||||
|
});
|
||||||
|
const downDecision = await downService.resolve('implement a function');
|
||||||
|
expect(downDecision.ruleName).toBe('fallback');
|
||||||
|
expect(downDecision.provider).toBe('anthropic');
|
||||||
|
|
||||||
|
// degraded → still routable, selected by its own rule, not the fallback chain
|
||||||
|
const degradedService = makeService([codingRule], { openai: { status: 'degraded' } });
|
||||||
|
const degradedDecision = await degradedService.resolve('implement a function');
|
||||||
|
expect(degradedDecision.ruleName).toBe('coding rule');
|
||||||
|
expect(degradedDecision.provider).toBe('openai');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,624 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import * as nodeOs from 'node:os';
|
||||||
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
|
import * as nodeUrl from 'node:url';
|
||||||
|
import { MODULE_METADATA } from '@nestjs/common/constants.js';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
|
|
||||||
|
interface ComposedModuleGraph {
|
||||||
|
imports: readonly unknown[];
|
||||||
|
federationModule: unknown;
|
||||||
|
bootLogLines: readonly string[];
|
||||||
|
mosaicConfig: MosaicConfig;
|
||||||
|
resolvedConfigPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageTier = 'local' | 'standalone' | 'federated';
|
||||||
|
|
||||||
|
interface ModuleGraphFixture {
|
||||||
|
tempRoot: string;
|
||||||
|
anchor: string;
|
||||||
|
homePath: string;
|
||||||
|
cwdPath: string;
|
||||||
|
monorepoRootEnvPath: string;
|
||||||
|
gatewayLocalEnvPath: string;
|
||||||
|
daemonEnvPath: string;
|
||||||
|
monorepoRootConfigPath: string;
|
||||||
|
gatewayLocalConfigPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModuleGraphFixtureOptions {
|
||||||
|
rootEnvMode?: 'present' | 'absent';
|
||||||
|
rootTier?: StorageTier;
|
||||||
|
rootEnvContents?: string;
|
||||||
|
redactionMarker?: string;
|
||||||
|
gatewayLocalTier?: StorageTier;
|
||||||
|
gatewayLocalEnvContents?: string;
|
||||||
|
daemonEnvContents?: string;
|
||||||
|
inheritedTier?: StorageTier;
|
||||||
|
expectedProcessTier?: string;
|
||||||
|
setup?: (fixture: ModuleGraphFixture) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
||||||
|
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
|
||||||
|
const MONOREPO_ROOT_DOTENV_LABEL = 'monorepo-root .env';
|
||||||
|
const DAEMON_DOTENV_LABEL = 'daemon .env';
|
||||||
|
|
||||||
|
function configJson(tier: StorageTier): string {
|
||||||
|
if (tier === 'local') {
|
||||||
|
return JSON.stringify({
|
||||||
|
tier,
|
||||||
|
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
||||||
|
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
||||||
|
memory: { type: 'keyword' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
tier,
|
||||||
|
storage: { type: 'postgres', url: 'postgresql://fixture.invalid/mosaic' },
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: tier === 'federated' ? 'pgvector' : 'keyword' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotProcessEnv(): Record<string, string | undefined> {
|
||||||
|
return { ...process.env };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (!(key in snapshot)) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(snapshot)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
|
||||||
|
const relativePath = relative(tempRoot, path);
|
||||||
|
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
|
||||||
|
expectPathUnderTempRoot(path, tempRoot);
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, contents, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigModuleProvider {
|
||||||
|
provide: string;
|
||||||
|
useFactory: () => MosaicConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfigModuleProvider(value: unknown): value is ConfigModuleProvider {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('provide' in value) || typeof value.provide !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'useFactory' in value && typeof value.useFactory === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
function singleBootLogLine(bootLogLines: readonly string[]): string {
|
||||||
|
expect(bootLogLines).toHaveLength(1);
|
||||||
|
const [bootLogLine] = bootLogLines;
|
||||||
|
if (bootLogLine === undefined) {
|
||||||
|
throw new Error('Expected a single boot log line');
|
||||||
|
}
|
||||||
|
|
||||||
|
return bootLogLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectBootLogLine(
|
||||||
|
bootLogLines: readonly string[],
|
||||||
|
tier: StorageTier,
|
||||||
|
source: string,
|
||||||
|
): void {
|
||||||
|
const bootLogLine = singleBootLogLine(bootLogLines);
|
||||||
|
|
||||||
|
expect(bootLogLine).toContain(`storage tier=${tier}`);
|
||||||
|
expect(bootLogLine).toContain(`source=${source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModuleGraphFromDotenv(
|
||||||
|
options: ModuleGraphFixtureOptions,
|
||||||
|
): Promise<ComposedModuleGraph> {
|
||||||
|
const originalEnv = snapshotProcessEnv();
|
||||||
|
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-module-'));
|
||||||
|
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||||
|
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
|
||||||
|
const homePath = join(tempRoot, 'home');
|
||||||
|
const cwdPath = join(tempRoot, 'ambient', 'parent', 'cwd');
|
||||||
|
const fixture: ModuleGraphFixture = {
|
||||||
|
tempRoot,
|
||||||
|
anchor,
|
||||||
|
homePath,
|
||||||
|
cwdPath,
|
||||||
|
monorepoRootEnvPath: resolve(anchor, '../../..', '.env'),
|
||||||
|
gatewayLocalEnvPath: resolve(anchor, '..', '.env'),
|
||||||
|
daemonEnvPath: join(homePath, '.config', 'mosaic', 'gateway', '.env'),
|
||||||
|
monorepoRootConfigPath: resolve(anchor, '../../..', 'mosaic.config.json'),
|
||||||
|
gatewayLocalConfigPath: resolve(anchor, '..', 'mosaic.config.json'),
|
||||||
|
};
|
||||||
|
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
||||||
|
|
||||||
|
for (const path of Object.values(fixture)) {
|
||||||
|
expectPathUnderTempRoot(path, tempRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(anchor, { recursive: true });
|
||||||
|
await mkdir(cwdPath, { recursive: true });
|
||||||
|
|
||||||
|
if ((options.rootEnvMode ?? 'present') === 'absent') {
|
||||||
|
if (
|
||||||
|
options.rootEnvContents !== undefined ||
|
||||||
|
options.rootTier !== undefined ||
|
||||||
|
options.redactionMarker !== undefined
|
||||||
|
) {
|
||||||
|
throw new Error('Expected no root env fixture values when rootEnvMode is absent');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (options.rootEnvContents === undefined && options.rootTier === undefined) {
|
||||||
|
throw new Error('Expected rootTier or rootEnvContents');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`;
|
||||||
|
const rootFixtureWithMarker = options.redactionMarker
|
||||||
|
? `${rootFixture}BETTER_AUTH_SECRET=${options.redactionMarker}\n`
|
||||||
|
: rootFixture;
|
||||||
|
await writeFixture(fixture.monorepoRootEnvPath, rootFixtureWithMarker, tempRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.daemonEnvContents !== undefined) {
|
||||||
|
await writeFixture(fixture.daemonEnvPath, options.daemonEnvContents, tempRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.gatewayLocalEnvContents !== undefined) {
|
||||||
|
await writeFixture(fixture.gatewayLocalEnvPath, options.gatewayLocalEnvContents, tempRoot);
|
||||||
|
} else if (options.gatewayLocalTier !== undefined) {
|
||||||
|
await writeFixture(
|
||||||
|
fixture.gatewayLocalEnvPath,
|
||||||
|
`MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`,
|
||||||
|
tempRoot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env['HOME'] = homePath;
|
||||||
|
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
delete process.env['DATABASE_URL'];
|
||||||
|
delete process.env['VALKEY_URL'];
|
||||||
|
delete process.env['MOSAIC_GATEWAY_HOME'];
|
||||||
|
|
||||||
|
await options.setup?.(fixture);
|
||||||
|
|
||||||
|
if (options.inheritedTier !== undefined) {
|
||||||
|
process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
|
||||||
|
vi.doMock('node:url', () => ({
|
||||||
|
...nodeUrl,
|
||||||
|
fileURLToPath: (url: string | URL): string => {
|
||||||
|
const actualPath = nodeUrl.fileURLToPath(url);
|
||||||
|
if (
|
||||||
|
actualPath.endsWith('/apps/gateway/src/env.ts') ||
|
||||||
|
actualPath.endsWith('/apps/gateway/src/env.js')
|
||||||
|
) {
|
||||||
|
return join(anchor, 'env.ts');
|
||||||
|
}
|
||||||
|
return actualPath;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
|
||||||
|
|
||||||
|
if (options.inheritedTier === undefined) {
|
||||||
|
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
|
||||||
|
} else {
|
||||||
|
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envModule = await import('./env.js');
|
||||||
|
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(
|
||||||
|
options.expectedProcessTier ?? options.rootTier,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { AppModule } = await import('./app.module.js');
|
||||||
|
const { FederationModule } = await import('./federation/federation.module.js');
|
||||||
|
const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule);
|
||||||
|
|
||||||
|
if (!Array.isArray(imports)) {
|
||||||
|
throw new Error('AppModule imports metadata is not an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ConfigModule, MOSAIC_CONFIG } = await import('./config/config.module.js');
|
||||||
|
const providers: unknown = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, ConfigModule);
|
||||||
|
|
||||||
|
if (!Array.isArray(providers)) {
|
||||||
|
throw new Error('ConfigModule providers metadata is not an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
const configProvider = providers
|
||||||
|
.filter(isConfigModuleProvider)
|
||||||
|
.find((provider: ConfigModuleProvider): boolean => provider.provide === MOSAIC_CONFIG);
|
||||||
|
|
||||||
|
if (!configProvider) {
|
||||||
|
throw new Error('MOSAIC_CONFIG provider factory not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imports,
|
||||||
|
federationModule: FederationModule,
|
||||||
|
bootLogLines: consoleInfoSpy.mock.calls.map((args: readonly unknown[]): string =>
|
||||||
|
args.map((value: unknown): string => String(value)).join(' '),
|
||||||
|
),
|
||||||
|
mosaicConfig: configProvider.useFactory(),
|
||||||
|
resolvedConfigPath: envModule.resolveGatewayConfigPath(),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
cwdSpy?.mockRestore();
|
||||||
|
vi.doUnmock('node:url');
|
||||||
|
vi.doUnmock('node:os');
|
||||||
|
vi.resetModules();
|
||||||
|
consoleInfoSpy?.mockRestore();
|
||||||
|
restoreProcessEnv(originalEnv);
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppModule federation gating', (): void => {
|
||||||
|
it('loads dotenv before tracing and AppModule evaluation', async (): Promise<void> => {
|
||||||
|
const mainSource = await readFile(new URL('./main.ts', import.meta.url), 'utf8');
|
||||||
|
const envImportIndex = mainSource.indexOf("import './env.js';");
|
||||||
|
const tracingImportIndex = mainSource.indexOf("import './tracing.js';");
|
||||||
|
const appModuleImportIndex = mainSource.indexOf("import { AppModule } from './app.module.js';");
|
||||||
|
|
||||||
|
expect(envImportIndex).toBeGreaterThan(-1);
|
||||||
|
expect(envImportIndex).toBeLessThan(tracingImportIndex);
|
||||||
|
expect(envImportIndex).toBeLessThan(appModuleImportIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'ignores ambient cwd/.env and cwd/../.env files',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
join(fixture.cwdPath, '.env'),
|
||||||
|
'MOSAIC_STORAGE_TIER=federated\n',
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
await writeFixture(
|
||||||
|
resolve(fixture.cwdPath, '..', '.env'),
|
||||||
|
'MOSAIC_STORAGE_TIER=federated\n',
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'ignores an ambient cwd/mosaic.config.json federated config',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
join(fixture.cwdPath, 'mosaic.config.json'),
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'ignores an ambient cwd/../../mosaic.config.json federated config',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
resolve(fixture.cwdPath, '../..', 'mosaic.config.json'),
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'anchored gateway-local config wins monorepo-root config and registers FederationModule',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
let gatewayLocalConfigPath = '';
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
gatewayLocalConfigPath = fixture.gatewayLocalConfigPath;
|
||||||
|
await writeFixture(
|
||||||
|
fixture.gatewayLocalConfigPath,
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.resolvedConfigPath).toBe(gatewayLocalConfigPath);
|
||||||
|
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'resolves the daemon-installed GATEWAY_HOME/mosaic.config.json ahead of gateway-local and monorepo-root configs',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
let daemonConfigPath = '';
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvMode: 'absent',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
const externalGatewayHome = join(fixture.tempRoot, 'external-gateway-home');
|
||||||
|
daemonConfigPath = join(externalGatewayHome, 'mosaic.config.json');
|
||||||
|
await writeFixture(daemonConfigPath, configJson('federated'), fixture.tempRoot);
|
||||||
|
await writeFixture(
|
||||||
|
fixture.gatewayLocalConfigPath,
|
||||||
|
configJson('standalone'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
||||||
|
process.env['MOSAIC_GATEWAY_HOME'] = externalGatewayHome;
|
||||||
|
process.env['DATABASE_URL'] = 'postgresql://fixture.invalid/mosaic';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.resolvedConfigPath).toBe(daemonConfigPath);
|
||||||
|
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'logs mosaic.config.json when anchored config and env tiers are both federated',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'federated',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
fixture.monorepoRootConfigPath,
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'logs standalone from a monorepo-root .env DATABASE_URL fallback',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'attributes an invalid monorepo-root dotenv tier to the default',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
|
||||||
|
expectedProcessTier: 'invalid',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'local', 'default');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'attributes DATABASE_URL fallback to daemon .env ahead of inherited local tier',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvMode: 'absent',
|
||||||
|
daemonEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
||||||
|
inheritedTier: 'local',
|
||||||
|
expectedProcessTier: 'local',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'daemon .env wins over monorepo-root and gateway-local tier values',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
gatewayLocalTier: 'federated',
|
||||||
|
daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n',
|
||||||
|
expectedProcessTier: 'standalone',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
gatewayLocalTier: 'federated',
|
||||||
|
daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n',
|
||||||
|
inheritedTier: 'standalone',
|
||||||
|
expectedProcessTier: 'standalone',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'gateway-local .env configures the tier and source when the monorepo-root .env is absent',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvMode: 'absent',
|
||||||
|
gatewayLocalTier: 'federated',
|
||||||
|
expectedProcessTier: 'federated',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'monorepo-root .env wins over gateway-local tier values',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'standalone',
|
||||||
|
gatewayLocalTier: 'federated',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['local', 'standalone'] as const)(
|
||||||
|
'does not register FederationModule for the %s tier',
|
||||||
|
async (tier): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({ rootTier: tier });
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'registers FederationModule when federated tier is supplied by the anchored monorepo root .env',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const redactionMarker = 'redaction-fixture-marker';
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'federated',
|
||||||
|
redactionMarker,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
expect(singleBootLogLine(graph.bootLogLines)).not.toContain(redactionMarker);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'MOSAIC_CONFIG provider ignores an ambient cwd/mosaic.config.json config',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
join(fixture.cwdPath, 'mosaic.config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
tier: 'federated',
|
||||||
|
storage: {
|
||||||
|
type: 'postgres',
|
||||||
|
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
||||||
|
enableVector: true,
|
||||||
|
},
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: 'pgvector' },
|
||||||
|
}),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.mosaicConfig.tier).toBe('local');
|
||||||
|
expect(graph.mosaicConfig.storage).not.toEqual(
|
||||||
|
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'MOSAIC_CONFIG provider resolves from the anchored monorepo-root mosaic.config.json',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
fixture.monorepoRootConfigPath,
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||||
|
expect(graph.mosaicConfig.storage).toEqual(
|
||||||
|
expect.objectContaining({ url: 'postgresql://fixture.invalid/mosaic' }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -21,11 +21,22 @@ import { AdminModule } from './admin/admin.module.js';
|
|||||||
import { CommandsModule } from './commands/commands.module.js';
|
import { CommandsModule } from './commands/commands.module.js';
|
||||||
import { PreferencesModule } from './preferences/preferences.module.js';
|
import { PreferencesModule } from './preferences/preferences.module.js';
|
||||||
import { GCModule } from './gc/gc.module.js';
|
import { GCModule } from './gc/gc.module.js';
|
||||||
|
import { HarnessModule } from './harness/harness.module.js';
|
||||||
import { ReloadModule } from './reload/reload.module.js';
|
import { ReloadModule } from './reload/reload.module.js';
|
||||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||||
import { QueueModule } from './queue/queue.module.js';
|
import { QueueModule } from './queue/queue.module.js';
|
||||||
import { FederationModule } from './federation/federation.module.js';
|
import { FederationModule } from './federation/federation.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { loadConfig } from '@mosaicstack/config';
|
||||||
|
import { resolveGatewayConfigPath } from './env.js';
|
||||||
|
|
||||||
|
// Federation (step-ca client, enrollment, federation verbs) is only wired for
|
||||||
|
// tier 'federated' — CaService hard-requires STEP_CA_* at construction, which
|
||||||
|
// must not gate standalone/local boots (docker-compose.federated.yml: the
|
||||||
|
// federation profile "must not start in non-federated dev"). The gateway
|
||||||
|
// entrypoint loads env.ts before evaluating this module so dotenv-backed tier
|
||||||
|
// configuration is visible here.
|
||||||
|
const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'federated';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -50,10 +61,11 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
PreferencesModule,
|
PreferencesModule,
|
||||||
CommandsModule,
|
CommandsModule,
|
||||||
GCModule,
|
GCModule,
|
||||||
|
HarnessModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
FederationModule,
|
...(federationEnabled ? [FederationModule] : []),
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,920 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { Test, type TestingModule } from '@nestjs/testing';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
|
||||||
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CommandsModule } from '../commands/commands.module.js';
|
||||||
|
import { HarnessModule } from '../harness/harness.module.js';
|
||||||
|
import { ChatModule } from './chat.module.js';
|
||||||
|
import { ChatGateway } from './chat.gateway.js';
|
||||||
|
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||||
|
import {
|
||||||
|
HARNESS_CONVERSATION_SERVICE,
|
||||||
|
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
HARNESS_REGISTRY,
|
||||||
|
type HarnessConversationServiceBinding,
|
||||||
|
} from '../harness/harness.tokens.js';
|
||||||
|
import { ChatRuntimeRouter } from './chat-runtime-router.js';
|
||||||
|
import {
|
||||||
|
ChatRuntimeUnavailableError,
|
||||||
|
ownConversation,
|
||||||
|
type ChatRuntime,
|
||||||
|
type ChatRuntimeMode,
|
||||||
|
type LegacyEmbeddedChatPort,
|
||||||
|
type LegacyRuntimeStream,
|
||||||
|
type LegacySessionPresentation,
|
||||||
|
type LegacySocketTurnLease,
|
||||||
|
type OwnedConversationContext,
|
||||||
|
} from './chat-runtime.js';
|
||||||
|
import { AppModule } from '../app.module.js';
|
||||||
|
import { ProviderService } from '../agent/provider.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, Step One (router). Proves the `ChatRuntimeRouter` resolves exactly one
|
||||||
|
* runtime by mode, fails closed at init when `pi-rpc` preconditions are unmet, and
|
||||||
|
* never downgrades `pi-rpc` to embedded execution. Red-first: the router is an
|
||||||
|
* unimplemented stub, so every behavioural assertion below fails until Step Three.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const embedded: ChatRuntime = { kind: 'embedded' };
|
||||||
|
const harness: ChatRuntime = { kind: 'harness' };
|
||||||
|
|
||||||
|
/** A structurally-complete, non-sentinel conversation service. Its methods are never invoked here. */
|
||||||
|
const boundConversationService = {
|
||||||
|
attach: () => Promise.reject(new Error('unused')),
|
||||||
|
detach: () => Promise.reject(new Error('unused')),
|
||||||
|
send: () => Promise.reject(new Error('unused')),
|
||||||
|
|
||||||
|
subscribeFrom: async function* () {
|
||||||
|
throw new Error('unused');
|
||||||
|
},
|
||||||
|
} as unknown as HarnessConversationService;
|
||||||
|
|
||||||
|
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
for (const id of adapterIds) {
|
||||||
|
registry.register({
|
||||||
|
id,
|
||||||
|
describe: () => Promise.reject(new Error('unused')),
|
||||||
|
catalog: () => Promise.reject(new Error('unused')),
|
||||||
|
create: () => Promise.reject(new Error('unused')),
|
||||||
|
resume: () => Promise.reject(new Error('unused')),
|
||||||
|
} as HarnessAdapter);
|
||||||
|
}
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRouter(
|
||||||
|
mode: ChatRuntimeMode,
|
||||||
|
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
|
||||||
|
): ChatRuntimeRouter {
|
||||||
|
return new ChatRuntimeRouter(registryWith(opts.adapters), opts.service, embedded, harness, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tear down a module that was deliberately driven to a fail-closed init.
|
||||||
|
* `NestApplicationContext.close()` re-awaits the module's `initializationPromise` before disposing
|
||||||
|
* (nest-application-context.js:127); when `init()` rejected, that await re-throws the SAME typed
|
||||||
|
* startup error, this time into teardown. Each caller here has already captured and asserted that
|
||||||
|
* exact `ChatRuntimeUnavailableError` via `initError`, so the re-throw is expected teardown noise —
|
||||||
|
* swallow ONLY that error, and surface anything else so a genuine teardown fault still fails loudly.
|
||||||
|
*/
|
||||||
|
async function closeIgnoringFailedInit(moduleRef: TestingModule): Promise<void> {
|
||||||
|
await moduleRef.close().catch((err: unknown) => {
|
||||||
|
if (err instanceof ChatRuntimeUnavailableError) return;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatRuntimeRouter', () => {
|
||||||
|
it('resolves only the harness runtime in pi-rpc mode when pi adapter and conversation service are present', () => {
|
||||||
|
const router = buildRouter('pi-rpc', {
|
||||||
|
adapters: ['pi'],
|
||||||
|
service: boundConversationService,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => router.onModuleInit()).not.toThrow();
|
||||||
|
expect(router.active).toBe(harness);
|
||||||
|
expect(router.active.kind).toBe('harness');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves only the embedded runtime in legacy mode and skips the pi preconditions', () => {
|
||||||
|
// Empty registry + unavailable service: legacy must ignore both and still start.
|
||||||
|
const router = buildRouter('legacy', {
|
||||||
|
adapters: [],
|
||||||
|
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => router.onModuleInit()).not.toThrow();
|
||||||
|
expect(router.active).toBe(embedded);
|
||||||
|
expect(router.active.kind).toBe('embedded');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed at init when pi-rpc mode has no registered pi adapter', () => {
|
||||||
|
const router = buildRouter('pi-rpc', {
|
||||||
|
adapters: [],
|
||||||
|
service: boundConversationService,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
|
||||||
|
try {
|
||||||
|
router.onModuleInit();
|
||||||
|
expect.unreachable('onModuleInit must throw when the pi adapter is absent');
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||||
|
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
|
||||||
|
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed at init when pi-rpc mode has the unavailable conversation-service sentinel', () => {
|
||||||
|
const router = buildRouter('pi-rpc', {
|
||||||
|
adapters: ['pi'],
|
||||||
|
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
router.onModuleInit();
|
||||||
|
expect.unreachable('onModuleInit must throw when the conversation service is unbound');
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||||
|
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
|
||||||
|
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never falls back to embedded execution when pi-rpc preconditions are unmet', () => {
|
||||||
|
const router = buildRouter('pi-rpc', {
|
||||||
|
adapters: [],
|
||||||
|
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
|
||||||
|
// A failed pi-rpc init must not silently expose the embedded runtime.
|
||||||
|
expect(() => router.active).toThrow();
|
||||||
|
let leaked: ChatRuntime | undefined;
|
||||||
|
try {
|
||||||
|
leaked = router.active;
|
||||||
|
} catch {
|
||||||
|
leaked = undefined;
|
||||||
|
}
|
||||||
|
expect(leaked).not.toBe(embedded);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes only fixed, browser-safe failure text (no raw provider or exception detail)', () => {
|
||||||
|
const router = buildRouter('pi-rpc', {
|
||||||
|
adapters: [],
|
||||||
|
service: boundConversationService,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
router.onModuleInit();
|
||||||
|
expect.unreachable('onModuleInit must throw');
|
||||||
|
} catch (err) {
|
||||||
|
const message = (err as ChatRuntimeUnavailableError).message;
|
||||||
|
expect(message).toBe(
|
||||||
|
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
|
||||||
|
);
|
||||||
|
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, Step Three — legacy port operations fail closed under pi-rpc (direct valid-input).
|
||||||
|
*
|
||||||
|
* The unit suite above constructs the router but never invokes a legacy port operation, so the
|
||||||
|
* six per-operation inner `if (this.mode === 'pi-rpc')` guards are unexercised — a mutation that
|
||||||
|
* deletes one of them SURVIVES for lack of a test that drives that operation. This group closes
|
||||||
|
* that gap the right way: it drives each of the six operations DIRECTLY, in pi-rpc mode, with a
|
||||||
|
* valid branded {@link OwnedConversationContext} and valid input, against a recording embedded
|
||||||
|
* stub whose method returns a distinguishable `ok:true` success and increments a per-op counter.
|
||||||
|
*
|
||||||
|
* For each operation:
|
||||||
|
* - pi-rpc test asserts the exact frozen `{ ok:false, code:'runtime_unsupported', retryable:false }`
|
||||||
|
* result AND that the embedded stub was touched zero times (no effects);
|
||||||
|
* - the paired legacy test proves that same stub method IS reached and returns its distinguishable
|
||||||
|
* success when the mode does not refuse — so the pi-rpc zero-invocation assertion is meaningful,
|
||||||
|
* not vacuously true because the stub could never be called.
|
||||||
|
*
|
||||||
|
* Deleting ONLY one operation's inner guard makes THAT operation's pi-rpc test behaviorally RED
|
||||||
|
* (the router returns the embedded `ok:true` value and records the call), with every outer guard
|
||||||
|
* and the other five inner guards intact. `next` is untouched; nothing here changes production.
|
||||||
|
*/
|
||||||
|
describe('ChatRuntimeRouter — legacy port ops fail closed under pi-rpc (Task Five, Step Three)', () => {
|
||||||
|
const RUNTIME_UNSUPPORTED = {
|
||||||
|
ok: false,
|
||||||
|
code: 'runtime_unsupported',
|
||||||
|
retryable: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const PRESENTATION: LegacySessionPresentation = {
|
||||||
|
provider: 'embedded-provider',
|
||||||
|
modelId: 'embedded-model',
|
||||||
|
thinkingLevel: 'low',
|
||||||
|
availableThinkingLevels: ['low', 'high'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream: LegacyRuntimeStream = {
|
||||||
|
channelId: 'websocket:test-socket',
|
||||||
|
onEvent: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = (): OwnedConversationContext =>
|
||||||
|
ownConversation('conversation-1', { userId: 'user-1', tenantId: 'tenant-1' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-operation invocation counters with declared keys (not an index signature) so each
|
||||||
|
* `calls.<op>` is definitely `number` under `noUncheckedIndexedAccess`.
|
||||||
|
*/
|
||||||
|
type LegacyPortCallCounts = {
|
||||||
|
completeLegacyRestTurn: number;
|
||||||
|
prepareLegacySocketTurn: number;
|
||||||
|
setLegacyThinking: number;
|
||||||
|
abortLegacyTurn: number;
|
||||||
|
applyLegacyModelOverride: number;
|
||||||
|
readLegacySessionPresentation: number;
|
||||||
|
dispatchVerifiedDiscordIngress: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An embedded port that records every invocation and returns a distinguishable `ok:true`
|
||||||
|
* value per operation. If a router op reaches it (its guard removed), both the recorded call
|
||||||
|
* count and the returned `ok:true` value diverge from the frozen `runtime_unsupported` result.
|
||||||
|
*/
|
||||||
|
function recordingEmbeddedPort(): {
|
||||||
|
port: ChatRuntime & LegacyEmbeddedChatPort;
|
||||||
|
calls: LegacyPortCallCounts;
|
||||||
|
} {
|
||||||
|
const calls: LegacyPortCallCounts = {
|
||||||
|
completeLegacyRestTurn: 0,
|
||||||
|
prepareLegacySocketTurn: 0,
|
||||||
|
setLegacyThinking: 0,
|
||||||
|
abortLegacyTurn: 0,
|
||||||
|
applyLegacyModelOverride: 0,
|
||||||
|
readLegacySessionPresentation: 0,
|
||||||
|
dispatchVerifiedDiscordIngress: 0,
|
||||||
|
};
|
||||||
|
const lease: LegacySocketTurnLease = {
|
||||||
|
presentation: PRESENTATION,
|
||||||
|
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
|
||||||
|
dispose: () => Promise.resolve(),
|
||||||
|
};
|
||||||
|
const port: ChatRuntime & LegacyEmbeddedChatPort = {
|
||||||
|
kind: 'embedded',
|
||||||
|
completeLegacyRestTurn: () => {
|
||||||
|
calls.completeLegacyRestTurn += 1;
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
value: { text: 'EMBEDDED-REST', presentation: PRESENTATION },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
prepareLegacySocketTurn: () => {
|
||||||
|
calls.prepareLegacySocketTurn += 1;
|
||||||
|
return Promise.resolve({ ok: true, value: lease });
|
||||||
|
},
|
||||||
|
setLegacyThinking: () => {
|
||||||
|
calls.setLegacyThinking += 1;
|
||||||
|
return { ok: true, value: PRESENTATION };
|
||||||
|
},
|
||||||
|
abortLegacyTurn: () => {
|
||||||
|
calls.abortLegacyTurn += 1;
|
||||||
|
return Promise.resolve({ ok: true, value: undefined });
|
||||||
|
},
|
||||||
|
applyLegacyModelOverride: () => {
|
||||||
|
calls.applyLegacyModelOverride += 1;
|
||||||
|
return { ok: true, value: PRESENTATION };
|
||||||
|
},
|
||||||
|
readLegacySessionPresentation: () => {
|
||||||
|
calls.readLegacySessionPresentation += 1;
|
||||||
|
return { ok: true, value: PRESENTATION };
|
||||||
|
},
|
||||||
|
dispatchVerifiedDiscordIngress: () => {
|
||||||
|
calls.dispatchVerifiedDiscordIngress += 1;
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
presentation: PRESENTATION,
|
||||||
|
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
|
||||||
|
dispose: () => Promise.resolve(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { port, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function piRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
|
||||||
|
return new ChatRuntimeRouter(
|
||||||
|
registryWith(['pi']),
|
||||||
|
boundConversationService,
|
||||||
|
port,
|
||||||
|
harness,
|
||||||
|
'pi-rpc',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function legacyRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
|
||||||
|
return new ChatRuntimeRouter(
|
||||||
|
registryWith([]),
|
||||||
|
boundConversationService,
|
||||||
|
port,
|
||||||
|
harness,
|
||||||
|
'legacy',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// completeLegacyRestTurn ---------------------------------------------------
|
||||||
|
it('completeLegacyRestTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = await piRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
|
||||||
|
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||||
|
expect(calls.completeLegacyRestTurn).toBe(0);
|
||||||
|
});
|
||||||
|
it('completeLegacyRestTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = await legacyRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(calls.completeLegacyRestTurn).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// prepareLegacySocketTurn --------------------------------------------------
|
||||||
|
it('prepareLegacySocketTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = await piRouter(port).prepareLegacySocketTurn(
|
||||||
|
ctx(),
|
||||||
|
{ content: 'hello' },
|
||||||
|
stream,
|
||||||
|
);
|
||||||
|
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||||
|
expect(calls.prepareLegacySocketTurn).toBe(0);
|
||||||
|
});
|
||||||
|
it('prepareLegacySocketTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = await legacyRouter(port).prepareLegacySocketTurn(
|
||||||
|
ctx(),
|
||||||
|
{ content: 'hello' },
|
||||||
|
stream,
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(calls.prepareLegacySocketTurn).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// setLegacyThinking (sync) -------------------------------------------------
|
||||||
|
it('setLegacyThinking refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = piRouter(port).setLegacyThinking(ctx(), 'high');
|
||||||
|
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||||
|
expect(calls.setLegacyThinking).toBe(0);
|
||||||
|
});
|
||||||
|
it('setLegacyThinking delegates to embedded under legacy (guard is the sole gate)', () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = legacyRouter(port).setLegacyThinking(ctx(), 'high');
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(calls.setLegacyThinking).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// abortLegacyTurn ----------------------------------------------------------
|
||||||
|
it('abortLegacyTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = await piRouter(port).abortLegacyTurn(ctx());
|
||||||
|
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||||
|
expect(calls.abortLegacyTurn).toBe(0);
|
||||||
|
});
|
||||||
|
it('abortLegacyTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = await legacyRouter(port).abortLegacyTurn(ctx());
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(calls.abortLegacyTurn).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// applyLegacyModelOverride (sync) ------------------------------------------
|
||||||
|
it('applyLegacyModelOverride refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = piRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
|
||||||
|
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||||
|
expect(calls.applyLegacyModelOverride).toBe(0);
|
||||||
|
});
|
||||||
|
it('applyLegacyModelOverride delegates to embedded under legacy (guard is the sole gate)', () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = legacyRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(calls.applyLegacyModelOverride).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// readLegacySessionPresentation (sync) -------------------------------------
|
||||||
|
it('readLegacySessionPresentation refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = piRouter(port).readLegacySessionPresentation(ctx());
|
||||||
|
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||||
|
expect(calls.readLegacySessionPresentation).toBe(0);
|
||||||
|
});
|
||||||
|
it('readLegacySessionPresentation delegates to embedded under legacy (guard is the sole gate)', () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = legacyRouter(port).readLegacySessionPresentation(ctx());
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(calls.readLegacySessionPresentation).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// dispatchVerifiedDiscordIngress delegates in BOTH modes (embedded-only, no guard) ---------
|
||||||
|
it('dispatchVerifiedDiscordIngress delegates to embedded under pi-rpc (embedded-only, no mode guard)', async () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const discordCtx = ctx() as unknown as Parameters<
|
||||||
|
ChatRuntimeRouter['dispatchVerifiedDiscordIngress']
|
||||||
|
>[0];
|
||||||
|
const result = await piRouter(port).dispatchVerifiedDiscordIngress(discordCtx, stream);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(calls.dispatchVerifiedDiscordIngress).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, Step Two — group 1 (real Nest module-graph readiness).
|
||||||
|
*
|
||||||
|
* The unit suite above constructs the router directly. This group drives the SAME contract
|
||||||
|
* through a real NestJS graph: it imports the production `HarnessModule` (the proven-booting
|
||||||
|
* idiom from harness.controller.spec.ts) so the router resolves the REAL, empty `HarnessRegistry`
|
||||||
|
* via the real `HARNESS_REGISTRY` token, then runs the router's `OnModuleInit` through the Nest
|
||||||
|
* lifecycle (`moduleRef.init()`). Red-first: the router is an unimplemented stub whose
|
||||||
|
* `onModuleInit` throws a generic Error, so:
|
||||||
|
* - readiness cases fail because the graph never comes up (init rejects), and
|
||||||
|
* - fail-closed cases fail because a generic stub throw is NOT the SPECIFIC typed
|
||||||
|
* `ChatRuntimeUnavailableError` (reason/code) the contract demands — a stub that
|
||||||
|
* "throws anything" cannot mask these greens.
|
||||||
|
* The router is NOT wired into a production module yet, so it is provided here via a factory
|
||||||
|
* over the real registry token. Importing the real `ChatModule` bare is deliberately avoided:
|
||||||
|
* it injects `AgentService` without importing `AgentModule`, so its graph fails to RESOLVE — a
|
||||||
|
* collection/DI error, not a behavioural red. `next` is untouched; nothing here implements the router.
|
||||||
|
*/
|
||||||
|
describe('ChatRuntimeRouter — real Nest module-graph readiness (Task Five, Step Two group 1)', () => {
|
||||||
|
async function bootRouterGraph(
|
||||||
|
mode: ChatRuntimeMode,
|
||||||
|
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
|
||||||
|
) {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [HarnessModule],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: ChatRuntimeRouter,
|
||||||
|
useFactory: (registry: HarnessRegistry) =>
|
||||||
|
new ChatRuntimeRouter(registry, opts.service, embedded, harness, mode),
|
||||||
|
inject: [HARNESS_REGISTRY],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
// The imported HarnessModule's controllers reference AuthGuard (an HTTP-only concern,
|
||||||
|
// never exercised here); stub it so the graph resolves. The registry is NOT overridden —
|
||||||
|
// group 1 asserts against the genuine production HarnessRegistry.
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
// Resolve the production registry singleton and register the requested adapters ON IT, so
|
||||||
|
// the router (which injects the same singleton) sees them when its lifecycle hook runs.
|
||||||
|
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
|
||||||
|
for (const id of opts.adapters) {
|
||||||
|
registry.register({
|
||||||
|
id,
|
||||||
|
describe: () => Promise.reject(new Error('unused')),
|
||||||
|
catalog: () => Promise.reject(new Error('unused')),
|
||||||
|
create: () => Promise.reject(new Error('unused')),
|
||||||
|
resume: () => Promise.reject(new Error('unused')),
|
||||||
|
} as HarnessAdapter);
|
||||||
|
}
|
||||||
|
return moduleRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture an init rejection without letting a resolved init masquerade as success.
|
||||||
|
const initError = (moduleRef: { init(): Promise<unknown> }): Promise<unknown> =>
|
||||||
|
moduleRef.init().then(
|
||||||
|
() => new Error('module init resolved but the contract requires it to reject'),
|
||||||
|
(err: unknown) => err,
|
||||||
|
);
|
||||||
|
|
||||||
|
it('brings the graph up and resolves only the harness runtime in pi-rpc mode (pi adapter + bound service)', async () => {
|
||||||
|
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||||
|
adapters: ['pi'],
|
||||||
|
service: boundConversationService,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await moduleRef.init();
|
||||||
|
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||||
|
expect(router.active).toBe(harness);
|
||||||
|
expect(router.active.kind).toBe('harness');
|
||||||
|
} finally {
|
||||||
|
await moduleRef.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('brings the graph up in legacy mode over the REAL empty HarnessRegistry and resolves only the embedded runtime', async () => {
|
||||||
|
const moduleRef = await bootRouterGraph('legacy', {
|
||||||
|
adapters: [],
|
||||||
|
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// Defense-in-depth: the production module wires the genuine registry, empty by default —
|
||||||
|
// guards against a test-double registry silently satisfying the readiness check.
|
||||||
|
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
|
||||||
|
expect(registry).toBeInstanceOf(HarnessRegistry);
|
||||||
|
expect(registry.list()).toHaveLength(0);
|
||||||
|
|
||||||
|
await moduleRef.init();
|
||||||
|
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||||
|
expect(router.active).toBe(embedded);
|
||||||
|
expect(router.active.kind).toBe('embedded');
|
||||||
|
} finally {
|
||||||
|
await moduleRef.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed at module init when pi-rpc mode has no registered pi adapter (specific typed error, not a stub throw)', async () => {
|
||||||
|
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||||
|
adapters: [],
|
||||||
|
service: boundConversationService,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const err = await initError(moduleRef);
|
||||||
|
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||||
|
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
|
||||||
|
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||||
|
} finally {
|
||||||
|
await closeIgnoringFailedInit(moduleRef);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed at module init when pi-rpc mode has the unavailable conversation-service sentinel', async () => {
|
||||||
|
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||||
|
adapters: ['pi'],
|
||||||
|
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const err = await initError(moduleRef);
|
||||||
|
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||||
|
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
|
||||||
|
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||||
|
} finally {
|
||||||
|
await closeIgnoringFailedInit(moduleRef);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces only fixed, browser-safe failure text when the graph fails closed (no stub/exception detail)', async () => {
|
||||||
|
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||||
|
adapters: [],
|
||||||
|
service: boundConversationService,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const err = await initError(moduleRef);
|
||||||
|
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||||
|
const message = (err as ChatRuntimeUnavailableError).message;
|
||||||
|
expect(message).toBe(
|
||||||
|
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
|
||||||
|
);
|
||||||
|
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(|not implemented/);
|
||||||
|
} finally {
|
||||||
|
await closeIgnoringFailedInit(moduleRef);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, Step Two — group 1b (production ChatModule wiring, declaration proof).
|
||||||
|
*
|
||||||
|
* Correction #1 (Scrappy fe3e02) asked for a red that imports the real `ChatModule` and calls
|
||||||
|
* `module.init()`. Investigated and found impractical/masking-prone: `ChatModule` provides
|
||||||
|
* `ChatGateway`, whose 10-argument constructor injects app-global providers (AgentService, AUTH,
|
||||||
|
* BRAIN, RoutingEngineService) plus the Commands/GC/Mcp/Reload subsystems across a forwardRef
|
||||||
|
* cycle. Booting it in isolation is a full-app integration boot — "override only unrelated
|
||||||
|
* dependencies" balloons into faking ~4 subsystems, and `overrideProvider` cannot even grant the
|
||||||
|
* cross-module export-scope visibility ChatGateway needs (probe: `ChatGateway` unresolved at
|
||||||
|
* `CommandExecutorService`). That is exactly the STOP-and-return branch of the directive.
|
||||||
|
*
|
||||||
|
* The faithful, unmaskable cover instead of a fragile boot: read the PRODUCTION `ChatModule`'s own
|
||||||
|
* Nest `@Module` metadata to prove it DECLARES the exclusive router provider and imports the real
|
||||||
|
* `HarnessModule` (the genuine registry source). This inspects the actual module object — not
|
||||||
|
* source text, not a test factory — so nothing can mask it. Group 1 above separately proves the
|
||||||
|
* router RESOLVES against the real, empty `HarnessRegistry` through the Nest lifecycle; the union
|
||||||
|
* of the two covers "the router is wired through ChatModule to the real registry" without the
|
||||||
|
* impractical single-graph boot. RED today (ChatModule provides only ChatGateway and imports only
|
||||||
|
* CommandsModule); GREEN once Step Three registers the router and imports HarnessModule.
|
||||||
|
*/
|
||||||
|
describe('ChatModule production wiring (Task Five, Step Two group 1b — declaration proof)', () => {
|
||||||
|
// Unwrap a forwardRef(() => Module) import to the module it references; pass others through.
|
||||||
|
const resolveImport = (imp: unknown): unknown =>
|
||||||
|
imp &&
|
||||||
|
typeof imp === 'object' &&
|
||||||
|
typeof (imp as { forwardRef?: unknown }).forwardRef === 'function'
|
||||||
|
? (imp as { forwardRef: () => unknown }).forwardRef()
|
||||||
|
: imp;
|
||||||
|
|
||||||
|
// A provider entry is either a class (shorthand) or a { provide, ... } object; take its token.
|
||||||
|
const providerToken = (provider: unknown): unknown =>
|
||||||
|
typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide;
|
||||||
|
|
||||||
|
it('declares the exclusive ChatRuntimeRouter as a provider on the production ChatModule', () => {
|
||||||
|
const providers: unknown[] = Reflect.getMetadata('providers', ChatModule) ?? [];
|
||||||
|
expect(providers.map(providerToken)).toContain(ChatRuntimeRouter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports the real HarnessModule into the production ChatModule (registry source, not a test double)', () => {
|
||||||
|
const imports: unknown[] = Reflect.getMetadata('imports', ChatModule) ?? [];
|
||||||
|
expect(imports.map(resolveImport)).toContain(HarnessModule);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, Step Two — group 1c (bounded real-`ChatModule` boot).
|
||||||
|
*
|
||||||
|
* Scrappy adjudication d67d2b (option c): boot the ACTUAL production `ChatModule` as the SUT and
|
||||||
|
* assert the exclusive router resolves THROUGH it — the single-graph proof group 1 (router over the
|
||||||
|
* real registry) and group 1b (production-module metadata) each cover only a half of. The heavy,
|
||||||
|
* UNRELATED cycle is the only thing bounded away, per the established isolation pattern in
|
||||||
|
* `apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts`:
|
||||||
|
* - `CommandsModule` (drags the Commands <-> Reload <-> Chat forwardRef cycle plus GC/Mcp/queue)
|
||||||
|
* is replaced wholesale with an empty module via `.overrideModule(...).useModule(...)`;
|
||||||
|
* - `ChatGateway` (10-arg constructor, an HTTP/socket concern never exercised here) is replaced
|
||||||
|
* with an inert value;
|
||||||
|
* - the sole legacy-controller dependency, `AgentService`, is supplied by a tiny `@Global()` stub;
|
||||||
|
* - the HTTP-only `AuthGuard` is stubbed.
|
||||||
|
* Nothing about the router, `HarnessModule`, the registry, or the conversation-service binding is
|
||||||
|
* faked in the production-legacy case — those are retrieved from the REAL `ChatModule` graph. Mode
|
||||||
|
* is driven only through the production `CHAT_HARNESS_RUNTIME` env contract (`resolveChatRuntimeMode`).
|
||||||
|
*
|
||||||
|
* Red-first: today `ChatModule` neither imports `HarnessModule` nor provides `ChatRuntimeRouter`, so
|
||||||
|
* the booted graph contains no router/registry/conversation-service tokens. `init()` may resolve
|
||||||
|
* (there is no router lifecycle hook yet to reject), so every case fails on the MISSING actual
|
||||||
|
* router/registry/service wiring — not on unrelated DI, which is bounded away. GREEN at Step Three
|
||||||
|
* once `ChatModule` imports `HarnessModule`, provides the exclusive router, and binds the
|
||||||
|
* conversation-service token (defaulting to the unavailable sentinel).
|
||||||
|
*/
|
||||||
|
describe('ChatModule bounded real boot (Task Five, Step Two group 1c)', () => {
|
||||||
|
// The unrelated heavy cycle, replaced wholesale — not stubbed provider-by-provider.
|
||||||
|
@Module({})
|
||||||
|
class EmptyCommandsModule {}
|
||||||
|
|
||||||
|
// The ONLY genuine legacy dependency of the real ChatController, supplied inertly and globally so
|
||||||
|
// the pre-refactor controller instantiates without dragging AgentModule into the graph.
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [{ provide: AgentService, useValue: {} }],
|
||||||
|
exports: [AgentService],
|
||||||
|
})
|
||||||
|
class LegacyControllerDepsModule {}
|
||||||
|
|
||||||
|
const ORIGINAL_RUNTIME_ENV = process.env['CHAT_HARNESS_RUNTIME'];
|
||||||
|
afterEach(() => {
|
||||||
|
if (ORIGINAL_RUNTIME_ENV === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
|
||||||
|
else process.env['CHAT_HARNESS_RUNTIME'] = ORIGINAL_RUNTIME_ENV;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot the real ChatModule with only the unrelated cycle bounded away. `mode` is set through the
|
||||||
|
* genuine production env contract before providers instantiate. The optional overrides replace
|
||||||
|
* the registry / conversation-service the router injects, exercising the pi-rpc precondition
|
||||||
|
* branches through the ACTUAL module (they are no-ops today because those tokens are not yet in
|
||||||
|
* the graph — which is exactly why the router-retrieval assertions go red).
|
||||||
|
*/
|
||||||
|
async function bootChatModule(
|
||||||
|
mode: ChatRuntimeMode,
|
||||||
|
overrides: {
|
||||||
|
registryAdapters?: readonly string[];
|
||||||
|
conversationService?: HarnessConversationServiceBinding;
|
||||||
|
} = {},
|
||||||
|
): Promise<TestingModule> {
|
||||||
|
if (mode === 'pi-rpc') process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
else delete process.env['CHAT_HARNESS_RUNTIME'];
|
||||||
|
|
||||||
|
let builder = Test.createTestingModule({
|
||||||
|
imports: [LegacyControllerDepsModule, ChatModule],
|
||||||
|
})
|
||||||
|
.overrideModule(CommandsModule)
|
||||||
|
.useModule(EmptyCommandsModule)
|
||||||
|
.overrideProvider(ChatGateway)
|
||||||
|
.useValue({})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true });
|
||||||
|
|
||||||
|
if (overrides.registryAdapters) {
|
||||||
|
builder = builder
|
||||||
|
.overrideProvider(HARNESS_REGISTRY)
|
||||||
|
.useValue(registryWith(overrides.registryAdapters));
|
||||||
|
}
|
||||||
|
if (overrides.conversationService !== undefined) {
|
||||||
|
builder = builder
|
||||||
|
.overrideProvider(HARNESS_CONVERSATION_SERVICE)
|
||||||
|
.useValue(overrides.conversationService);
|
||||||
|
}
|
||||||
|
return builder.compile();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture an init rejection without letting a resolved init masquerade as success.
|
||||||
|
const initError = (moduleRef: TestingModule): Promise<unknown> =>
|
||||||
|
moduleRef.init().then(
|
||||||
|
() => new Error('module init resolved but the contract requires it to reject'),
|
||||||
|
(err: unknown) => err,
|
||||||
|
);
|
||||||
|
|
||||||
|
it('legacy mode: the actual router resolves the embedded runtime, the actual registry is empty, and the conversation-service token is the unavailable sentinel', async () => {
|
||||||
|
const moduleRef = await bootChatModule('legacy');
|
||||||
|
try {
|
||||||
|
await moduleRef.init();
|
||||||
|
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||||
|
expect(router.active.kind).toBe('embedded');
|
||||||
|
|
||||||
|
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
|
||||||
|
expect(registry).toBeInstanceOf(HarnessRegistry);
|
||||||
|
expect(registry.list()).toHaveLength(0);
|
||||||
|
|
||||||
|
const service = moduleRef.get<HarnessConversationServiceBinding>(
|
||||||
|
HARNESS_CONVERSATION_SERVICE,
|
||||||
|
{
|
||||||
|
strict: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(service).toBe(HARNESS_CONVERSATION_SERVICE_UNAVAILABLE);
|
||||||
|
} finally {
|
||||||
|
await moduleRef.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pi-rpc mode over the REAL empty registry fails closed at init with the typed adapter-unavailable error', async () => {
|
||||||
|
const moduleRef = await bootChatModule('pi-rpc');
|
||||||
|
try {
|
||||||
|
const err = await initError(moduleRef);
|
||||||
|
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||||
|
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
|
||||||
|
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||||
|
} finally {
|
||||||
|
await closeIgnoringFailedInit(moduleRef);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pi-rpc mode with a pi adapter present but the sentinel conversation service fails closed with the typed conversation-service-unavailable error', async () => {
|
||||||
|
const moduleRef = await bootChatModule('pi-rpc', {
|
||||||
|
registryAdapters: ['pi'],
|
||||||
|
conversationService: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const err = await initError(moduleRef);
|
||||||
|
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||||
|
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
|
||||||
|
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||||
|
} finally {
|
||||||
|
await closeIgnoringFailedInit(moduleRef);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pi-rpc mode with a pi adapter and a bound conversation service: the actual router selects the harness runtime', async () => {
|
||||||
|
const moduleRef = await bootChatModule('pi-rpc', {
|
||||||
|
registryAdapters: ['pi'],
|
||||||
|
conversationService: boundConversationService,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await moduleRef.init();
|
||||||
|
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||||
|
expect(router.active.kind).toBe('harness');
|
||||||
|
} finally {
|
||||||
|
await moduleRef.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, Step Two — group 2 (WHOLE production `AppModule` boot, legacy end-to-end wiring).
|
||||||
|
*
|
||||||
|
* The groups above bound away the heavy cycle to isolate the router. This group instead boots the
|
||||||
|
* ACTUAL production `AppModule` (the exact graph `main.ts` runs) in the default LEGACY chat-runtime
|
||||||
|
* mode, overriding ONLY the storage/network side-effect adapters so the boot is bounded and offline
|
||||||
|
* — never the chat/router/harness/reload/commands surface under test. The bounded fakes are exactly
|
||||||
|
* the disk/network leaves:
|
||||||
|
* - `ProviderService` (the #1 hang risk: its real `onModuleInit` starts an unref'd health-check
|
||||||
|
* `setInterval` and fetches Ollama over HTTP) → inert no-op instance;
|
||||||
|
* - `DB_HANDLE`/`DB` → a fake Drizzle-shaped handle that satisfies `runPgliteMigrations` (the local
|
||||||
|
* tier's `DatabaseModule.onModuleInit`) AND `DefaultRoutingRulesSeed.onModuleInit` (which reads a
|
||||||
|
* system-rule count — the fake reports rules already present so the seed insert is skipped),
|
||||||
|
* opening no real database;
|
||||||
|
* - `STORAGE_ADAPTER`/`MEMORY`/`MEMORY_ADAPTER`/`AUTH`/`BRAIN`/`LOG_SERVICE` → inert fakes so no
|
||||||
|
* storage/auth/log backend is contacted.
|
||||||
|
* Local tier (the repo's `mosaic.config.json`) already disables BullMQ/Redis and the queue handles;
|
||||||
|
* Discord/Telegram/MCP plugins are env-gated and disarmed by deleting their tokens. Nothing about the
|
||||||
|
* router, `ChatModule`, `HarnessModule`, or `ChatGateway` is faked — those come from the REAL graph.
|
||||||
|
*
|
||||||
|
* The boot+init MUST SUCCEED cleanly (proven by `beforeAll` completing and the ChatGateway test
|
||||||
|
* passing). Red-first: on this branch `ChatRuntimeRouter` is registered in NO module (ChatModule
|
||||||
|
* provides only ChatGateway), so `moduleRef.get(ChatRuntimeRouter)` throws `UnknownElementException`
|
||||||
|
* — a WIRING gap, NOT an init failure. That single retrieval is the intended behavioural red; it
|
||||||
|
* flips green once Step Three registers the exclusive router. The ChatGateway retrieval and its
|
||||||
|
* browser-facing method surface are asserted alongside and pass today, pinning that the boot itself
|
||||||
|
* is healthy so the router failure cannot be mistaken for a mis-shaped fake or an unbounded side
|
||||||
|
* effect.
|
||||||
|
*/
|
||||||
|
describe('AppModule production boot — legacy ChatRuntimeRouter wiring (Task Five, Step Two group 2)', () => {
|
||||||
|
// A Drizzle-shaped fake that satisfies both DB consumers reached during a local-tier init:
|
||||||
|
// • runPgliteMigrations(): reads handle.db.$client.exec + handle.db.execute(SELECT hashes);
|
||||||
|
// exec is a no-op and execute yields an empty ledger, so migration statements no-op through.
|
||||||
|
// • DefaultRoutingRulesSeed.seedDefaultRules(): db.select().from().where() must resolve to a
|
||||||
|
// row set — we report a non-zero system-rule count so the seeding INSERT branch is skipped.
|
||||||
|
const fakeDb = {
|
||||||
|
$client: { exec: async (): Promise<void> => {} },
|
||||||
|
execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }),
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: async (): Promise<Array<{ count: number }>> => [{ count: 1 }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
insert: () => ({ values: async (): Promise<void> => {} }),
|
||||||
|
};
|
||||||
|
const fakeDbHandle = { db: fakeDb, close: async (): Promise<void> => {} };
|
||||||
|
const fakeStorageAdapter = {
|
||||||
|
name: 'fake',
|
||||||
|
migrate: async (): Promise<void> => {},
|
||||||
|
close: async (): Promise<void> => {},
|
||||||
|
};
|
||||||
|
// Inert stand-in for the real ProviderService: no health-check interval, no Ollama fetch.
|
||||||
|
const fakeProviderService = {
|
||||||
|
onModuleInit: async (): Promise<void> => {},
|
||||||
|
onModuleDestroy: (): void => {},
|
||||||
|
getRegistry: () => ({
|
||||||
|
getAvailable: () => [],
|
||||||
|
getAll: () => [],
|
||||||
|
find: () => undefined,
|
||||||
|
}),
|
||||||
|
getDefaultModel: () => undefined,
|
||||||
|
listAvailableModels: () => [],
|
||||||
|
listProviders: () => [],
|
||||||
|
getAdapter: () => undefined,
|
||||||
|
getProvidersHealth: () => [],
|
||||||
|
};
|
||||||
|
const fakeBrain = { conversations: {}, agents: {} };
|
||||||
|
|
||||||
|
const BOOT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
let moduleRef: TestingModule;
|
||||||
|
let envSnapshot: Record<string, string | undefined>;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
envSnapshot = { ...process.env };
|
||||||
|
// Env hygiene: disarm the network-facing plugins/adapters and pin the legacy runtime mode.
|
||||||
|
delete process.env['DATABASE_URL'];
|
||||||
|
delete process.env['DISCORD_BOT_TOKEN'];
|
||||||
|
delete process.env['TELEGRAM_BOT_TOKEN'];
|
||||||
|
delete process.env['MCP_SERVERS'];
|
||||||
|
delete process.env['CHAT_HARNESS_RUNTIME']; // resolveChatRuntimeMode → 'legacy'
|
||||||
|
process.env['MOSAIC_STORAGE_TIER'] = 'local';
|
||||||
|
|
||||||
|
moduleRef = await Test.createTestingModule({ imports: [AppModule] })
|
||||||
|
// Storage/network side-effect adapters ONLY — never the router/chat/harness surface under test.
|
||||||
|
.overrideProvider('DB_HANDLE')
|
||||||
|
.useValue(fakeDbHandle)
|
||||||
|
.overrideProvider('DB')
|
||||||
|
.useValue(fakeDb)
|
||||||
|
.overrideProvider('STORAGE_ADAPTER')
|
||||||
|
.useValue(fakeStorageAdapter)
|
||||||
|
.overrideProvider('AUTH')
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider('BRAIN')
|
||||||
|
.useValue(fakeBrain)
|
||||||
|
.overrideProvider('LOG_SERVICE')
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider('MEMORY')
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider('MEMORY_ADAPTER')
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(ProviderService)
|
||||||
|
.useValue(fakeProviderService)
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
// The boot itself MUST succeed cleanly — a rejection here is a bounding failure, not the red.
|
||||||
|
await moduleRef.init();
|
||||||
|
}, BOOT_TIMEOUT_MS);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (moduleRef) await moduleRef.close();
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (!(key in envSnapshot)) delete process.env[key];
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(envSnapshot)) {
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Passes TODAY: the real ChatGateway is provided by the real ChatModule and its browser-facing
|
||||||
|
// surface exists. This pins that the whole-AppModule boot came up healthy, so the router failure
|
||||||
|
// below is unambiguously a wiring gap and not a mis-shaped fake or an unbounded side effect.
|
||||||
|
it('boots the whole AppModule and exposes the real ChatGateway with its browser-facing methods', () => {
|
||||||
|
const gateway = moduleRef.get(ChatGateway, { strict: false });
|
||||||
|
expect(typeof gateway.broadcastReload).toBe('function');
|
||||||
|
expect(typeof gateway.getModelOverride).toBe('function');
|
||||||
|
expect(typeof gateway.setModelOverride).toBe('function');
|
||||||
|
expect(typeof gateway.broadcastSessionInfo).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
// RED TODAY: ChatRuntimeRouter is registered in no module on this branch, so this retrieval throws
|
||||||
|
// UnknownElementException — the intended red-first wiring failure. GREEN once Step Three registers
|
||||||
|
// the exclusive router in the production graph, where legacy mode resolves the embedded runtime.
|
||||||
|
it('resolves the exclusive ChatRuntimeRouter to the embedded runtime in legacy mode', () => {
|
||||||
|
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||||
|
expect(router.active.kind).toBe('embedded');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||||
|
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||||
|
import {
|
||||||
|
isHarnessConversationServiceAvailable,
|
||||||
|
type HarnessConversationServiceBinding,
|
||||||
|
} from '../harness/harness.tokens.js';
|
||||||
|
import type {
|
||||||
|
ChatRuntime,
|
||||||
|
ChatRuntimeMode,
|
||||||
|
LegacyBrowserMessagePayload,
|
||||||
|
LegacyEmbeddedChatPort,
|
||||||
|
LegacyRuntimeResult,
|
||||||
|
LegacyRuntimeStream,
|
||||||
|
LegacySessionPresentation,
|
||||||
|
LegacySocketTurnLease,
|
||||||
|
OwnedConversationContext,
|
||||||
|
VerifiedDiscordIngressContext,
|
||||||
|
VerifiedDiscordTurnLease,
|
||||||
|
} from './chat-runtime.js';
|
||||||
|
import { ChatRuntimeUnavailableError, resolveChatRuntimeMode } from './chat-runtime.js';
|
||||||
|
|
||||||
|
/** The fixed fail-closed result for a legacy browser operation issued under `pi-rpc`. */
|
||||||
|
const RUNTIME_UNSUPPORTED = {
|
||||||
|
ok: false as const,
|
||||||
|
code: 'runtime_unsupported' as const,
|
||||||
|
retryable: false as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the one live {@link ChatRuntime} for this process and enforces the
|
||||||
|
* `pi-rpc` readiness preconditions at module init — before the gateway accepts
|
||||||
|
* traffic. It never falls back from `pi-rpc` to embedded execution: an unmet
|
||||||
|
* `pi-rpc` precondition is a typed startup failure ({@link ChatRuntimeUnavailableError}),
|
||||||
|
* and until `onModuleInit` selects a runtime, {@link active} throws rather than
|
||||||
|
* exposing any runtime — a failed `pi-rpc` init can never leak the embedded one.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ChatRuntimeRouter implements OnModuleInit, LegacyEmbeddedChatPort {
|
||||||
|
private readonly mode: ChatRuntimeMode;
|
||||||
|
|
||||||
|
/** The single resolved runtime. Undefined until a successful `onModuleInit`. */
|
||||||
|
private resolved: ChatRuntime | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly harnessRegistry: HarnessRegistry,
|
||||||
|
private readonly conversationService: HarnessConversationServiceBinding,
|
||||||
|
private readonly embedded: ChatRuntime,
|
||||||
|
private readonly harness: ChatRuntime,
|
||||||
|
mode: ChatRuntimeMode = resolveChatRuntimeMode(),
|
||||||
|
) {
|
||||||
|
this.mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
if (this.mode === 'legacy') {
|
||||||
|
// Legacy ignores the pi-rpc preconditions entirely and always runs embedded.
|
||||||
|
this.resolved = this.embedded;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// pi-rpc: both preconditions are hard startup failures, checked in a fixed order.
|
||||||
|
if (!this.harnessRegistry.has('pi')) {
|
||||||
|
this.resolved = undefined;
|
||||||
|
throw new ChatRuntimeUnavailableError('adapter_unavailable');
|
||||||
|
}
|
||||||
|
if (!isHarnessConversationServiceAvailable(this.conversationService)) {
|
||||||
|
this.resolved = undefined;
|
||||||
|
throw new ChatRuntimeUnavailableError('conversation_service_unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resolved = this.harness;
|
||||||
|
}
|
||||||
|
|
||||||
|
get active(): ChatRuntime {
|
||||||
|
if (this.resolved === undefined) {
|
||||||
|
// Reached only if init has not run or failed closed; never expose a runtime here.
|
||||||
|
throw new Error('The chat runtime is not available: startup did not resolve a runtime.');
|
||||||
|
}
|
||||||
|
return this.resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The process-wide mode, available before {@link onModuleInit}. Production handlers read
|
||||||
|
* this to fail a legacy browser turn closed under `pi-rpc` *before* parsing the payload as
|
||||||
|
* either browser-legacy input or a Discord envelope — never to branch into a fallback.
|
||||||
|
*/
|
||||||
|
get runtimeMode(): ChatRuntimeMode {
|
||||||
|
return this.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The embedded runtime narrowed to its port. Only reached on the legacy path (and for the
|
||||||
|
* verified-Discord op in both modes), where the injected runtime is always a real
|
||||||
|
* `EmbeddedChatRuntime`. The router spec constructs the router with a bare `{ kind }` stub
|
||||||
|
* but never invokes a port op, so this narrowing is never exercised against the stub.
|
||||||
|
*/
|
||||||
|
private get embeddedPort(): LegacyEmbeddedChatPort {
|
||||||
|
return this.embedded as unknown as LegacyEmbeddedChatPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LegacyEmbeddedChatPort: legacy browser operations fail closed under pi-rpc ---
|
||||||
|
|
||||||
|
completeLegacyRestTurn(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
input: Readonly<{ content: string }>,
|
||||||
|
): Promise<
|
||||||
|
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
|
||||||
|
> {
|
||||||
|
if (this.mode === 'pi-rpc') {
|
||||||
|
return Promise.resolve(RUNTIME_UNSUPPORTED);
|
||||||
|
}
|
||||||
|
return this.embeddedPort.completeLegacyRestTurn(context, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareLegacySocketTurn(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
input: LegacyBrowserMessagePayload,
|
||||||
|
stream: LegacyRuntimeStream,
|
||||||
|
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
|
||||||
|
if (this.mode === 'pi-rpc') {
|
||||||
|
return Promise.resolve(RUNTIME_UNSUPPORTED);
|
||||||
|
}
|
||||||
|
return this.embeddedPort.prepareLegacySocketTurn(context, input, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLegacyThinking(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
level: string,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||||
|
if (this.mode === 'pi-rpc') {
|
||||||
|
return RUNTIME_UNSUPPORTED;
|
||||||
|
}
|
||||||
|
return this.embeddedPort.setLegacyThinking(context, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
|
||||||
|
if (this.mode === 'pi-rpc') {
|
||||||
|
return Promise.resolve(RUNTIME_UNSUPPORTED);
|
||||||
|
}
|
||||||
|
return this.embeddedPort.abortLegacyTurn(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyLegacyModelOverride(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
modelId: string,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||||
|
if (this.mode === 'pi-rpc') {
|
||||||
|
return RUNTIME_UNSUPPORTED;
|
||||||
|
}
|
||||||
|
return this.embeddedPort.applyLegacyModelOverride(context, modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
readLegacySessionPresentation(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||||
|
if (this.mode === 'pi-rpc') {
|
||||||
|
return RUNTIME_UNSUPPORTED;
|
||||||
|
}
|
||||||
|
return this.embeddedPort.readLegacySessionPresentation(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verified Discord ingress bypasses browser mode: it is embedded-only in BOTH modes and
|
||||||
|
* never reaches the harness or routing-engine selection. It is reached only through a
|
||||||
|
* {@link VerifiedDiscordIngressContext}, which exists only after every ingress check.
|
||||||
|
*/
|
||||||
|
dispatchVerifiedDiscordIngress(
|
||||||
|
context: VerifiedDiscordIngressContext,
|
||||||
|
stream: LegacyRuntimeStream,
|
||||||
|
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
|
||||||
|
return this.embeddedPort.dispatchVerifiedDiscordIngress(context, stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import type { ChannelAttachmentDto, RoutingDecisionInfo } from '@mosaicstack/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single chat execution strategy resolved by {@link ChatRuntimeRouter}.
|
||||||
|
*
|
||||||
|
* Exactly one runtime is live per process. There is no union that lets a
|
||||||
|
* `pi-rpc` deployment silently fall back to embedded execution: an unmet
|
||||||
|
* `pi-rpc` precondition is a typed startup failure, never a downgrade.
|
||||||
|
*/
|
||||||
|
export type ChatRuntimeMode = 'legacy' | 'pi-rpc';
|
||||||
|
|
||||||
|
export type ChatRuntimeKind = 'embedded' | 'harness';
|
||||||
|
|
||||||
|
/** The resolved runtime. Slice Zero exposes only its immutable {@link ChatRuntimeKind}. */
|
||||||
|
export interface ChatRuntime {
|
||||||
|
readonly kind: ChatRuntimeKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why the `pi-rpc` runtime could not be made ready. Both are hard startup failures. */
|
||||||
|
export type ChatRuntimeUnavailableReason =
|
||||||
|
| 'adapter_unavailable'
|
||||||
|
| 'conversation_service_unavailable';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised at module init when `pi-rpc` mode is selected but its preconditions are
|
||||||
|
* unmet. Carries only fixed, browser-safe text — never a raw exception message,
|
||||||
|
* stack, or provider detail — and reports the frozen ack code `runtime_unsupported`.
|
||||||
|
*/
|
||||||
|
export class ChatRuntimeUnavailableError extends Error {
|
||||||
|
readonly code = 'runtime_unsupported' as const;
|
||||||
|
readonly reason: ChatRuntimeUnavailableReason;
|
||||||
|
|
||||||
|
constructor(reason: ChatRuntimeUnavailableReason) {
|
||||||
|
super(
|
||||||
|
reason === 'adapter_unavailable'
|
||||||
|
? 'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.'
|
||||||
|
: 'The pi-rpc chat runtime is unavailable: the harness conversation service is not bound.',
|
||||||
|
);
|
||||||
|
this.name = 'ChatRuntimeUnavailableError';
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the process-wide chat runtime mode from the environment. Anything other
|
||||||
|
* than the exact opt-in token `pi-rpc` keeps the legacy embedded runtime.
|
||||||
|
*/
|
||||||
|
export function resolveChatRuntimeMode(
|
||||||
|
env: Record<string, string | undefined> = process.env,
|
||||||
|
): ChatRuntimeMode {
|
||||||
|
return env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Transitional embedded chat port (Task Five).
|
||||||
|
//
|
||||||
|
// The legacy embedded browser behaviour is moved behind this exact interface so
|
||||||
|
// neither the controller nor the gateway retains AgentService, RoutingEngine,
|
||||||
|
// session, `piSession`, metric, listener, or channel access. `EmbeddedChatRuntime`
|
||||||
|
// implements the port; `ChatRuntimeRouter` exposes the same narrowly named
|
||||||
|
// operations and returns `runtime_unsupported` before touching Embedded for legacy
|
||||||
|
// browser operations when the mode is `pi-rpc`.
|
||||||
|
//
|
||||||
|
// The names are frozen (spec jarvis-brain@1c629b06). Legacy REST completion,
|
||||||
|
// legacy Socket streaming, P3 harness turns, and verified Discord are distinct
|
||||||
|
// transport/trust capabilities — there is deliberately no generic
|
||||||
|
// `sendConversationTurn` nor an AgentService-shaped mirror on the router.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phantom brand keeping {@link OwnedConversationContext} nominally distinct so browser
|
||||||
|
* DTOs are never structurally assignable to it. The factory that mints one may be called
|
||||||
|
* only after authentication with `scopeFromUser(...)`, never with payload authority fields.
|
||||||
|
*/
|
||||||
|
declare const ownedConversationContextBrand: unique symbol;
|
||||||
|
|
||||||
|
/** Gateway-only ownership context. Embedded rechecks owner+tenant on every operation. */
|
||||||
|
export interface OwnedConversationContext {
|
||||||
|
readonly [ownedConversationContextBrand]: true;
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly scope: Readonly<{ userId: string; tenantId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every non-`ok` legacy runtime outcome. Missing, foreign, and no-longer-owned
|
||||||
|
* conversations all collapse to `conversation_unavailable`. Ownership/mode/validation
|
||||||
|
* failures are total results and never throw.
|
||||||
|
*/
|
||||||
|
export type LegacyRuntimeFailure =
|
||||||
|
| { readonly ok: false; readonly code: 'runtime_unsupported'; readonly retryable: false }
|
||||||
|
| { readonly ok: false; readonly code: 'conversation_unavailable'; readonly retryable: false }
|
||||||
|
| { readonly ok: false; readonly code: 'request_invalid'; readonly retryable: false }
|
||||||
|
| {
|
||||||
|
readonly ok: false;
|
||||||
|
readonly code: 'thinking_level_invalid';
|
||||||
|
readonly retryable: false;
|
||||||
|
readonly availableThinkingLevels: readonly string[];
|
||||||
|
}
|
||||||
|
| { readonly ok: false; readonly code: 'runtime_unavailable'; readonly retryable: true }
|
||||||
|
| { readonly ok: false; readonly code: 'turn_already_dispatched'; readonly retryable: false }
|
||||||
|
| { readonly ok: false; readonly code: 'operation_failed'; readonly retryable: boolean }
|
||||||
|
| { readonly ok: false; readonly code: 'timeout'; readonly retryable: true };
|
||||||
|
|
||||||
|
/** Total result: an `ok` value or one of the fixed {@link LegacyRuntimeFailure} codes. */
|
||||||
|
export type LegacyRuntimeResult<T> =
|
||||||
|
| { readonly ok: true; readonly value: T }
|
||||||
|
| LegacyRuntimeFailure;
|
||||||
|
|
||||||
|
/** User-facing session projection. Carries no session object, handle, or credential path. */
|
||||||
|
export interface LegacySessionPresentation {
|
||||||
|
readonly provider: string;
|
||||||
|
readonly modelId: string;
|
||||||
|
readonly thinkingLevel: string;
|
||||||
|
readonly availableThinkingLevels: readonly string[];
|
||||||
|
readonly agentName?: string;
|
||||||
|
readonly routingDecision?: RoutingDecisionInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Terminal usage stats, normalized by Embedded from AgentService metrics. */
|
||||||
|
export interface LegacyUsage {
|
||||||
|
readonly provider: string;
|
||||||
|
readonly modelId: string;
|
||||||
|
readonly thinkingLevel: string;
|
||||||
|
readonly tokens: Readonly<{
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
cacheRead: number;
|
||||||
|
cacheWrite: number;
|
||||||
|
total: number;
|
||||||
|
}>;
|
||||||
|
readonly cost: number;
|
||||||
|
readonly context: Readonly<{ percent: number | null; window: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized stream event. Exposes no `AgentSession`, `piSession`, native handle, raw
|
||||||
|
* exception, tool arguments, or credential-bearing path — the gateway sees only these.
|
||||||
|
*/
|
||||||
|
export type LegacyRuntimeEvent =
|
||||||
|
| { readonly type: 'started' }
|
||||||
|
| { readonly type: 'text_delta'; readonly text: string }
|
||||||
|
| { readonly type: 'thinking_delta'; readonly text: string }
|
||||||
|
| {
|
||||||
|
readonly type: 'tool_started';
|
||||||
|
readonly toolCallId: string;
|
||||||
|
readonly toolName: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly type: 'tool_finished';
|
||||||
|
readonly toolCallId: string;
|
||||||
|
readonly toolName: string;
|
||||||
|
readonly isError: boolean;
|
||||||
|
}
|
||||||
|
| { readonly type: 'settled'; readonly usage?: LegacyUsage };
|
||||||
|
|
||||||
|
/** Legacy browser message input. Authority fields are advisory only; scope comes from the context. */
|
||||||
|
export interface LegacyBrowserMessagePayload {
|
||||||
|
readonly content: string;
|
||||||
|
readonly provider?: string;
|
||||||
|
readonly modelId?: string;
|
||||||
|
readonly agentId?: string;
|
||||||
|
readonly attachments?: readonly ChannelAttachmentDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A prepared-but-not-yet-dispatched legacy socket turn. */
|
||||||
|
export interface LegacySocketTurnLease {
|
||||||
|
readonly presentation: LegacySessionPresentation;
|
||||||
|
/**
|
||||||
|
* Atomically one-shot and scope-rechecking. A second call returns
|
||||||
|
* `turn_already_dispatched` and performs zero prompt/tool effects.
|
||||||
|
*/
|
||||||
|
dispatch(): Promise<LegacyRuntimeResult<void>>;
|
||||||
|
/** Idempotent, non-throwing. Removes listener and channel, including partial setup. */
|
||||||
|
dispose(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phantom brand for {@link VerifiedDiscordIngressContext}. Minted only after service-token
|
||||||
|
* auth plus signature, allowlist, binding, expected-route, replay, configured-agent,
|
||||||
|
* forced-scope, and attachment-normalization checks.
|
||||||
|
*/
|
||||||
|
declare const verifiedDiscordIngressContextBrand: unique symbol;
|
||||||
|
|
||||||
|
/** Fully-verified Discord ingress. Contains no socket, envelope, signature, token, or escape hatch. */
|
||||||
|
export interface VerifiedDiscordIngressContext {
|
||||||
|
readonly [verifiedDiscordIngressContextBrand]: true;
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly scope: Readonly<{ userId: string; tenantId: string }>;
|
||||||
|
readonly configuredAgent: Readonly<{ agentConfigId: string; instanceId: string }>;
|
||||||
|
readonly content: string;
|
||||||
|
readonly attachments?: readonly ChannelAttachmentDto[];
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly discordMessageId: string;
|
||||||
|
readonly discordUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verified-Discord turn lease. Same atomic one-shot dispatch and idempotent dispose rules. */
|
||||||
|
export interface VerifiedDiscordTurnLease {
|
||||||
|
readonly presentation: LegacySessionPresentation;
|
||||||
|
dispatch(): Promise<LegacyRuntimeResult<void>>;
|
||||||
|
dispose(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-owned egress projection the runtime pushes normalized events into. */
|
||||||
|
export interface LegacyRuntimeStream {
|
||||||
|
/** Server-derived, e.g. `websocket:<socket-id>`. Never client-supplied. */
|
||||||
|
readonly channelId: string;
|
||||||
|
onEvent(event: LegacyRuntimeEvent): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact transitional port. `EmbeddedChatRuntime` implements it; `ChatRuntimeRouter`
|
||||||
|
* mirrors the operation names and fails closed with `runtime_unsupported` for legacy
|
||||||
|
* browser operations under `pi-rpc`.
|
||||||
|
*/
|
||||||
|
export interface LegacyEmbeddedChatPort {
|
||||||
|
completeLegacyRestTurn(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
input: Readonly<{ content: string }>,
|
||||||
|
): Promise<
|
||||||
|
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
prepareLegacySocketTurn(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
input: LegacyBrowserMessagePayload,
|
||||||
|
stream: LegacyRuntimeStream,
|
||||||
|
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>>;
|
||||||
|
|
||||||
|
setLegacyThinking(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
level: string,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation>;
|
||||||
|
|
||||||
|
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>>;
|
||||||
|
|
||||||
|
applyLegacyModelOverride(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
modelId: string,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation>;
|
||||||
|
|
||||||
|
readLegacySessionPresentation(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation>;
|
||||||
|
|
||||||
|
dispatchVerifiedDiscordIngress(
|
||||||
|
context: VerifiedDiscordIngressContext,
|
||||||
|
stream: LegacyRuntimeStream,
|
||||||
|
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mints an {@link OwnedConversationContext} from a server-derived scope. Callers must pass
|
||||||
|
* a scope produced by `scopeFromUser(...)` after authentication — never a client-supplied
|
||||||
|
* authority field. The brand is phantom, so this is the only way to obtain the branded type.
|
||||||
|
*/
|
||||||
|
export function ownConversation(
|
||||||
|
conversationId: string,
|
||||||
|
scope: Readonly<{ userId: string; tenantId: string }>,
|
||||||
|
): OwnedConversationContext {
|
||||||
|
return { conversationId, scope } as unknown as OwnedConversationContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mints a {@link VerifiedDiscordIngressContext}. Callers must have already completed every
|
||||||
|
* ingress check (service-token auth, signature, allowlist, binding, expected-route, replay,
|
||||||
|
* configured-agent, forced-scope, attachment normalization) before calling this.
|
||||||
|
*/
|
||||||
|
export function verifyDiscordIngress(
|
||||||
|
fields: Omit<VerifiedDiscordIngressContext, typeof verifiedDiscordIngressContextBrand>,
|
||||||
|
): VerifiedDiscordIngressContext {
|
||||||
|
return { ...fields } as unknown as VerifiedDiscordIngressContext;
|
||||||
|
}
|
||||||
@@ -3,21 +3,20 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Body,
|
Body,
|
||||||
Logger,
|
Logger,
|
||||||
ForbiddenException,
|
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Inject,
|
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
|
||||||
import { Throttle } from '@nestjs/throttler';
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { ChatRequestDto } from './chat.dto.js';
|
import { ChatRequestDto } from './chat.dto.js';
|
||||||
|
import { ChatRuntimeRouter } from './chat-runtime-router.js';
|
||||||
|
import { ownConversation } from './chat-runtime.js';
|
||||||
|
import type { LegacyRuntimeFailure } from './chat-runtime.js';
|
||||||
|
|
||||||
interface ChatResponse {
|
interface ChatResponse {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
@@ -29,7 +28,7 @@ interface ChatResponse {
|
|||||||
export class ChatController {
|
export class ChatController {
|
||||||
private readonly logger = new Logger(ChatController.name);
|
private readonly logger = new Logger(ChatController.name);
|
||||||
|
|
||||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
constructor(private readonly runtime: ChatRuntimeRouter) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||||
@@ -40,68 +39,38 @@ export class ChatController {
|
|||||||
const conversationId = body.conversationId ?? uuid();
|
const conversationId = body.conversationId ?? uuid();
|
||||||
const scope = scopeFromUser(user);
|
const scope = scopeFromUser(user);
|
||||||
|
|
||||||
try {
|
|
||||||
let agentSession = this.agentService.getSession(conversationId, scope);
|
|
||||||
if (!agentSession) {
|
|
||||||
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),
|
|
||||||
);
|
|
||||||
throw new HttpException('Agent session unavailable', HttpStatus.SERVICE_UNAVAILABLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug(`Handling chat request for user=${user.id}, conversation=${conversationId}`);
|
this.logger.debug(`Handling chat request for user=${user.id}, conversation=${conversationId}`);
|
||||||
|
|
||||||
let responseText = '';
|
// The one exclusive runtime owns execution. In legacy mode this reaches the embedded runtime;
|
||||||
|
// in pi-rpc it fails closed with `runtime_unsupported` before ever touching embedded execution.
|
||||||
|
const result = await this.runtime.completeLegacyRestTurn(
|
||||||
|
ownConversation(conversationId, scope),
|
||||||
|
{ content: body.content },
|
||||||
|
);
|
||||||
|
|
||||||
const done = new Promise<void>((resolve, reject) => {
|
if (result.ok) {
|
||||||
const timer = setTimeout(() => {
|
return { conversationId, text: result.value.text };
|
||||||
cleanup();
|
|
||||||
this.logger.error(`Agent response timed out after 120s for conversation=${conversationId}`);
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scope,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.agentService.prompt(conversationId, body.content, scope);
|
|
||||||
await done;
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof HttpException) throw err;
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
if (message.includes('timed out')) {
|
|
||||||
throw new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
|
|
||||||
}
|
|
||||||
this.logger.error(`Chat prompt failed for conversation=${conversationId}`, String(err));
|
|
||||||
throw new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { conversationId, text: responseText };
|
throw this.toHttpException(result, conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a total {@link LegacyRuntimeFailure} to the fixed browser-safe HTTP surface. */
|
||||||
|
private toHttpException(failure: LegacyRuntimeFailure, conversationId: string): HttpException {
|
||||||
|
switch (failure.code) {
|
||||||
|
case 'conversation_unavailable':
|
||||||
|
return new NotFoundException('Session not found');
|
||||||
|
case 'request_invalid':
|
||||||
|
case 'thinking_level_invalid':
|
||||||
|
return new HttpException('Invalid chat request', HttpStatus.BAD_REQUEST);
|
||||||
|
case 'timeout':
|
||||||
|
return new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
|
||||||
|
case 'runtime_unsupported':
|
||||||
|
case 'runtime_unavailable':
|
||||||
|
return new HttpException('Agent runtime unavailable', HttpStatus.SERVICE_UNAVAILABLE);
|
||||||
|
default:
|
||||||
|
this.logger.error(`Chat turn failed for conversation=${conversationId}: ${failure.code}`);
|
||||||
|
return new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import type { ChannelAttachmentDto } from '@mosaicstack/types';
|
import type { ChannelAttachmentDto } from '@mosaicstack/types';
|
||||||
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsNotEmpty,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class ChatRequestDto {
|
export class ChatRequestDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -37,3 +46,56 @@ export class ChatSocketMessageDto {
|
|||||||
/** Validated channel attachment references; binary content is not embedded. */
|
/** Validated channel attachment references; binary content is not embedded. */
|
||||||
attachments?: readonly ChannelAttachmentDto[];
|
attachments?: readonly ChannelAttachmentDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, group 2 — the frozen pi-rpc `turn:send` selection triple.
|
||||||
|
*
|
||||||
|
* Each id is a required, non-empty, bounded string. There is no `@IsOptional` and no extra
|
||||||
|
* field: under `forbidNonWhitelisted` an unknown selection key is rejected, and a missing id
|
||||||
|
* fails `@IsString` (undefined is not a string) rather than silently passing.
|
||||||
|
*/
|
||||||
|
export class HarnessTurnSelectionDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
|
harnessId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
|
providerId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
|
modelId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, group 2 — the frozen wire contract for a pi-rpc `turn:send`.
|
||||||
|
*
|
||||||
|
* Validated through the production `ValidationPipe({ whitelist, forbidNonWhitelisted, transform })`:
|
||||||
|
* a UUID conversation id; `content` trimmed then bounded to 1..10_000 characters (whitespace-only
|
||||||
|
* collapses to empty and fails `@IsNotEmpty`); a nested `selection` object recursed with an
|
||||||
|
* explicit `@Type` (a bare `@ValidateNested` is masked green by class-validator's empty-metadata
|
||||||
|
* `unknownValue`); and a UUID-v4 idempotency key. No `provider`/`modelId`/`attachments` or other
|
||||||
|
* authority field is declared, so `forbidNonWhitelisted` rejects every unknown top-level key.
|
||||||
|
*/
|
||||||
|
export class HarnessTurnSendDto {
|
||||||
|
@IsUUID()
|
||||||
|
conversationId!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(10_000)
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => HarnessTurnSelectionDto)
|
||||||
|
selection!: HarnessTurnSelectionDto;
|
||||||
|
|
||||||
|
@IsUUID('4')
|
||||||
|
idempotencyKey!: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,12 +8,31 @@ const payload: SlashCommandPayload = {
|
|||||||
approvalId: 'approval-1',
|
approvalId: 'approval-1',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 5 fence (F, existing control): gateway-owned command authorization/approval must
|
||||||
|
* cause ZERO chat-runtime dispatch. Placed in the gateway's chat-runtime-router slot (the
|
||||||
|
* former direct `AgentService` slot) so any accidental chat-runtime resolution throws
|
||||||
|
* loudly instead of silently passing. Because execute/approval run entirely through the
|
||||||
|
* command executor dependency and never resolve a chat runtime, this fixture is never
|
||||||
|
* triggered and the ingress stays a GREEN control.
|
||||||
|
*/
|
||||||
|
function failIfUsedChatRuntimeRouter() {
|
||||||
|
return {
|
||||||
|
onModuleInit: () => {
|
||||||
|
throw new Error('chat runtime router must not initialise on the command approval path');
|
||||||
|
},
|
||||||
|
get active(): never {
|
||||||
|
throw new Error('chat runtime must not be resolved on the command approval path');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildGateway(commandExecutor: {
|
function buildGateway(commandExecutor: {
|
||||||
execute: ReturnType<typeof vi.fn>;
|
execute: ReturnType<typeof vi.fn>;
|
||||||
createApproval: ReturnType<typeof vi.fn>;
|
createApproval: ReturnType<typeof vi.fn>;
|
||||||
}): ChatGateway {
|
}): ChatGateway {
|
||||||
return new ChatGateway(
|
return new ChatGateway(
|
||||||
{} as never,
|
failIfUsedChatRuntimeRouter() as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
@@ -72,3 +91,114 @@ describe('ChatGateway command approval ingress', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 5 (G3) command runtime fence. Under pi-rpc there is no embedded chat session, so
|
||||||
|
* embedded slash-commands (/model, /agent, and every other non-audited command) are fixed
|
||||||
|
* "unsupported" and MUST fail closed BEFORE reaching the command executor — never a silent
|
||||||
|
* fall-through to embedded execution. Only runtime-independent audited system commands
|
||||||
|
* (/reload) pass through as a positive control, and the approval path stays runtime-independent.
|
||||||
|
* The router stub here carries `runtimeMode: 'pi-rpc'` and throws if any runtime is resolved, so
|
||||||
|
* a fence bypass surfaces as a thrown error rather than a silent embedded dispatch.
|
||||||
|
*/
|
||||||
|
function buildPiRpcGateway(commandExecutor: {
|
||||||
|
execute: ReturnType<typeof vi.fn>;
|
||||||
|
createApproval: ReturnType<typeof vi.fn>;
|
||||||
|
}): ChatGateway {
|
||||||
|
const piRpcRouter = {
|
||||||
|
runtimeMode: 'pi-rpc' as const,
|
||||||
|
onModuleInit: () => {
|
||||||
|
throw new Error('chat runtime router must not initialise on the pi-rpc command path');
|
||||||
|
},
|
||||||
|
get active(): never {
|
||||||
|
throw new Error('chat runtime must not be resolved on the pi-rpc command path');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return new ChatGateway(
|
||||||
|
piRpcRouter as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
commandExecutor as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatGateway command runtime fence (Task 5 G3, pi-rpc)', () => {
|
||||||
|
const UNSUPPORTED = 'Slash commands are not available on this deployment.';
|
||||||
|
|
||||||
|
it.each(['model', 'agent', 'gc'])(
|
||||||
|
'fails /%s closed before the executor under pi-rpc (execute never called)',
|
||||||
|
async (command): Promise<void> => {
|
||||||
|
const commandExecutor = {
|
||||||
|
execute: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ command, conversationId: 'conversation-1', success: true }),
|
||||||
|
createApproval: vi.fn(),
|
||||||
|
};
|
||||||
|
const gateway = buildPiRpcGateway(commandExecutor);
|
||||||
|
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||||
|
|
||||||
|
await gateway.handleCommandExecute(client as never, {
|
||||||
|
command,
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(commandExecutor.execute).toHaveBeenCalledTimes(0);
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('command:result', {
|
||||||
|
command,
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
success: false,
|
||||||
|
message: UNSUPPORTED,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('passes the audited /reload system command through as a positive control under pi-rpc', async (): Promise<void> => {
|
||||||
|
const reloadResult = { command: 'reload', conversationId: 'conversation-1', success: true };
|
||||||
|
const commandExecutor = {
|
||||||
|
execute: vi.fn().mockResolvedValue(reloadResult),
|
||||||
|
createApproval: vi.fn(),
|
||||||
|
};
|
||||||
|
const gateway = buildPiRpcGateway(commandExecutor);
|
||||||
|
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||||
|
|
||||||
|
await gateway.handleCommandExecute(client as never, {
|
||||||
|
command: 'reload',
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(commandExecutor.execute).toHaveBeenCalledTimes(1);
|
||||||
|
expect(commandExecutor.execute).toHaveBeenCalledWith(
|
||||||
|
{ command: 'reload', conversationId: 'conversation-1' },
|
||||||
|
{ userId: 'admin-1', tenantId: 'admin-1' },
|
||||||
|
);
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('command:result', reloadResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps command approval runtime-independent under pi-rpc (createApproval still runs)', async (): Promise<void> => {
|
||||||
|
const commandExecutor = {
|
||||||
|
execute: vi.fn(),
|
||||||
|
createApproval: vi.fn().mockResolvedValue({
|
||||||
|
approvalId: 'approval-1',
|
||||||
|
expiresAt: '2026-07-12T00:05:00.000Z',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const gateway = buildPiRpcGateway(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',
|
||||||
|
expect.objectContaining({ success: true, approvalId: 'approval-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,59 @@
|
|||||||
import { forwardRef, Module } from '@nestjs/common';
|
import { forwardRef, Module } from '@nestjs/common';
|
||||||
import { CommandsModule } from '../commands/commands.module.js';
|
import { CommandsModule } from '../commands/commands.module.js';
|
||||||
|
import { HarnessModule } from '../harness/harness.module.js';
|
||||||
|
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||||
|
import {
|
||||||
|
HARNESS_CONVERSATION_SERVICE,
|
||||||
|
HARNESS_REGISTRY,
|
||||||
|
type HarnessConversationServiceBinding,
|
||||||
|
} from '../harness/harness.tokens.js';
|
||||||
|
import type { HarnessConversationService } from '@mosaicstack/types';
|
||||||
import { ChatGateway } from './chat.gateway.js';
|
import { ChatGateway } from './chat.gateway.js';
|
||||||
import { ChatController } from './chat.controller.js';
|
import { ChatController } from './chat.controller.js';
|
||||||
|
import { ChatRuntimeRouter } from './chat-runtime-router.js';
|
||||||
|
import { EmbeddedChatRuntime } from './embedded-chat.runtime.js';
|
||||||
|
import { HarnessChatRuntime } from './harness-chat.runtime.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five wiring. The exclusive {@link ChatRuntimeRouter} is the single chat-execution
|
||||||
|
* authority: the controller and gateway inject only the router, never `AgentService`,
|
||||||
|
* `RoutingEngineService`, or a session/`piSession` handle. The router resolves exactly one
|
||||||
|
* runtime at module init — {@link EmbeddedChatRuntime} in legacy mode, {@link HarnessChatRuntime}
|
||||||
|
* in `pi-rpc` — over the REAL {@link HarnessModule} registry and conversation-service binding.
|
||||||
|
*
|
||||||
|
* The router and the harness runtime are constructed through factories because their
|
||||||
|
* dependencies are interface/union types with no runtime injection token (the registry and
|
||||||
|
* conversation-service arrive via the string tokens exported by `HarnessModule`); the embedded
|
||||||
|
* runtime injects the class-typed `AgentService` and is provided directly.
|
||||||
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => CommandsModule)],
|
imports: [forwardRef(() => CommandsModule), HarnessModule],
|
||||||
controllers: [ChatController],
|
controllers: [ChatController],
|
||||||
providers: [ChatGateway],
|
providers: [
|
||||||
exports: [ChatGateway],
|
ChatGateway,
|
||||||
|
EmbeddedChatRuntime,
|
||||||
|
{
|
||||||
|
provide: HarnessChatRuntime,
|
||||||
|
useFactory: (conversationService: HarnessConversationServiceBinding) =>
|
||||||
|
new HarnessChatRuntime(conversationService as HarnessConversationService),
|
||||||
|
inject: [HARNESS_CONVERSATION_SERVICE],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ChatRuntimeRouter,
|
||||||
|
useFactory: (
|
||||||
|
registry: HarnessRegistry,
|
||||||
|
conversationService: HarnessConversationServiceBinding,
|
||||||
|
embedded: EmbeddedChatRuntime,
|
||||||
|
harness: HarnessChatRuntime,
|
||||||
|
) => new ChatRuntimeRouter(registry, conversationService, embedded, harness),
|
||||||
|
inject: [
|
||||||
|
HARNESS_REGISTRY,
|
||||||
|
HARNESS_CONVERSATION_SERVICE,
|
||||||
|
EmbeddedChatRuntime,
|
||||||
|
HarnessChatRuntime,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [ChatGateway, ChatRuntimeRouter],
|
||||||
})
|
})
|
||||||
export class ChatModule {}
|
export class ChatModule {}
|
||||||
|
|||||||
@@ -0,0 +1,532 @@
|
|||||||
|
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { AgentService, type AgentSession } from '../agent/agent.service.js';
|
||||||
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
|
import type {
|
||||||
|
ChatRuntime,
|
||||||
|
LegacyBrowserMessagePayload,
|
||||||
|
LegacyEmbeddedChatPort,
|
||||||
|
LegacyRuntimeEvent,
|
||||||
|
LegacyRuntimeResult,
|
||||||
|
LegacySessionPresentation,
|
||||||
|
LegacySocketTurnLease,
|
||||||
|
LegacyUsage,
|
||||||
|
OwnedConversationContext,
|
||||||
|
VerifiedDiscordIngressContext,
|
||||||
|
VerifiedDiscordTurnLease,
|
||||||
|
LegacyRuntimeStream,
|
||||||
|
} from './chat-runtime.js';
|
||||||
|
|
||||||
|
/** Fixed timeout for a synchronous REST turn, matching the historical controller budget. */
|
||||||
|
const REST_TURN_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `legacy` chat runtime and the sole implementation of {@link LegacyEmbeddedChatPort}.
|
||||||
|
*
|
||||||
|
* It owns the embedded in-process execution path — the `AgentService` stack that the
|
||||||
|
* `ChatController` and `ChatGateway` drove directly before Task Five. Once the
|
||||||
|
* {@link import('./chat-runtime-router.js').ChatRuntimeRouter} fronts it, the browser
|
||||||
|
* HTTP/WebSocket legacy path and verified-Discord ingress route through THIS runtime, so
|
||||||
|
* neither the controller nor the gateway retains `AgentService`, `piSession`, session,
|
||||||
|
* listener, channel, or metric access. Ownership (`userId`/`tenantId`) is re-checked by
|
||||||
|
* `AgentService` on every operation; a missing, foreign, or no-longer-owned conversation
|
||||||
|
* collapses to `conversation_unavailable` and never throws out of the port.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort {
|
||||||
|
readonly kind = 'embedded' as const;
|
||||||
|
private readonly logger = new Logger(EmbeddedChatRuntime.name);
|
||||||
|
|
||||||
|
constructor(readonly agentService: AgentService) {}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Legacy REST completion (op A)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async completeLegacyRestTurn(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
input: Readonly<{ content: string }>,
|
||||||
|
): Promise<
|
||||||
|
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
|
||||||
|
> {
|
||||||
|
const scope = toScope(context.scope);
|
||||||
|
const { conversationId } = context;
|
||||||
|
|
||||||
|
const resolved = await this.resolveOrCreate(conversationId, scope, {});
|
||||||
|
if (!resolved.ok) return resolved;
|
||||||
|
|
||||||
|
let responseText = '';
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let detach: (() => void) | undefined;
|
||||||
|
let disposed = false;
|
||||||
|
// One idempotent teardown owned OUTSIDE the completion promise: it clears the timeout and
|
||||||
|
// detaches the event listener exactly once, whichever of agent_end, timeout, or a prompt
|
||||||
|
// rejection fires first. Without this, a prompt() rejection surfaced through the catch below
|
||||||
|
// would return while leaving the listener attached (free to consume a later turn's events) and
|
||||||
|
// the 120s timer live (its rejection later going unobserved).
|
||||||
|
const dispose = (): void => {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
if (timer !== undefined) clearTimeout(timer);
|
||||||
|
detach?.();
|
||||||
|
};
|
||||||
|
const done = new Promise<void>((resolve, reject) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
dispose();
|
||||||
|
reject(new Error('Agent response timed out'));
|
||||||
|
}, REST_TURN_TIMEOUT_MS);
|
||||||
|
|
||||||
|
detach = 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') {
|
||||||
|
dispose();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach the prompt and the completion promise CONCURRENTLY. Awaiting prompt() first left the
|
||||||
|
// timeout unobservable until prompt settled (a hung prompt could never time out) and, worse,
|
||||||
|
// let the 120s timer reject `done` while nothing yet awaited it — a transient unhandledRejection
|
||||||
|
// window. Promise.all installs handlers on BOTH synchronously, so the timeout bounds the whole
|
||||||
|
// turn even while prompt is pending, and neither promise can reject unobserved. Success still
|
||||||
|
// requires both prompt() to resolve AND agent_end to arrive (identical to the prior sequential
|
||||||
|
// await). The idempotent dispose() clears the timer + detaches on whichever settles first.
|
||||||
|
const prompting = this.agentService.prompt(conversationId, input.content, scope);
|
||||||
|
try {
|
||||||
|
await Promise.all([prompting, done]);
|
||||||
|
} catch (err) {
|
||||||
|
dispose();
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes('timed out')) {
|
||||||
|
return { ok: false, code: 'timeout', retryable: true };
|
||||||
|
}
|
||||||
|
this.logger.error(`Legacy REST turn failed for conversation=${conversationId}`, message);
|
||||||
|
return { ok: false, code: 'operation_failed', retryable: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const presentation = this.presentationFor(conversationId, scope) ?? resolved.presentation;
|
||||||
|
return { ok: true, value: { text: responseText, presentation } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Legacy Socket streaming (op B)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async prepareLegacySocketTurn(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
input: LegacyBrowserMessagePayload,
|
||||||
|
stream: LegacyRuntimeStream,
|
||||||
|
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
|
||||||
|
const scope = toScope(context.scope);
|
||||||
|
const { conversationId } = context;
|
||||||
|
|
||||||
|
const resolved = await this.resolveOrCreate(conversationId, scope, {
|
||||||
|
...(input.provider ? { provider: input.provider } : {}),
|
||||||
|
...(input.modelId ? { modelId: input.modelId } : {}),
|
||||||
|
...(input.agentId ? { agentConfigId: input.agentId } : {}),
|
||||||
|
});
|
||||||
|
if (!resolved.ok) return resolved;
|
||||||
|
|
||||||
|
let detach: () => void;
|
||||||
|
try {
|
||||||
|
detach = this.subscribe(conversationId, scope, stream);
|
||||||
|
} catch (err) {
|
||||||
|
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
|
||||||
|
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
|
||||||
|
this.logger.error(
|
||||||
|
`Embedded socket subscription failed for conversation=${conversationId}`,
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: this.buildLease(
|
||||||
|
conversationId,
|
||||||
|
scope,
|
||||||
|
input.content,
|
||||||
|
input.attachments,
|
||||||
|
detach,
|
||||||
|
resolved.presentation,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Thinking level (op C) — synchronous, total
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
setLegacyThinking(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
level: string,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||||
|
const scope = toScope(context.scope);
|
||||||
|
const session = this.agentService.getSession(context.conversationId, scope);
|
||||||
|
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||||
|
|
||||||
|
const availableThinkingLevels = session.piSession.getAvailableThinkingLevels();
|
||||||
|
if (!(availableThinkingLevels as readonly string[]).includes(level)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'thinking_level_invalid',
|
||||||
|
retryable: false,
|
||||||
|
availableThinkingLevels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
session.piSession.setThinkingLevel(level as never);
|
||||||
|
return { ok: true, value: this.presentationForSession(session) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Abort (op D)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
|
||||||
|
const scope = toScope(context.scope);
|
||||||
|
const session = this.agentService.getSession(context.conversationId, scope);
|
||||||
|
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await session.piSession.abort();
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Legacy abort failed for conversation=${context.conversationId}`,
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
return { ok: false, code: 'operation_failed', retryable: false };
|
||||||
|
}
|
||||||
|
return { ok: true, value: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Model override (synchronous, total)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
applyLegacyModelOverride(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
modelId: string,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||||
|
const scope = toScope(context.scope);
|
||||||
|
const session = this.agentService.getSession(context.conversationId, scope);
|
||||||
|
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||||
|
|
||||||
|
this.agentService.updateSessionModel(context.conversationId, modelId, scope);
|
||||||
|
const refreshed = this.agentService.getSession(context.conversationId, scope) ?? session;
|
||||||
|
return { ok: true, value: this.presentationForSession(refreshed) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Presentation read (synchronous, total)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
readLegacySessionPresentation(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||||
|
const scope = toScope(context.scope);
|
||||||
|
const session = this.agentService.getSession(context.conversationId, scope);
|
||||||
|
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||||
|
return { ok: true, value: this.presentationForSession(session) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Verified Discord ingress (embedded-only in both modes)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async dispatchVerifiedDiscordIngress(
|
||||||
|
context: VerifiedDiscordIngressContext,
|
||||||
|
stream: LegacyRuntimeStream,
|
||||||
|
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
|
||||||
|
const scope = toScope(context.scope);
|
||||||
|
const { conversationId } = context;
|
||||||
|
|
||||||
|
const resolved = await this.resolveOrCreate(
|
||||||
|
conversationId,
|
||||||
|
scope,
|
||||||
|
{ agentConfigId: context.configuredAgent.agentConfigId },
|
||||||
|
{
|
||||||
|
agentConfigId: context.configuredAgent.agentConfigId,
|
||||||
|
instanceId: context.configuredAgent.instanceId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!resolved.ok) return resolved;
|
||||||
|
|
||||||
|
let detach: () => void;
|
||||||
|
try {
|
||||||
|
detach = this.subscribe(conversationId, scope, stream);
|
||||||
|
} catch (err) {
|
||||||
|
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
|
||||||
|
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
|
||||||
|
this.logger.error(
|
||||||
|
`Embedded Discord subscription failed for conversation=${conversationId}`,
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: this.buildLease(
|
||||||
|
conversationId,
|
||||||
|
scope,
|
||||||
|
context.content,
|
||||||
|
context.attachments,
|
||||||
|
detach,
|
||||||
|
resolved.presentation,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Shared helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the owned session, creating it on first use. Ownership/scope rejections
|
||||||
|
* (`Forbidden`/`NotFound`) collapse to `conversation_unavailable`; any other creation
|
||||||
|
* failure surfaces as the retryable `runtime_unavailable`. On success returns the
|
||||||
|
* session presentation so callers avoid a redundant `getSession`.
|
||||||
|
*/
|
||||||
|
private async resolveOrCreate(
|
||||||
|
conversationId: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
extraOptions: Readonly<{ provider?: string; modelId?: string; agentConfigId?: string }>,
|
||||||
|
expectedAgent?: Readonly<{ agentConfigId: string; instanceId: string }>,
|
||||||
|
): Promise<
|
||||||
|
| { readonly ok: true; readonly presentation: LegacySessionPresentation }
|
||||||
|
| Exclude<LegacyRuntimeResult<never>, { ok: true }>
|
||||||
|
> {
|
||||||
|
// A verified-Discord turn may only run under a session whose configured identity matches the
|
||||||
|
// reconciled agent record EXACTLY (config id + resolved name). This holds for BOTH a reused
|
||||||
|
// pre-existing session AND a freshly created one: a session carrying a different configured
|
||||||
|
// agent — however it arose — is rejected rather than executed under the verified label, so we
|
||||||
|
// never silently run a different prompt/model/tool policy. A plain (non-verified) turn passes
|
||||||
|
// no expectedAgent and skips the check.
|
||||||
|
const identityMatches = (candidate: AgentSession): boolean =>
|
||||||
|
expectedAgent === undefined ||
|
||||||
|
(candidate.agentConfigId === expectedAgent.agentConfigId &&
|
||||||
|
candidate.agentName === expectedAgent.instanceId);
|
||||||
|
|
||||||
|
let session = this.agentService.getSession(conversationId, scope);
|
||||||
|
if (session && !identityMatches(session)) {
|
||||||
|
// Reused same-scope session minted under a different configured identity — reject with zero
|
||||||
|
// effects rather than dispatch a verified turn onto a foreign agent's session.
|
||||||
|
return CONVERSATION_UNAVAILABLE;
|
||||||
|
}
|
||||||
|
if (!session) {
|
||||||
|
try {
|
||||||
|
session = await this.agentService.createSession(conversationId, {
|
||||||
|
userId: scope.userId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
...extraOptions,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ForbiddenException || err instanceof NotFoundException) {
|
||||||
|
return CONVERSATION_UNAVAILABLE;
|
||||||
|
}
|
||||||
|
this.logger.error(
|
||||||
|
`Embedded session creation failed for conversation=${conversationId}`,
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
||||||
|
}
|
||||||
|
// The just-created session must ALSO carry the reconciled identity before any effect. A
|
||||||
|
// createSession that returns a session under a different configured agent (misconfiguration
|
||||||
|
// or a substituted factory) is rejected here, before subscribe/persist/ack/prompt.
|
||||||
|
if (!identityMatches(session)) {
|
||||||
|
return CONVERSATION_UNAVAILABLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, presentation: this.presentationForSession(session) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Installs a normalizing event listener that forwards to the server-owned stream. */
|
||||||
|
private subscribe(
|
||||||
|
conversationId: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
stream: LegacyRuntimeStream,
|
||||||
|
): () => void {
|
||||||
|
const unsubscribe = this.agentService.onEvent(
|
||||||
|
conversationId,
|
||||||
|
(event: AgentSessionEvent) => {
|
||||||
|
const normalized = this.normalizeEvent(conversationId, scope, event);
|
||||||
|
if (normalized) stream.onEvent(normalized);
|
||||||
|
},
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
this.agentService.addChannel(conversationId, stream.channelId, scope);
|
||||||
|
} catch (err) {
|
||||||
|
// Partial setup: the listener was acquired but the channel attach failed. Roll back
|
||||||
|
// exactly what was acquired (the listener) before the failure escapes, so no leaked
|
||||||
|
// subscription survives; the caller converts the rethrow into a total safe failure.
|
||||||
|
try {
|
||||||
|
unsubscribe();
|
||||||
|
} catch {
|
||||||
|
/* idempotent teardown */
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
unsubscribe();
|
||||||
|
} catch {
|
||||||
|
/* idempotent teardown */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.agentService.removeChannel(conversationId, stream.channelId, scope);
|
||||||
|
} catch {
|
||||||
|
/* idempotent teardown */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds an atomically one-shot, scope-rechecking dispatch lease. */
|
||||||
|
private buildLease(
|
||||||
|
conversationId: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
content: string,
|
||||||
|
attachments: VerifiedDiscordIngressContext['attachments'],
|
||||||
|
detach: () => void,
|
||||||
|
presentation: LegacySessionPresentation,
|
||||||
|
): LegacySocketTurnLease & VerifiedDiscordTurnLease {
|
||||||
|
let dispatched = false;
|
||||||
|
let disposed = false;
|
||||||
|
return {
|
||||||
|
presentation,
|
||||||
|
dispatch: async (): Promise<LegacyRuntimeResult<void>> => {
|
||||||
|
if (dispatched) {
|
||||||
|
return { ok: false, code: 'turn_already_dispatched', retryable: false };
|
||||||
|
}
|
||||||
|
dispatched = true;
|
||||||
|
try {
|
||||||
|
await this.agentService.prompt(conversationId, content, scope, attachments);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Legacy dispatch failed for conversation=${conversationId}`,
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
return { ok: false, code: 'operation_failed', retryable: false };
|
||||||
|
}
|
||||||
|
return { ok: true, value: undefined };
|
||||||
|
},
|
||||||
|
dispose: async (): Promise<void> => {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
detach();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes a raw agent event into the redaction-agnostic transport event, or drops it. */
|
||||||
|
private normalizeEvent(
|
||||||
|
conversationId: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
event: AgentSessionEvent,
|
||||||
|
): LegacyRuntimeEvent | undefined {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'agent_start':
|
||||||
|
return { type: 'started' };
|
||||||
|
case 'agent_end':
|
||||||
|
return { type: 'settled', ...this.usageFor(conversationId, scope) };
|
||||||
|
case 'message_update': {
|
||||||
|
const assistant = event.assistantMessageEvent;
|
||||||
|
if (assistant.type === 'text_delta') return { type: 'text_delta', text: assistant.delta };
|
||||||
|
if (assistant.type === 'thinking_delta') {
|
||||||
|
return { type: 'thinking_delta', text: assistant.delta };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
case 'tool_execution_start':
|
||||||
|
return { type: 'tool_started', toolCallId: event.toolCallId, toolName: event.toolName };
|
||||||
|
case 'tool_execution_end':
|
||||||
|
return {
|
||||||
|
type: 'tool_finished',
|
||||||
|
toolCallId: event.toolCallId,
|
||||||
|
toolName: event.toolName,
|
||||||
|
isError: event.isError,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gathers terminal usage from the Pi session and records it into session metrics.
|
||||||
|
* Embedded owns AgentService metrics; the gateway never touches `piSession` stats.
|
||||||
|
*/
|
||||||
|
private usageFor(conversationId: string, scope: ActorTenantScope): { usage?: LegacyUsage } {
|
||||||
|
const session = this.agentService.getSession(conversationId, scope);
|
||||||
|
const piSession = session?.piSession;
|
||||||
|
const stats = piSession?.getSessionStats();
|
||||||
|
if (!session || !stats) return {};
|
||||||
|
const contextUsage = piSession?.getContextUsage();
|
||||||
|
|
||||||
|
const tokens = {
|
||||||
|
input: stats.tokens?.input ?? 0,
|
||||||
|
output: stats.tokens?.output ?? 0,
|
||||||
|
cacheRead: stats.tokens?.cacheRead ?? 0,
|
||||||
|
cacheWrite: stats.tokens?.cacheWrite ?? 0,
|
||||||
|
total: stats.tokens?.total ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.agentService.recordTokenUsage(conversationId, { ...tokens });
|
||||||
|
|
||||||
|
return {
|
||||||
|
usage: {
|
||||||
|
provider: session.provider,
|
||||||
|
modelId: session.modelId,
|
||||||
|
thinkingLevel: piSession?.thinkingLevel ?? 'off',
|
||||||
|
tokens,
|
||||||
|
cost: stats.cost ?? 0,
|
||||||
|
context: {
|
||||||
|
percent: contextUsage?.percent ?? null,
|
||||||
|
window: contextUsage?.contextWindow ?? 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Presentation from a live session id, or undefined when no owned session exists. */
|
||||||
|
private presentationFor(
|
||||||
|
conversationId: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
): LegacySessionPresentation | undefined {
|
||||||
|
const session = this.agentService.getSession(conversationId, scope);
|
||||||
|
return session ? this.presentationForSession(session) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User-facing projection carrying no session handle, credential, or raw stats. */
|
||||||
|
private presentationForSession(session: AgentSession): LegacySessionPresentation {
|
||||||
|
return {
|
||||||
|
provider: session.provider,
|
||||||
|
modelId: session.modelId,
|
||||||
|
thinkingLevel: session.piSession.thinkingLevel,
|
||||||
|
availableThinkingLevels: session.piSession.getAvailableThinkingLevels(),
|
||||||
|
...(session.agentName ? { agentName: session.agentName } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shared terminal `conversation_unavailable` failure (missing/foreign/lost ownership). */
|
||||||
|
const CONVERSATION_UNAVAILABLE = {
|
||||||
|
ok: false as const,
|
||||||
|
code: 'conversation_unavailable' as const,
|
||||||
|
retryable: false as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Narrows a branded context scope to the `AgentService` actor/tenant scope (identical shape). */
|
||||||
|
function toScope(scope: Readonly<{ userId: string; tenantId: string }>): ActorTenantScope {
|
||||||
|
return { userId: scope.userId, tenantId: scope.tenantId };
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type {
|
||||||
|
AttachConversation,
|
||||||
|
ConversationSnapshot,
|
||||||
|
DetachConversation,
|
||||||
|
HarnessActorContext,
|
||||||
|
HarnessConversationService,
|
||||||
|
HarnessEventEnvelope,
|
||||||
|
HarnessSelection,
|
||||||
|
SendHarnessTurn,
|
||||||
|
TurnReceipt,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { HarnessChatRuntime } from './harness-chat.runtime.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, Step One (harness runtime). Proves the `pi-rpc` runtime executes
|
||||||
|
* exclusively through the {@link HarnessConversationService} RPC boundary and
|
||||||
|
* forwards the caller's exact selection tuple and idempotency key without
|
||||||
|
* substitution. Red-first: the runtime is an unimplemented stub, so every
|
||||||
|
* delegation assertion fails until Step Three.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const context: HarnessActorContext = {
|
||||||
|
actorId: 'actor-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
seatId: 'seat-1',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const selection: HarnessSelection = {
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
modelId: 'claude-opus-4-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
const conversationId = '11111111-1111-4111-8111-111111111111';
|
||||||
|
const idempotencyKey = '22222222-2222-4222-8222-222222222222';
|
||||||
|
|
||||||
|
const sendInput: SendHarnessTurn & { idempotencyKey: string } = {
|
||||||
|
context,
|
||||||
|
conversationId,
|
||||||
|
selection,
|
||||||
|
turnId: 'turn-abc',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
content: 'hello',
|
||||||
|
idempotencyKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachInput: AttachConversation & { afterSequence?: number } = {
|
||||||
|
context,
|
||||||
|
conversationId,
|
||||||
|
clientId: 'client-1',
|
||||||
|
selection,
|
||||||
|
afterSequence: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const detachInput: DetachConversation = {
|
||||||
|
context,
|
||||||
|
conversationId,
|
||||||
|
clientId: 'client-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RecordedCalls {
|
||||||
|
attach: (AttachConversation & { afterSequence?: number })[];
|
||||||
|
detach: DetachConversation[];
|
||||||
|
send: (SendHarnessTurn & { idempotencyKey: string })[];
|
||||||
|
subscribeFrom: { conversationId: string; afterSequence: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot: ConversationSnapshot = {
|
||||||
|
session: {
|
||||||
|
conversationId,
|
||||||
|
nativeSessionId: 'native-1',
|
||||||
|
seatId: 'seat-1',
|
||||||
|
selection,
|
||||||
|
state: 'idle',
|
||||||
|
attachedClientIds: ['client-1'],
|
||||||
|
},
|
||||||
|
lastSequence: 0,
|
||||||
|
replay: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function build(): { runtime: HarnessChatRuntime; calls: RecordedCalls } {
|
||||||
|
const calls: RecordedCalls = { attach: [], detach: [], send: [], subscribeFrom: [] };
|
||||||
|
const service: HarnessConversationService = {
|
||||||
|
attach: (input) => {
|
||||||
|
calls.attach.push(input);
|
||||||
|
return Promise.resolve(snapshot);
|
||||||
|
},
|
||||||
|
detach: (input) => {
|
||||||
|
calls.detach.push(input);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
send: (input) => {
|
||||||
|
calls.send.push(input);
|
||||||
|
// The service echoes only the requested tuple; there is no representable substitute.
|
||||||
|
const receipt: TurnReceipt = {
|
||||||
|
conversationId: input.conversationId,
|
||||||
|
turnId: 'turn-server',
|
||||||
|
correlationId: input.correlationId,
|
||||||
|
state: 'accepted',
|
||||||
|
selection: input.selection,
|
||||||
|
};
|
||||||
|
return Promise.resolve(receipt);
|
||||||
|
},
|
||||||
|
subscribeFrom: (id, afterSequence) => {
|
||||||
|
calls.subscribeFrom.push({ conversationId: id, afterSequence });
|
||||||
|
|
||||||
|
return (async function* (): AsyncIterable<HarnessEventEnvelope> {
|
||||||
|
return;
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { runtime: new HarnessChatRuntime(service), calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HarnessChatRuntime', () => {
|
||||||
|
it('is the harness runtime kind and needs only a HarnessConversationService', () => {
|
||||||
|
const { runtime } = build();
|
||||||
|
expect(runtime.kind).toBe('harness');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates send to the conversation service with the exact tuple and idempotency key', async () => {
|
||||||
|
const { runtime, calls } = build();
|
||||||
|
|
||||||
|
const receipt = await runtime.send(sendInput);
|
||||||
|
|
||||||
|
expect(calls.send).toHaveLength(1);
|
||||||
|
const firstSend = calls.send[0]!;
|
||||||
|
expect(firstSend).toEqual(sendInput);
|
||||||
|
expect(firstSend.idempotencyKey).toBe(idempotencyKey);
|
||||||
|
expect(firstSend.selection).toEqual(selection);
|
||||||
|
// The runtime must not substitute an effective tuple onto the receipt.
|
||||||
|
expect(receipt.selection).toEqual(selection);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates attach to the conversation service and returns its snapshot', async () => {
|
||||||
|
const { runtime, calls } = build();
|
||||||
|
|
||||||
|
const result = await runtime.attach(attachInput);
|
||||||
|
|
||||||
|
expect(calls.attach).toHaveLength(1);
|
||||||
|
expect(calls.attach[0]).toEqual(attachInput);
|
||||||
|
expect(result).toBe(snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates detach to the conversation service', async () => {
|
||||||
|
const { runtime, calls } = build();
|
||||||
|
|
||||||
|
await runtime.detach(detachInput);
|
||||||
|
|
||||||
|
expect(calls.detach).toHaveLength(1);
|
||||||
|
expect(calls.detach[0]).toEqual(detachInput);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates subscribeFrom to the conversation service journal replay', async () => {
|
||||||
|
const { runtime, calls } = build();
|
||||||
|
|
||||||
|
const iterable = runtime.subscribeFrom(conversationId, 7);
|
||||||
|
// Drain to prove it is the service-backed async iterable, not a fabricated one.
|
||||||
|
const drained: unknown[] = [];
|
||||||
|
for await (const event of iterable) {
|
||||||
|
drained.push(event);
|
||||||
|
}
|
||||||
|
expect(drained).toHaveLength(0);
|
||||||
|
|
||||||
|
expect(calls.subscribeFrom).toHaveLength(1);
|
||||||
|
expect(calls.subscribeFrom[0]).toEqual({ conversationId, afterSequence: 7 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type {
|
||||||
|
AttachConversation,
|
||||||
|
ConversationSnapshot,
|
||||||
|
DetachConversation,
|
||||||
|
HarnessConversationService,
|
||||||
|
HarnessEventEnvelope,
|
||||||
|
SendHarnessTurn,
|
||||||
|
TurnReceipt,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import type { ChatRuntime } from './chat-runtime.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `pi-rpc` chat runtime. It executes browser chat exclusively through the
|
||||||
|
* harness-neutral {@link HarnessConversationService} RPC boundary — it never
|
||||||
|
* touches the embedded `AgentService`/`ProviderService`/`RoutingEngineService`
|
||||||
|
* stack, and it forwards the caller's exact selection tuple and idempotency key
|
||||||
|
* without substitution.
|
||||||
|
*
|
||||||
|
* It owns no state and adds no policy: every method forwards the caller's exact
|
||||||
|
* argument to the injected {@link HarnessConversationService} and returns its
|
||||||
|
* result unchanged, so the requested selection tuple and idempotency key can
|
||||||
|
* never be substituted on the way through.
|
||||||
|
*/
|
||||||
|
export class HarnessChatRuntime implements ChatRuntime {
|
||||||
|
readonly kind = 'harness' as const;
|
||||||
|
|
||||||
|
constructor(private readonly conversations: HarnessConversationService) {}
|
||||||
|
|
||||||
|
attach(input: AttachConversation & { afterSequence?: number }): Promise<ConversationSnapshot> {
|
||||||
|
return this.conversations.attach(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
detach(input: DetachConversation): Promise<void> {
|
||||||
|
return this.conversations.detach(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(input: SendHarnessTurn & { idempotencyKey: string }): Promise<TurnReceipt> {
|
||||||
|
return this.conversations.send(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeFrom(
|
||||||
|
conversationId: string,
|
||||||
|
afterSequence: number,
|
||||||
|
): AsyncIterable<HarnessEventEnvelope> {
|
||||||
|
return this.conversations.subscribeFrom(conversationId, afterSequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||||
@@ -12,6 +13,7 @@ const mockRegistry = {
|
|||||||
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
||||||
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
|
{ name: 'mcp', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
],
|
],
|
||||||
skills: [],
|
skills: [],
|
||||||
})),
|
})),
|
||||||
@@ -72,7 +74,20 @@ const mockChatGateway = {
|
|||||||
broadcastSessionInfo: vi.fn(),
|
broadcastSessionInfo: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService {
|
const mockMcpClient = {
|
||||||
|
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getServerStatuses: vi.fn(() => []),
|
||||||
|
getToolDefinitions: vi.fn(() => []),
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildService(
|
||||||
|
redis: typeof mockRedis | null = mockRedis,
|
||||||
|
mcpClient: {
|
||||||
|
reconnectServer: ReturnType<typeof vi.fn>;
|
||||||
|
getServerStatuses: ReturnType<typeof vi.fn>;
|
||||||
|
getToolDefinitions: ReturnType<typeof vi.fn>;
|
||||||
|
} = mockMcpClient,
|
||||||
|
): CommandExecutorService {
|
||||||
return new CommandExecutorService(
|
return new CommandExecutorService(
|
||||||
mockRegistry as never,
|
mockRegistry as never,
|
||||||
mockAgentService as never,
|
mockAgentService as never,
|
||||||
@@ -82,7 +97,7 @@ function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecut
|
|||||||
mockBrain as never,
|
mockBrain as never,
|
||||||
null,
|
null,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
null,
|
mcpClient as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,4 +273,124 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
expect(result.command).toBe('tools');
|
expect(result.command).toBe('tools');
|
||||||
expect(result.message).toContain('tools');
|
expect(result.message).toContain('tools');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Top-level catch sanitization (P3-4 re-review finding #1): a rejected
|
||||||
|
// Redis `set` inside /provider login is the only reachable path into the
|
||||||
|
// top-level catch in `execute()`. The raw exception must be logged
|
||||||
|
// server-side but never handed back to the socket client.
|
||||||
|
it('sanitizes the top-level command catch, logging the raw exception but never returning it to the client', async () => {
|
||||||
|
const distinctiveRawFailure = 'ECONNREFUSED distinctive-raw-redis-failure-token-9f31';
|
||||||
|
const rawError = new Error(distinctiveRawFailure);
|
||||||
|
const failingRedis = {
|
||||||
|
set: vi.fn().mockRejectedValue(rawError),
|
||||||
|
get: vi.fn(),
|
||||||
|
del: vi.fn(),
|
||||||
|
};
|
||||||
|
const failingService = buildService(failingRedis as unknown as typeof mockRedis);
|
||||||
|
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'provider',
|
||||||
|
args: 'login anthropic',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const result = await failingService.execute(payload, userScope);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.command).toBe('provider');
|
||||||
|
expect(result.message).toBe('Command failed due to an internal error.');
|
||||||
|
expect(result.message).not.toContain(distinctiveRawFailure);
|
||||||
|
expect(result.message).not.toContain('ECONNREFUSED');
|
||||||
|
|
||||||
|
// The real exception is still logged server-side, as the raw Error
|
||||||
|
// object itself (not stringified/interpolated into the log message).
|
||||||
|
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||||
|
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(rawError));
|
||||||
|
expect(loggedRawError).toBe(true);
|
||||||
|
|
||||||
|
loggerErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inner catch sanitization (P3-5 operator ruling): every catch in
|
||||||
|
// command-executor.service.ts that returns a SlashCommandResultPayload
|
||||||
|
// must sanitize the client-facing message the same way the top-level
|
||||||
|
// catch does, while still logging the raw exception server-side.
|
||||||
|
it('/agent new sanitizes agent-creation failures, logging the raw exception but never returning it to the client', async () => {
|
||||||
|
const marker = new Error('distinctive-agent-create-failure-token-A17f');
|
||||||
|
mockBrain.agents.create.mockRejectedValueOnce(marker);
|
||||||
|
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'agent',
|
||||||
|
args: 'new my-new-agent',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const result = await service.execute(payload, userScope);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.command).toBe('agent');
|
||||||
|
expect(result.message).toBe('Failed to create agent due to an internal error.');
|
||||||
|
expect(result.message).not.toContain('distinctive-agent-create-failure-token-A17f');
|
||||||
|
|
||||||
|
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||||
|
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
||||||
|
expect(loggedRawError).toBe(true);
|
||||||
|
|
||||||
|
loggerErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/agent <name> switch sanitizes agent-lookup failures, logging the raw exception but never returning it to the client', async () => {
|
||||||
|
const marker = new Error('distinctive-agent-switch-failure-token-B29c');
|
||||||
|
mockBrain.agents.findByName.mockRejectedValueOnce(marker);
|
||||||
|
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'agent',
|
||||||
|
args: 'some-other-agent',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const result = await service.execute(payload, userScope);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.command).toBe('agent');
|
||||||
|
expect(result.message).toBe('Failed to switch agent due to an internal error.');
|
||||||
|
expect(result.message).not.toContain('distinctive-agent-switch-failure-token-B29c');
|
||||||
|
|
||||||
|
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||||
|
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
||||||
|
expect(loggedRawError).toBe(true);
|
||||||
|
|
||||||
|
loggerErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/mcp reconnect sanitizes MCP client failures, logging the raw exception but never returning it to the client', async () => {
|
||||||
|
const marker = new Error('distinctive-mcp-reconnect-failure-token-C33e');
|
||||||
|
const mockMcpClient = {
|
||||||
|
reconnectServer: vi.fn().mockRejectedValue(marker),
|
||||||
|
getServerStatuses: vi.fn(() => []),
|
||||||
|
getToolDefinitions: vi.fn(() => []),
|
||||||
|
};
|
||||||
|
const mcpService = buildService(mockRedis, mockMcpClient);
|
||||||
|
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'mcp',
|
||||||
|
args: 'reconnect my-server',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const result = await mcpService.execute(payload, userScope);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.command).toBe('mcp');
|
||||||
|
expect(result.message).toBe(
|
||||||
|
'Failed to reconnect MCP server "my-server" due to an internal error.',
|
||||||
|
);
|
||||||
|
expect(result.message).not.toContain('distinctive-mcp-reconnect-failure-token-C33e');
|
||||||
|
|
||||||
|
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||||
|
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
||||||
|
expect(loggedRawError).toBe(true);
|
||||||
|
|
||||||
|
loggerErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ const authorization = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockMcpClient = {
|
||||||
|
getServerStatuses: vi.fn(() => []),
|
||||||
|
getToolDefinitions: vi.fn(() => []),
|
||||||
|
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
|
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
|
||||||
return new CommandExecutorService(
|
return new CommandExecutorService(
|
||||||
registry as never,
|
registry as never,
|
||||||
@@ -46,7 +52,7 @@ function buildExecutor(authorizationService: unknown = authorization): CommandEx
|
|||||||
{ agents: {} } as never,
|
{ agents: {} } as never,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
mockMcpClient as never,
|
||||||
authorizationService as never,
|
authorizationService as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ export class CommandExecutorService {
|
|||||||
@Optional()
|
@Optional()
|
||||||
@Inject(forwardRef(() => ChatGateway))
|
@Inject(forwardRef(() => ChatGateway))
|
||||||
private readonly chatGateway: ChatGateway | null,
|
private readonly chatGateway: ChatGateway | null,
|
||||||
@Optional()
|
@Inject(McpClientService) private readonly mcpClient: McpClientService,
|
||||||
@Inject(McpClientService)
|
|
||||||
private readonly mcpClient: McpClientService | null,
|
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(CommandAuthorizationService)
|
@Inject(CommandAuthorizationService)
|
||||||
private readonly authorization: CommandAuthorizationService | null = null,
|
private readonly authorization: CommandAuthorizationService | null = null,
|
||||||
@@ -159,8 +157,13 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Command /${command} failed: ${err}`);
|
this.logger.error(`Command /${command} failed`, err);
|
||||||
return { command, conversationId, success: false, message: String(err) };
|
return {
|
||||||
|
command,
|
||||||
|
conversationId,
|
||||||
|
success: false,
|
||||||
|
message: 'Command failed due to an internal error.',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,11 +339,11 @@ export class CommandExecutorService {
|
|||||||
data: { agentId: newAgent.id, agentName: newAgent.name },
|
data: { agentId: newAgent.id, agentName: newAgent.name },
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to create agent: ${err}`);
|
this.logger.error(`Failed to create agent "${namePart}" for user ${userId}`, err);
|
||||||
return {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
success: false,
|
success: false,
|
||||||
message: `Failed to create agent: ${String(err)}`,
|
message: 'Failed to create agent due to an internal error.',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -391,11 +394,11 @@ export class CommandExecutorService {
|
|||||||
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
|
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to switch agent "${agentName}": ${err}`);
|
this.logger.error(`Failed to switch agent "${agentName}"`, err);
|
||||||
return {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
success: false,
|
success: false,
|
||||||
message: `Failed to switch agent: ${String(err)}`,
|
message: 'Failed to switch agent due to an internal error.',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -543,15 +546,6 @@ export class CommandExecutorService {
|
|||||||
args: string | null,
|
args: string | null,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
): Promise<SlashCommandResultPayload> {
|
): Promise<SlashCommandResultPayload> {
|
||||||
if (!this.mcpClient) {
|
|
||||||
return {
|
|
||||||
command: 'mcp',
|
|
||||||
conversationId,
|
|
||||||
success: false,
|
|
||||||
message: 'MCP client service is not available.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const action = args?.trim().split(/\s+/)[0] ?? 'status';
|
const action = args?.trim().split(/\s+/)[0] ?? 'status';
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -608,11 +602,12 @@ export class CommandExecutorService {
|
|||||||
message: `MCP server "${serverName}" reconnected successfully.`,
|
message: `MCP server "${serverName}" reconnected successfully.`,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to reconnect MCP server "${serverName}"`, err);
|
||||||
return {
|
return {
|
||||||
command: 'mcp',
|
command: 'mcp',
|
||||||
conversationId,
|
conversationId,
|
||||||
success: false,
|
success: false,
|
||||||
message: `Failed to reconnect MCP server "${serverName}": ${err instanceof Error ? err.message : String(err)}`,
|
message: `Failed to reconnect MCP server "${serverName}" due to an internal error.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
* - Unknown command returns descriptive error
|
* - Unknown command returns descriptive error
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { CommandsModule } from './commands.module.js';
|
||||||
|
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||||
@@ -47,6 +49,12 @@ const mockBrain = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockMcpClient = {
|
||||||
|
getServerStatuses: vi.fn(() => []),
|
||||||
|
getToolDefinitions: vi.fn(() => []),
|
||||||
|
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildRegistry(): CommandRegistryService {
|
function buildRegistry(): CommandRegistryService {
|
||||||
@@ -65,7 +73,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
|
|||||||
mockBrain as never,
|
mockBrain as never,
|
||||||
null, // reloadService (optional)
|
null, // reloadService (optional)
|
||||||
null, // chatGateway (optional)
|
null, // chatGateway (optional)
|
||||||
null, // mcpClient (optional)
|
mockMcpClient as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +161,15 @@ describe('CommandRegistryService — integration', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Module Wiring Tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CommandsModule — Nest wiring', () => {
|
||||||
|
it('CommandsModule imports McpClientModule in its Nest metadata', () => {
|
||||||
|
const imports = Reflect.getMetadata('imports', CommandsModule) ?? [];
|
||||||
|
expect(imports).toContain(McpClientModule);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Executor Tests ───────────────────────────────────────────────────────────
|
// ─── Executor Tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('CommandExecutorService — integration', () => {
|
describe('CommandExecutorService — integration', () => {
|
||||||
@@ -259,4 +276,14 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
expect(result.command).toBe(cmd);
|
expect(result.command).toBe(cmd);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /mcp status reaches the required McpClientService and never reports it unavailable
|
||||||
|
it('/mcp status calls the wired McpClientService and reports the no-servers message', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'mcp', conversationId };
|
||||||
|
const result = await executor.execute(payload, userScope);
|
||||||
|
expect(mockMcpClient.getServerStatuses).toHaveBeenCalledOnce();
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('No MCP servers configured.');
|
||||||
|
expect(result.message).not.toBe('MCP client service is not available.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { MosaicConfig } from '@mosaicstack/config';
|
|||||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||||
import { ChatModule } from '../chat/chat.module.js';
|
import { ChatModule } from '../chat/chat.module.js';
|
||||||
import { GCModule } from '../gc/gc.module.js';
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
|
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
import { ReloadModule } from '../reload/reload.module.js';
|
import { ReloadModule } from '../reload/reload.module.js';
|
||||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
@@ -14,7 +15,12 @@ import { COMMANDS_REDIS } from './commands.tokens.js';
|
|||||||
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [GCModule, forwardRef(() => ReloadModule), forwardRef(() => ChatModule)],
|
imports: [
|
||||||
|
GCModule,
|
||||||
|
McpClientModule,
|
||||||
|
forwardRef(() => ReloadModule),
|
||||||
|
forwardRef(() => ChatModule),
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: COMMANDS_QUEUE_HANDLE,
|
provide: COMMANDS_QUEUE_HANDLE,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
||||||
|
import { resolveGatewayConfigPath } from '../env.js';
|
||||||
|
|
||||||
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: MOSAIC_CONFIG,
|
provide: MOSAIC_CONFIG,
|
||||||
useFactory: (): MosaicConfig => loadConfig(),
|
useFactory: (): MosaicConfig => loadConfig(resolveGatewayConfigPath()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [MOSAIC_CONFIG],
|
exports: [MOSAIC_CONFIG],
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { ChatRuntimeMode } from '../chat/chat-runtime.js';
|
||||||
|
import { ConversationsController } from './conversations.controller.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 5 harness fence for the conversations REST write path.
|
||||||
|
*
|
||||||
|
* Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the
|
||||||
|
* legacy direct-repository write via `POST /api/conversations/:id/messages` must be refused with a
|
||||||
|
* fixed typed `runtime_unsupported` BEFORE the repository is touched — never a duplicate write.
|
||||||
|
* Under `legacy` the endpoint keeps its current behaviour and writes through `brain.conversations`.
|
||||||
|
*
|
||||||
|
* Item 3 (single runtime-mode source of truth): the mode is the router's ONE init-time resolution,
|
||||||
|
* injected into the controller and read as `router.runtimeMode`. It is NOT re-derived from
|
||||||
|
* `process.env` at request time. The two "env is flipped after construction" tests below are the
|
||||||
|
* load-bearing guard: they pass only because the controller reads the fixed injected mode, and turn
|
||||||
|
* RED the instant the fence is reverted to `resolveChatRuntimeMode(process.env)`.
|
||||||
|
*/
|
||||||
|
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
|
||||||
|
const USER = { id: 'user-1' };
|
||||||
|
|
||||||
|
function sendMessageDto() {
|
||||||
|
return {
|
||||||
|
role: 'user' as const,
|
||||||
|
content: 'hello from the legacy REST write path',
|
||||||
|
metadata: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function brainWithMessageSpy() {
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({
|
||||||
|
id: 'message-1',
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
role: 'user',
|
||||||
|
content: 'hello from the legacy REST write path',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
brain: { conversations: { addMessage } } as never,
|
||||||
|
addMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The controller only needs the router's immutable `runtimeMode`; supply exactly that. */
|
||||||
|
function routerFixedTo(mode: ChatRuntimeMode) {
|
||||||
|
return { runtimeMode: mode };
|
||||||
|
}
|
||||||
|
|
||||||
|
let priorMode: string | undefined;
|
||||||
|
|
||||||
|
describe('conversations REST write path — Task 5 harness fence', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
priorMode = process.env['CHAT_HARNESS_RUNTIME'];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (priorMode === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
|
||||||
|
else process.env['CHAT_HARNESS_RUNTIME'] = priorMode;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses the legacy repository write when the router resolved pi-rpc, before any write', async () => {
|
||||||
|
const { brain, addMessage } = brainWithMessageSpy();
|
||||||
|
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
|
||||||
|
).rejects.toMatchObject({ code: 'runtime_unsupported' });
|
||||||
|
|
||||||
|
// Load-bearing: the durable/harness path owns pi-rpc persistence — the legacy repo must not be
|
||||||
|
// written, so no duplicate message can be produced.
|
||||||
|
expect(addMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes through the repository when the router resolved legacy (GREEN control)', async () => {
|
||||||
|
const { brain, addMessage } = brainWithMessageSpy();
|
||||||
|
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
|
||||||
|
|
||||||
|
const result = await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
|
||||||
|
|
||||||
|
expect(addMessage).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
role: 'user',
|
||||||
|
content: 'hello from the legacy REST write path',
|
||||||
|
metadata: undefined,
|
||||||
|
},
|
||||||
|
USER.id,
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ id: 'message-1', conversationId: CONVERSATION_ID });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps refusing under a pi-rpc router even when CHAT_HARNESS_RUNTIME is flipped to legacy after startup', async () => {
|
||||||
|
// The runtime mode is fixed at module init. A later env mutation must not reopen the fence:
|
||||||
|
// a request-time `resolveChatRuntimeMode(process.env)` read would see `legacy` and wrongly write.
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
|
||||||
|
const { brain, addMessage } = brainWithMessageSpy();
|
||||||
|
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
|
||||||
|
).rejects.toMatchObject({ code: 'runtime_unsupported' });
|
||||||
|
|
||||||
|
expect(addMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps writing under a legacy router even when CHAT_HARNESS_RUNTIME is flipped to pi-rpc after startup', async () => {
|
||||||
|
// Symmetric guard: a legacy-resolved router must keep writing regardless of the live env, so a
|
||||||
|
// request-time env read of `pi-rpc` cannot spuriously refuse a legitimate legacy write.
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
const { brain, addMessage } = brainWithMessageSpy();
|
||||||
|
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
|
||||||
|
|
||||||
|
await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
|
||||||
|
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Inject,
|
Inject,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -19,6 +20,7 @@ import type { Brain } from '@mosaicstack/brain';
|
|||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js';
|
||||||
import {
|
import {
|
||||||
CreateConversationDto,
|
CreateConversationDto,
|
||||||
UpdateConversationDto,
|
UpdateConversationDto,
|
||||||
@@ -26,10 +28,41 @@ import {
|
|||||||
SearchMessagesDto,
|
SearchMessagesDto,
|
||||||
} from './conversations.dto.js';
|
} from './conversations.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the
|
||||||
|
* legacy direct-repository write must fail closed with a fixed typed `runtime_unsupported` before
|
||||||
|
* the repository is touched — never a duplicate write. The `code` field is exposed at the top level
|
||||||
|
* so callers can discriminate the refusal while the 503 status carries the browser-safe surface.
|
||||||
|
*/
|
||||||
|
class HarnessRuntimeWriteUnsupportedException extends HttpException {
|
||||||
|
readonly code = 'runtime_unsupported' as const;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
{
|
||||||
|
code: 'runtime_unsupported',
|
||||||
|
message:
|
||||||
|
'Conversation message writes are handled by the harness runtime on this deployment.',
|
||||||
|
},
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('api/conversations')
|
@Controller('api/conversations')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class ConversationsController {
|
export class ConversationsController {
|
||||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
/**
|
||||||
|
* `router` supplies the ONE immutable runtime mode resolved at module init (Task 5, item 3).
|
||||||
|
* The pre-write fence reads `router.runtimeMode`, never `resolveChatRuntimeMode(process.env)` at
|
||||||
|
* request time — a single source of truth, so the controller cannot disagree with the router
|
||||||
|
* about the live runtime if the environment is mutated after startup. Narrowed to `runtimeMode`
|
||||||
|
* so this class depends on nothing else the router exposes.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
@Inject(BRAIN) private readonly brain: Brain,
|
||||||
|
@Inject(ChatRuntimeRouter) private readonly router: Pick<ChatRuntimeRouter, 'runtimeMode'>,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(@CurrentUser() user: { id: string }) {
|
async list(@CurrentUser() user: { id: string }) {
|
||||||
@@ -94,6 +127,13 @@ export class ConversationsController {
|
|||||||
@Body() dto: SendMessageDto,
|
@Body() dto: SendMessageDto,
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
) {
|
) {
|
||||||
|
// Fail the legacy repository write closed under pi-rpc BEFORE touching the repository — the
|
||||||
|
// harness path owns persistence there, so a direct write would duplicate the message. The mode
|
||||||
|
// comes from the router's init-time resolution, not a request-time env read.
|
||||||
|
if (this.router.runtimeMode === 'pi-rpc') {
|
||||||
|
throw new HarnessRuntimeWriteUnsupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
const message = await this.brain.conversations.addMessage(
|
const message = await this.brain.conversations.addMessage(
|
||||||
{
|
{
|
||||||
conversationId: id,
|
conversationId: id,
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ChatModule } from '../chat/chat.module.js';
|
||||||
import { ConversationsController } from './conversations.controller.js';
|
import { ConversationsController } from './conversations.controller.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports {@link ChatModule} solely to inject its exported {@link ChatRuntimeRouter} into
|
||||||
|
* {@link ConversationsController}, so the REST write fence reads the same init-time runtime mode the
|
||||||
|
* router resolved — one source of truth, no duplicate provider, no global token, no AppModule edit.
|
||||||
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [ChatModule],
|
||||||
controllers: [ConversationsController],
|
controllers: [ConversationsController],
|
||||||
})
|
})
|
||||||
export class ConversationsModule {}
|
export class ConversationsModule {}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CoordModule } from './coord.module.js';
|
||||||
|
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
|
||||||
|
describe('CoordModule DI (compiled-metadata boot)', () => {
|
||||||
|
it('resolves InteractionCoordinationService through Nest DI', async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({ imports: [CoordModule] })
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: (): boolean => true })
|
||||||
|
.compile();
|
||||||
|
expect(moduleRef.get(InteractionCoordinationService)).toBeInstanceOf(
|
||||||
|
InteractionCoordinationService,
|
||||||
|
);
|
||||||
|
await moduleRef.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
InteractionCoordinationClient,
|
InteractionCoordinationClient,
|
||||||
type CoordinationObservation,
|
type CoordinationObservation,
|
||||||
@@ -13,6 +13,7 @@ import type { CreateHandoffDto } from './interaction-coordination.dto.js';
|
|||||||
|
|
||||||
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
||||||
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
||||||
|
export const HANDOFF_ID_FACTORY = Symbol('HANDOFF_ID_FACTORY');
|
||||||
|
|
||||||
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
||||||
const MAX_TRACKED_HANDOFFS = 1_000;
|
const MAX_TRACKED_HANDOFFS = 1_000;
|
||||||
@@ -60,6 +61,8 @@ export class InteractionCoordinationService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
||||||
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
||||||
|
@Optional()
|
||||||
|
@Inject(HANDOFF_ID_FACTORY)
|
||||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { detectFromEnv, loadConfig } from '@mosaicstack/config';
|
||||||
|
|
||||||
|
type TierSource =
|
||||||
|
| 'process environment'
|
||||||
|
| 'daemon .env'
|
||||||
|
| 'monorepo-root .env'
|
||||||
|
| 'gateway-local .env'
|
||||||
|
| 'default';
|
||||||
|
|
||||||
|
type BootSource = TierSource | 'mosaic.config.json';
|
||||||
|
|
||||||
|
export interface GatewayDotenvPaths {
|
||||||
|
daemonEnv: string;
|
||||||
|
monorepoRootEnv: string;
|
||||||
|
gatewayLocalEnv: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export function resolveGatewayDotenvPaths(
|
||||||
|
anchor: string = here,
|
||||||
|
homeBase: string = homedir(),
|
||||||
|
): GatewayDotenvPaths {
|
||||||
|
return {
|
||||||
|
daemonEnv: join(homeBase, '.config', 'mosaic', 'gateway', '.env'),
|
||||||
|
monorepoRootEnv: resolve(anchor, '../../..', '.env'),
|
||||||
|
gatewayLocalEnv: resolve(anchor, '..', '.env'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGatewayConfigPath(anchor: string = here): string {
|
||||||
|
// GATEWAY_HOME is daemon-created 0700; its env override adds no authority because env can set MOSAIC_STORAGE_TIER.
|
||||||
|
const gatewayHome = resolve(
|
||||||
|
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
|
||||||
|
);
|
||||||
|
const daemonConfig = join(gatewayHome, 'mosaic.config.json');
|
||||||
|
const gatewayLocalConfig = resolve(anchor, '..', 'mosaic.config.json');
|
||||||
|
const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json');
|
||||||
|
|
||||||
|
if (existsSync(daemonConfig)) {
|
||||||
|
return daemonConfig;
|
||||||
|
}
|
||||||
|
if (existsSync(gatewayLocalConfig)) {
|
||||||
|
return gatewayLocalConfig;
|
||||||
|
}
|
||||||
|
if (existsSync(monorepoRootConfig)) {
|
||||||
|
return monorepoRootConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return monorepoRootConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadGatewayEnv(anchor: string = here, homeBase: string = homedir()): void {
|
||||||
|
const { daemonEnv, monorepoRootEnv, gatewayLocalEnv } = resolveGatewayDotenvPaths(
|
||||||
|
anchor,
|
||||||
|
homeBase,
|
||||||
|
);
|
||||||
|
const inheritedTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment';
|
||||||
|
const inheritedDatabaseUrl = process.env['DATABASE_URL'];
|
||||||
|
let databaseUrlSource: TierSource =
|
||||||
|
inheritedDatabaseUrl === undefined ? 'default' : 'process environment';
|
||||||
|
|
||||||
|
function loadAnchoredDotenv(
|
||||||
|
path: string,
|
||||||
|
sourceLabel: Exclude<TierSource, 'process environment' | 'default'>,
|
||||||
|
): void {
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
const beforeDatabaseUrl = process.env['DATABASE_URL'];
|
||||||
|
config({ path, quiet: true });
|
||||||
|
|
||||||
|
if (
|
||||||
|
beforeTier === undefined &&
|
||||||
|
process.env['MOSAIC_STORAGE_TIER'] !== undefined &&
|
||||||
|
tierSource === 'default'
|
||||||
|
) {
|
||||||
|
tierSource = sourceLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
beforeDatabaseUrl === undefined &&
|
||||||
|
process.env['DATABASE_URL'] !== undefined &&
|
||||||
|
databaseUrlSource === 'default'
|
||||||
|
) {
|
||||||
|
databaseUrlSource = sourceLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load .env from daemon config dir (global install / daemon mode) first.
|
||||||
|
// It takes precedence over file-based local-dev configuration.
|
||||||
|
loadAnchoredDotenv(daemonEnv, 'daemon .env');
|
||||||
|
|
||||||
|
// Load .env from the anchored monorepo root, then fill any remaining values
|
||||||
|
// from apps/gateway/.env when present.
|
||||||
|
loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env');
|
||||||
|
loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env');
|
||||||
|
|
||||||
|
const envOnlyTier = detectFromEnv().tier;
|
||||||
|
const configPath = resolveGatewayConfigPath(anchor);
|
||||||
|
const anchoredConfigExists = existsSync(configPath);
|
||||||
|
const resolvedTier = loadConfig(configPath).tier;
|
||||||
|
const configuredTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
const databaseUrlDeterminesTier = envOnlyTier === 'standalone' && configuredTier !== 'standalone';
|
||||||
|
const recognizedTierDeterminesTier =
|
||||||
|
(configuredTier === 'federated' ||
|
||||||
|
configuredTier === 'standalone' ||
|
||||||
|
configuredTier === 'local') &&
|
||||||
|
configuredTier === envOnlyTier;
|
||||||
|
|
||||||
|
let source: BootSource;
|
||||||
|
if (anchoredConfigExists) {
|
||||||
|
source = 'mosaic.config.json';
|
||||||
|
} else if (databaseUrlDeterminesTier && databaseUrlSource !== 'default') {
|
||||||
|
source = databaseUrlSource;
|
||||||
|
} else if (recognizedTierDeterminesTier && tierSource !== 'default') {
|
||||||
|
source = tierSource;
|
||||||
|
} else {
|
||||||
|
source = 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadGatewayEnv();
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import {
|
||||||
|
type CanActivate,
|
||||||
|
type ExecutionContext,
|
||||||
|
type INestApplication,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { HarnessRegistry } from './harness.registry.js';
|
||||||
|
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||||
|
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
||||||
|
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||||
|
// Import the REAL module (not a hand-listed controllers+mocks list) so an
|
||||||
|
// unresolved provider fails at app.init() — the #1145-class DI-boot guard.
|
||||||
|
import { HarnessModule } from './harness.module.js';
|
||||||
|
|
||||||
|
// A known-available tuple from the fake adapter's default catalog.
|
||||||
|
const VALID = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-mini' };
|
||||||
|
// A tuple whose provider/model are not in any catalog.
|
||||||
|
const UNKNOWN = { harnessId: 'fake', providerId: 'ghost-provider', modelId: 'ghost-model' };
|
||||||
|
// A tuple that is known in the catalog but flagged unavailable.
|
||||||
|
const UNAVAILABLE = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-legacy' };
|
||||||
|
|
||||||
|
const authGuard: CanActivate = {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
||||||
|
requestContext.user = { id: 'user-1' };
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function registryWithFake(): HarnessRegistry {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Harness selection HTTP surface', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let repository: HarnessSelectionRepository;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [HarnessModule],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue(authGuard)
|
||||||
|
.overrideProvider(HARNESS_REGISTRY)
|
||||||
|
.useValue(registryWithFake())
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
// Real in-memory repository from the module graph — proves the module wired it.
|
||||||
|
repository = moduleRef.get(HarnessSelectionRepository);
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
||||||
|
);
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset owner-scoped state between tests via the public API surface.
|
||||||
|
repository.set({ userId: 'user-1', tenantId: 'user-1' }, VALID);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET selection is server-scoped and ignores caller-supplied scope in the query', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/api/chat/preferences/selection')
|
||||||
|
.query({ userId: 'attacker', tenantId: 'attacker-tenant', seatId: 'attacker-seat' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
// The returned selection is user-1's (guard-derived scope), not the query's.
|
||||||
|
expect(response.body.selection).toEqual(VALID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT with a valid structured tuple persists and round-trips via GET', async () => {
|
||||||
|
const next = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-pro' };
|
||||||
|
|
||||||
|
const put = await request(app.getHttpServer())
|
||||||
|
.put('/api/chat/preferences/selection')
|
||||||
|
.send(next)
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
expect(put.status).toBe(200);
|
||||||
|
expect(put.body.selection).toEqual(next);
|
||||||
|
|
||||||
|
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||||
|
expect(get.status).toBe(200);
|
||||||
|
expect(get.body.selection).toEqual(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT with FREE TEXT is rejected 400 and does not mutate the stored selection', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.put('/api/chat/preferences/selection')
|
||||||
|
.send({ selection: 'gpt-4o' })
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
|
||||||
|
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||||
|
expect(get.body.selection).toEqual(VALID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['seatId', { ...VALID, seatId: 'attacker-seat' }],
|
||||||
|
['tenantId', { ...VALID, tenantId: 'attacker-tenant' }],
|
||||||
|
['userId', { ...VALID, userId: 'attacker' }],
|
||||||
|
['nativeSessionPath', { ...VALID, nativeSessionPath: '/var/native/x.jsonl' }],
|
||||||
|
['executable', { ...VALID, executable: '/usr/bin/evil' }],
|
||||||
|
['home', { ...VALID, home: '/home/attacker' }],
|
||||||
|
['cwd', { ...VALID, cwd: '/tmp/attacker' }],
|
||||||
|
])(
|
||||||
|
'PUT with an extra authority-bearing field (%s) is rejected 400 and does not mutate stored selection',
|
||||||
|
async (_name, body) => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.put('/api/chat/preferences/selection')
|
||||||
|
.send(body)
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
|
||||||
|
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||||
|
expect(get.body.selection).toEqual(VALID);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('PUT with an UNKNOWN tuple returns selection_invalid, unchanged and echoed unchanged (no fallback)', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.put('/api/chat/preferences/selection')
|
||||||
|
.send(UNKNOWN)
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(422);
|
||||||
|
expect(response.body.code).toBe('selection_invalid');
|
||||||
|
// Echoed back unchanged: no first-row / first-provider substitution.
|
||||||
|
expect(response.body.selection).toEqual(UNKNOWN);
|
||||||
|
|
||||||
|
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||||
|
expect(get.body.selection).toEqual(VALID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT with a KNOWN-but-UNAVAILABLE tuple returns model_unavailable, unchanged (distinct from selection_invalid)', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.put('/api/chat/preferences/selection')
|
||||||
|
.send(UNAVAILABLE)
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(422);
|
||||||
|
expect(response.body.code).toBe('model_unavailable');
|
||||||
|
expect(response.body.selection).toEqual(UNAVAILABLE);
|
||||||
|
|
||||||
|
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||||
|
expect(get.body.selection).toEqual(VALID);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Body, Controller, Get, HttpException, HttpStatus, Put, UseGuards } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
|
import { HarnessOperationError } from './harness.registry.js';
|
||||||
|
import { HarnessSelectionService } from './harness-selection.service.js';
|
||||||
|
import { HarnessSelectionInputDto, type SelectionResponseDto } from './harness.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat-preferences selection surface. The scope is ALWAYS derived on the server
|
||||||
|
* from the authenticated user (`scopeFromUser(CurrentUser)`); the request body and
|
||||||
|
* query string can never name another user, tenant, or seat. A typed selection
|
||||||
|
* failure (unknown tuple → `selection_invalid`, known-but-unavailable →
|
||||||
|
* `model_unavailable`) is returned as 422 with the requested tuple echoed back
|
||||||
|
* unchanged, and never mutates the stored selection.
|
||||||
|
*/
|
||||||
|
@Controller('api/chat/preferences/selection')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class HarnessSelectionController {
|
||||||
|
constructor(private readonly selection: HarnessSelectionService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
get(@CurrentUser() user: AuthenticatedUserLike): SelectionResponseDto {
|
||||||
|
return { selection: this.selection.getSelection(scopeFromUser(user)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put()
|
||||||
|
async put(
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Body() dto: HarnessSelectionInputDto,
|
||||||
|
): Promise<SelectionResponseDto> {
|
||||||
|
try {
|
||||||
|
const stored = await this.selection.setSelection(scopeFromUser(user), {
|
||||||
|
harnessId: dto.harnessId,
|
||||||
|
providerId: dto.providerId,
|
||||||
|
modelId: dto.modelId,
|
||||||
|
});
|
||||||
|
return { selection: stored };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HarnessOperationError) {
|
||||||
|
throw new HttpException(error.dto, HttpStatus.UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,90 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { HarnessSelection } from '@mosaicstack/types';
|
||||||
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
|
import {
|
||||||
|
HarnessAdapterUnavailableError,
|
||||||
|
HarnessRegistry,
|
||||||
|
operationError,
|
||||||
|
} from './harness.registry.js';
|
||||||
|
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||||
|
import { readContextFromScope } from './harness.dto.js';
|
||||||
|
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection logic for the Slice-Zero chat-preferences surface. It validates the
|
||||||
|
* requested harness/provider/model tuple against the live catalog with NO
|
||||||
|
* fallback substitution, then persists it owner-scoped. The stored selection is
|
||||||
|
* only ever mutated when the tuple is valid AND available.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HarnessSelectionService {
|
||||||
|
constructor(
|
||||||
|
@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry,
|
||||||
|
private readonly repository: HarnessSelectionRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getSelection(scope: ActorTenantScope): HarnessSelection | null {
|
||||||
|
return this.repository.get(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSelection(
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
selection: HarnessSelection,
|
||||||
|
): Promise<HarnessSelection> {
|
||||||
|
// Throws HarnessOperationError (selection_invalid / model_unavailable) with the
|
||||||
|
// requested tuple echoed back unchanged. The store is untouched on any throw.
|
||||||
|
await this.assertSelectionAvailable(scope, selection);
|
||||||
|
return this.repository.set(scope, selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertSelectionAvailable(
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
selection: HarnessSelection,
|
||||||
|
): Promise<void> {
|
||||||
|
const correlationId = randomUUID();
|
||||||
|
|
||||||
|
let adapter;
|
||||||
|
try {
|
||||||
|
adapter = this.registry.get(selection.harnessId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HarnessAdapterUnavailableError) {
|
||||||
|
// An unknown harness makes the whole tuple invalid — no fallback adapter.
|
||||||
|
throw operationError(
|
||||||
|
'selection_invalid',
|
||||||
|
'The requested harness/provider/model tuple is not in the catalog.',
|
||||||
|
selection,
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = await adapter.catalog(readContextFromScope(scope));
|
||||||
|
const entry = catalog.models.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.harnessId === selection.harnessId &&
|
||||||
|
candidate.providerId === selection.providerId &&
|
||||||
|
candidate.modelId === selection.modelId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
// No first-row / first-provider fallback: reject the requested tuple unchanged.
|
||||||
|
throw operationError(
|
||||||
|
'selection_invalid',
|
||||||
|
'The requested harness/provider/model tuple is not in the catalog.',
|
||||||
|
selection,
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.availability === 'unavailable') {
|
||||||
|
throw operationError(
|
||||||
|
'model_unavailable',
|
||||||
|
'The requested model is currently unavailable.',
|
||||||
|
selection,
|
||||||
|
correlationId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import {
|
||||||
|
type CanActivate,
|
||||||
|
type ExecutionContext,
|
||||||
|
type INestApplication,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { HarnessRegistry } from './harness.registry.js';
|
||||||
|
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||||
|
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||||
|
// The real module under test — importing it (not a hand-listed controllers/mocks
|
||||||
|
// list) is what makes an unresolved provider fail loudly at app.init() (#1145 guard).
|
||||||
|
import { HarnessModule } from './harness.module.js';
|
||||||
|
|
||||||
|
// Fields that must NEVER surface on a browser-facing catalog/list response.
|
||||||
|
const FORBIDDEN_KEYS = [
|
||||||
|
'executable',
|
||||||
|
'executablePath',
|
||||||
|
'home',
|
||||||
|
'homeDir',
|
||||||
|
'cwd',
|
||||||
|
'workingDir',
|
||||||
|
'workingDirectory',
|
||||||
|
'nativeSessionPath',
|
||||||
|
'sessionPath',
|
||||||
|
'env',
|
||||||
|
'secret',
|
||||||
|
'secrets',
|
||||||
|
'token',
|
||||||
|
'apiKey',
|
||||||
|
];
|
||||||
|
|
||||||
|
function assertNoForbiddenLeak(payload: unknown): void {
|
||||||
|
const serialized = JSON.stringify(payload).toLowerCase();
|
||||||
|
for (const key of FORBIDDEN_KEYS) {
|
||||||
|
expect(serialized).not.toContain(key.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authGuard: CanActivate = {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
||||||
|
requestContext.user = { id: 'user-1' };
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function registryWithFake(): HarnessRegistry {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Harness catalog HTTP surface', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [HarnessModule],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue(authGuard)
|
||||||
|
.overrideProvider(HARNESS_REGISTRY)
|
||||||
|
.useValue(registryWithFake())
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
||||||
|
);
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('boots the real HarnessModule so all providers resolve at app.init()', () => {
|
||||||
|
// If HarnessModule failed to resolve a provider, beforeAll's app.init() would
|
||||||
|
// have thrown and this suite would never reach here.
|
||||||
|
expect(app).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/harnesses returns 200 with safe fields only', async () => {
|
||||||
|
const response = await request(app.getHttpServer()).get('/api/harnesses');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(Array.isArray(response.body)).toBe(true);
|
||||||
|
expect(response.body.length).toBeGreaterThan(0);
|
||||||
|
const summary = response.body[0];
|
||||||
|
expect(Object.keys(summary).sort()).toEqual(['capabilities', 'displayName', 'id']);
|
||||||
|
expect(summary.id).toBe('fake');
|
||||||
|
expect(typeof summary.displayName).toBe('string');
|
||||||
|
expect(Array.isArray(summary.capabilities)).toBe(true);
|
||||||
|
assertNoForbiddenLeak(response.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/harnesses/:harnessId/catalog returns 200 with safe catalog fields only', async () => {
|
||||||
|
const response = await request(app.getHttpServer()).get('/api/harnesses/fake/catalog');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.harnessId).toBe('fake');
|
||||||
|
expect(typeof response.body.version).toBe('string');
|
||||||
|
expect(typeof response.body.fingerprint).toBe('string');
|
||||||
|
expect(Array.isArray(response.body.models)).toBe(true);
|
||||||
|
expect(response.body.models.length).toBeGreaterThan(0);
|
||||||
|
const entry = response.body.models[0];
|
||||||
|
// Whitelisted catalog-entry fields only (no executables/paths/secrets).
|
||||||
|
expect(Object.keys(entry).sort()).toEqual(
|
||||||
|
[
|
||||||
|
'authState',
|
||||||
|
'availability',
|
||||||
|
'displayName',
|
||||||
|
'harnessId',
|
||||||
|
'inputTypes',
|
||||||
|
'modelId',
|
||||||
|
'providerId',
|
||||||
|
'reasoningCapability',
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
assertNoForbiddenLeak(response.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET catalog for an unknown harnessId returns a typed adapter_unavailable error, never a fallback catalog', async () => {
|
||||||
|
const response = await request(app.getHttpServer()).get('/api/harnesses/ghost-harness/catalog');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body.code).toBe('adapter_unavailable');
|
||||||
|
// A fallback catalog would carry a models array; a typed error must not.
|
||||||
|
expect(response.body.models).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
|
import { HarnessAdapterUnavailableError, HarnessRegistry } from './harness.registry.js';
|
||||||
|
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||||
|
import {
|
||||||
|
readContextFromScope,
|
||||||
|
toHarnessSummary,
|
||||||
|
toSafeCatalog,
|
||||||
|
type HarnessCatalogDto,
|
||||||
|
type HarnessSummaryDto,
|
||||||
|
} from './harness.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic harness catalog surface. It exposes only harness-neutral, browser-safe
|
||||||
|
* fields (identity, capabilities, provider/model catalog) — never executables,
|
||||||
|
* native paths, home/cwd, env, or secrets. There is NO provider-probe route here;
|
||||||
|
* `/api/providers` and `POST /api/providers/test` are intentionally out of scope.
|
||||||
|
*/
|
||||||
|
@Controller('api/harnesses')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class HarnessController {
|
||||||
|
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AuthenticatedUserLike): Promise<HarnessSummaryDto[]> {
|
||||||
|
const context = readContextFromScope(scopeFromUser(user));
|
||||||
|
const summaries: HarnessSummaryDto[] = [];
|
||||||
|
for (const adapter of this.registry.list()) {
|
||||||
|
summaries.push(toHarnessSummary(await adapter.describe(context)));
|
||||||
|
}
|
||||||
|
return summaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':harnessId/catalog')
|
||||||
|
async catalog(
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Param('harnessId') harnessId: string,
|
||||||
|
): Promise<HarnessCatalogDto> {
|
||||||
|
const context = readContextFromScope(scopeFromUser(user));
|
||||||
|
let adapter;
|
||||||
|
try {
|
||||||
|
adapter = this.registry.get(harnessId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HarnessAdapterUnavailableError) {
|
||||||
|
// Typed failure — NEVER a fallback catalog for an unknown harness id.
|
||||||
|
throw new HttpException(
|
||||||
|
{ code: error.code, message: error.message, harnessId },
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return toSafeCatalog(await adapter.catalog(context));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
import type {
|
||||||
|
HarnessActorContext,
|
||||||
|
HarnessAuthState,
|
||||||
|
HarnessCapability,
|
||||||
|
HarnessCatalog,
|
||||||
|
HarnessCatalogEntry,
|
||||||
|
HarnessDescriptor,
|
||||||
|
HarnessInputType,
|
||||||
|
HarnessModelAvailability,
|
||||||
|
HarnessSelection,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured selection tuple accepted on `PUT /api/chat/preferences/selection`.
|
||||||
|
*
|
||||||
|
* The body is a STRUCTURED tuple (harness + provider + model), never a free-text
|
||||||
|
* model string. With `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })`
|
||||||
|
* any extra property — including smuggled server-authority fields such as
|
||||||
|
* `seatId`, `tenantId`, `userId`, `nativeSessionPath`, `executable`, `home`, `cwd` —
|
||||||
|
* is rejected with 400. There is deliberately no field through which a caller can
|
||||||
|
* name a scope; scope is derived on the server from the authenticated session.
|
||||||
|
*/
|
||||||
|
export class HarnessSelectionInputDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
harnessId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
providerId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
modelId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Browser-safe harness summary — identity and capabilities only. */
|
||||||
|
export interface HarnessSummaryDto {
|
||||||
|
readonly id: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly capabilities: readonly HarnessCapability[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Browser-safe catalog entry — no executables, paths, secrets, or env. */
|
||||||
|
export interface HarnessCatalogEntryDto {
|
||||||
|
readonly harnessId: string;
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly modelId: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly reasoningCapability: boolean;
|
||||||
|
readonly inputTypes: readonly HarnessInputType[];
|
||||||
|
readonly authState: HarnessAuthState;
|
||||||
|
readonly availability: HarnessModelAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Browser-safe catalog envelope. */
|
||||||
|
export interface HarnessCatalogDto {
|
||||||
|
readonly harnessId: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly fingerprint: string;
|
||||||
|
readonly models: readonly HarnessCatalogEntryDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response envelope for the caller's current selection (null when unset). */
|
||||||
|
export interface SelectionResponseDto {
|
||||||
|
readonly selection: HarnessSelection | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a server-trusted {@link HarnessActorContext} for read operations from the
|
||||||
|
* session-derived {@link ActorTenantScope}. All authority originates on the server;
|
||||||
|
* nothing here is caller-supplied. A fresh correlation id is minted per call.
|
||||||
|
*/
|
||||||
|
export function readContextFromScope(scope: ActorTenantScope): HarnessActorContext {
|
||||||
|
return {
|
||||||
|
actorId: scope.userId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
seatId: scope.userId,
|
||||||
|
correlationId: randomUUID(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a descriptor onto the browser-safe summary shape (whitelist by construction). */
|
||||||
|
export function toHarnessSummary(descriptor: HarnessDescriptor): HarnessSummaryDto {
|
||||||
|
return {
|
||||||
|
id: descriptor.id,
|
||||||
|
displayName: descriptor.displayName,
|
||||||
|
capabilities: [...descriptor.capabilities],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a catalog onto the browser-safe shape (whitelist by construction). */
|
||||||
|
export function toSafeCatalog(catalog: HarnessCatalog): HarnessCatalogDto {
|
||||||
|
return {
|
||||||
|
harnessId: catalog.harnessId,
|
||||||
|
version: catalog.version,
|
||||||
|
fingerprint: catalog.fingerprint,
|
||||||
|
models: catalog.models.map(toSafeCatalogEntry),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSafeCatalogEntry(entry: HarnessCatalogEntry): HarnessCatalogEntryDto {
|
||||||
|
return {
|
||||||
|
harnessId: entry.harnessId,
|
||||||
|
providerId: entry.providerId,
|
||||||
|
modelId: entry.modelId,
|
||||||
|
displayName: entry.displayName,
|
||||||
|
reasoningCapability: entry.reasoningCapability,
|
||||||
|
inputTypes: [...entry.inputTypes],
|
||||||
|
authState: entry.authState,
|
||||||
|
availability: entry.availability,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HarnessRegistry } from './harness.registry.js';
|
||||||
|
import { HarnessService } from './harness.service.js';
|
||||||
|
import {
|
||||||
|
HARNESS_CONVERSATION_SERVICE,
|
||||||
|
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
HARNESS_REGISTRY,
|
||||||
|
HARNESS_SERVICE,
|
||||||
|
} from './harness.tokens.js';
|
||||||
|
import { HarnessController } from './harness.controller.js';
|
||||||
|
import { HarnessSelectionController } from './harness-selection.controller.js';
|
||||||
|
import { HarnessSelectionService } from './harness-selection.service.js';
|
||||||
|
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires the harness-neutral registry/service (Task Two) together with the
|
||||||
|
* Slice-Zero catalog and selection HTTP surfaces (Task Three).
|
||||||
|
*
|
||||||
|
* The registry is provided empty here; real harness adapters are registered in a
|
||||||
|
* later task. Because the controllers/services resolve their collaborators through
|
||||||
|
* this real module graph, an unresolved provider fails loudly at `app.init()`.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [HarnessController, HarnessSelectionController],
|
||||||
|
providers: [
|
||||||
|
{ provide: HARNESS_REGISTRY, useFactory: () => new HarnessRegistry() },
|
||||||
|
{ provide: HARNESS_SERVICE, useClass: HarnessService },
|
||||||
|
// Task Five: bind the conversation-service token to its explicit "not yet bound"
|
||||||
|
// sentinel. The pi-rpc router treats this as a hard, typed startup failure; Task 14
|
||||||
|
// replaces it with a real service. Exported so ChatModule's router can inject it.
|
||||||
|
{ provide: HARNESS_CONVERSATION_SERVICE, useValue: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE },
|
||||||
|
HarnessSelectionRepository,
|
||||||
|
HarnessSelectionService,
|
||||||
|
],
|
||||||
|
exports: [HARNESS_REGISTRY, HARNESS_SERVICE, HARNESS_CONVERSATION_SERVICE],
|
||||||
|
})
|
||||||
|
export class HarnessModule {}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
HarnessAdapterUnavailableError,
|
||||||
|
HarnessRegistrationError,
|
||||||
|
HarnessRegistry,
|
||||||
|
} from './harness.registry.js';
|
||||||
|
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||||
|
|
||||||
|
describe('HarnessRegistry', () => {
|
||||||
|
it('registers and looks up an adapter by harness id', () => {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||||
|
|
||||||
|
registry.register(adapter);
|
||||||
|
|
||||||
|
expect(registry.get('fake')).toBe(adapter);
|
||||||
|
expect(registry.has('fake')).toBe(true);
|
||||||
|
expect(registry.list().map((entry) => entry.id)).toEqual(['fake']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a blank adapter id', () => {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
registry.register(new FakeHarnessAdapter({ id: ' ' }));
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessRegistrationError);
|
||||||
|
expect((error as HarnessRegistrationError).reason).toBe('blank_id');
|
||||||
|
expect(registry.list()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a duplicate adapter id', () => {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessRegistrationError);
|
||||||
|
expect((error as HarnessRegistrationError).reason).toBe('duplicate_id');
|
||||||
|
expect((error as HarnessRegistrationError).harnessId).toBe('fake');
|
||||||
|
// The original registration is untouched.
|
||||||
|
expect(registry.list()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns adapter_unavailable for an unknown harness id', () => {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
registry.get('missing');
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessAdapterUnavailableError);
|
||||||
|
expect((error as HarnessAdapterUnavailableError).code).toBe('adapter_unavailable');
|
||||||
|
expect((error as HarnessAdapterUnavailableError).harnessId).toBe('missing');
|
||||||
|
expect(registry.has('missing')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
HarnessAdapter,
|
||||||
|
HarnessErrorCode,
|
||||||
|
HarnessErrorDto,
|
||||||
|
HarnessSelection,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A typed harness operation failure that carries a fully-formed, browser-safe
|
||||||
|
* {@link HarnessErrorDto}. The DTO's `selection` is always the exact requested
|
||||||
|
* tuple — there is no field through which a substituted "effective" selection
|
||||||
|
* could ever be reported.
|
||||||
|
*/
|
||||||
|
export class HarnessOperationError extends Error {
|
||||||
|
readonly code: HarnessErrorCode;
|
||||||
|
readonly dto: HarnessErrorDto;
|
||||||
|
|
||||||
|
constructor(dto: HarnessErrorDto) {
|
||||||
|
super(dto.message);
|
||||||
|
this.name = 'HarnessOperationError';
|
||||||
|
this.code = dto.code;
|
||||||
|
this.dto = dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a {@link HarnessOperationError} that echoes the requested selection unchanged. */
|
||||||
|
export function operationError(
|
||||||
|
code: HarnessErrorCode,
|
||||||
|
message: string,
|
||||||
|
selection: HarnessSelection,
|
||||||
|
correlationId: string,
|
||||||
|
retryable = false,
|
||||||
|
): HarnessOperationError {
|
||||||
|
return new HarnessOperationError({ code, message, retryable, correlationId, selection });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raised when an unknown harness id is looked up. Discriminated by `code`. */
|
||||||
|
export class HarnessAdapterUnavailableError extends Error {
|
||||||
|
readonly code = 'adapter_unavailable' as const satisfies HarnessErrorCode;
|
||||||
|
|
||||||
|
constructor(readonly harnessId: string) {
|
||||||
|
super(`No harness adapter is registered for id "${harnessId}".`);
|
||||||
|
this.name = 'HarnessAdapterUnavailableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HarnessRegistrationFailure = 'blank_id' | 'duplicate_id';
|
||||||
|
|
||||||
|
/** Raised when an adapter cannot be registered (blank or duplicate id). */
|
||||||
|
export class HarnessRegistrationError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly reason: HarnessRegistrationFailure,
|
||||||
|
readonly harnessId: string,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
reason === 'blank_id'
|
||||||
|
? 'A harness adapter id must be a non-empty string.'
|
||||||
|
: `A harness adapter is already registered for id "${harnessId}".`,
|
||||||
|
);
|
||||||
|
this.name = 'HarnessRegistrationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Harness-neutral adapter registry. Adapters are keyed by their harness id.
|
||||||
|
* Registration rejects blank and duplicate ids; lookup of an unknown id fails
|
||||||
|
* with {@link HarnessAdapterUnavailableError} (`adapter_unavailable`).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HarnessRegistry {
|
||||||
|
private readonly adapters = new Map<string, HarnessAdapter>();
|
||||||
|
|
||||||
|
register(adapter: HarnessAdapter): void {
|
||||||
|
const id = adapter.id;
|
||||||
|
if (typeof id !== 'string' || id.trim().length === 0) {
|
||||||
|
throw new HarnessRegistrationError('blank_id', id ?? '');
|
||||||
|
}
|
||||||
|
if (this.adapters.has(id)) {
|
||||||
|
throw new HarnessRegistrationError('duplicate_id', id);
|
||||||
|
}
|
||||||
|
this.adapters.set(id, adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(harnessId: string): HarnessAdapter {
|
||||||
|
const adapter = this.adapters.get(harnessId);
|
||||||
|
if (!adapter) {
|
||||||
|
throw new HarnessAdapterUnavailableError(harnessId);
|
||||||
|
}
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(harnessId: string): boolean {
|
||||||
|
return this.adapters.has(harnessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): readonly HarnessAdapter[] {
|
||||||
|
return [...this.adapters.values()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { HarnessActorContext, HarnessCapability, HarnessSelection } from '@mosaicstack/types';
|
||||||
|
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
|
||||||
|
import { HarnessOperationError, HarnessRegistry } from './harness.registry.js';
|
||||||
|
import {
|
||||||
|
HarnessScopeViolationError,
|
||||||
|
HarnessService,
|
||||||
|
type TrustedGatewayScope,
|
||||||
|
} from './harness.service.js';
|
||||||
|
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||||
|
|
||||||
|
const SCOPE: TrustedGatewayScope = {
|
||||||
|
actorId: 'actor-trusted',
|
||||||
|
tenantId: 'tenant-trusted',
|
||||||
|
seatId: 'seat-trusted',
|
||||||
|
correlationId: 'correlation-trusted',
|
||||||
|
};
|
||||||
|
|
||||||
|
const READ_CONTEXT: HarnessActorContext = {
|
||||||
|
actorId: SCOPE.actorId,
|
||||||
|
tenantId: SCOPE.tenantId,
|
||||||
|
seatId: SCOPE.seatId,
|
||||||
|
correlationId: SCOPE.correlationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(capabilities?: readonly HarnessCapability[]) {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
const adapter = new FakeHarnessAdapter({ id: 'fake', capabilities });
|
||||||
|
registry.register(adapter);
|
||||||
|
const service = new HarnessService(registry);
|
||||||
|
return { registry, adapter, service };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function availableSelection(adapter: FakeHarnessAdapter): Promise<HarnessSelection> {
|
||||||
|
const catalog = await adapter.catalog(READ_CONTEXT);
|
||||||
|
const entry = catalog.models.find((model) => model.availability === 'available');
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error('fixture requires an available model');
|
||||||
|
}
|
||||||
|
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HarnessService', () => {
|
||||||
|
it('derives the actor context from trusted scope on create', async () => {
|
||||||
|
const { service, adapter } = setup();
|
||||||
|
const selection = await availableSelection(adapter);
|
||||||
|
|
||||||
|
const snapshot = await service.createSession(SCOPE, {
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot.seatId).toBe(SCOPE.seatId);
|
||||||
|
expect(snapshot.state).toBe('idle');
|
||||||
|
expect(snapshot.selection).toEqual(selection);
|
||||||
|
expect(snapshot.nativeSessionId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects server-authority fields supplied by an external caller', async () => {
|
||||||
|
const { service, adapter } = setup();
|
||||||
|
const selection = await availableSelection(adapter);
|
||||||
|
|
||||||
|
const hostile = {
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
selection,
|
||||||
|
seatId: 'attacker-seat',
|
||||||
|
executablePath: '/usr/bin/evil',
|
||||||
|
home: '/home/attacker',
|
||||||
|
cwd: '/tmp/attacker',
|
||||||
|
nativeSessionPath: '/var/native/attacker.jsonl',
|
||||||
|
} as unknown as Parameters<HarnessService['createSession']>[1];
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await service.createSession(SCOPE, hostile);
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessScopeViolationError);
|
||||||
|
expect((error as HarnessScopeViolationError).field).toBe('seatId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns adapter_unavailable for an unknown harness id, echoing the requested tuple', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
const selection: HarnessSelection = {
|
||||||
|
harnessId: 'ghost-harness',
|
||||||
|
providerId: 'p',
|
||||||
|
modelId: 'm',
|
||||||
|
};
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('adapter_unavailable');
|
||||||
|
expect(dto.selection).toEqual(selection);
|
||||||
|
expect(dto.correlationId).toBe(SCOPE.correlationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns selection_invalid for an unknown provider/model tuple, unchanged', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
const selection: HarnessSelection = {
|
||||||
|
harnessId: 'fake',
|
||||||
|
providerId: 'ghost-provider',
|
||||||
|
modelId: 'ghost-model',
|
||||||
|
};
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('selection_invalid');
|
||||||
|
expect(dto.selection).toEqual(selection);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns model_unavailable without falling back for a known unavailable model', async () => {
|
||||||
|
const { service, adapter } = setup();
|
||||||
|
const catalog = await adapter.catalog(READ_CONTEXT);
|
||||||
|
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
|
||||||
|
expect(unavailable).toBeDefined();
|
||||||
|
const selection: HarnessSelection = {
|
||||||
|
harnessId: unavailable!.harnessId,
|
||||||
|
providerId: unavailable!.providerId,
|
||||||
|
modelId: unavailable!.modelId,
|
||||||
|
};
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('model_unavailable');
|
||||||
|
// No substitution: the DTO tuple is exactly what was requested.
|
||||||
|
expect(dto.selection).toEqual(selection);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives create, resume, detach, evict, and end distinct observable effects', async () => {
|
||||||
|
const { service, adapter } = setup();
|
||||||
|
const selection = await availableSelection(adapter);
|
||||||
|
|
||||||
|
const created = await service.createSession(SCOPE, {
|
||||||
|
conversationId: 'conversation-create',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
expect(created.state).toBe('idle');
|
||||||
|
expect(created.processId).toBeTruthy();
|
||||||
|
expect(created.attachedClientIds).toEqual([]);
|
||||||
|
|
||||||
|
const resumed = await service.resumeSession(SCOPE, {
|
||||||
|
conversationId: 'conversation-resume',
|
||||||
|
nativeSessionId: 'native-preexisting-123',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
// Resume binds the supplied native session; create mints a fresh one.
|
||||||
|
expect(resumed.nativeSessionId).toBe('native-preexisting-123');
|
||||||
|
expect(resumed.nativeSessionId).not.toBe(created.nativeSessionId);
|
||||||
|
|
||||||
|
await service.attach(SCOPE, {
|
||||||
|
conversationId: 'conversation-create',
|
||||||
|
clientId: 'browser-1',
|
||||||
|
});
|
||||||
|
const afterAttach = await service.snapshot(SCOPE, 'conversation-create');
|
||||||
|
expect(afterAttach.attachedClientIds).toEqual(['browser-1']);
|
||||||
|
|
||||||
|
const afterDetach = await service.detach(SCOPE, {
|
||||||
|
conversationId: 'conversation-create',
|
||||||
|
clientId: 'browser-1',
|
||||||
|
});
|
||||||
|
// Detach removes the browser attachment only; the process stays alive.
|
||||||
|
expect(afterDetach.attachedClientIds).toEqual([]);
|
||||||
|
expect(afterDetach.state).toBe('idle');
|
||||||
|
expect(afterDetach.processId).toBeTruthy();
|
||||||
|
|
||||||
|
const afterEvict = await service.evict(SCOPE, {
|
||||||
|
conversationId: 'conversation-create',
|
||||||
|
reason: 'idle_timeout',
|
||||||
|
});
|
||||||
|
// Evict stops the process but retains the resumable native session.
|
||||||
|
expect(afterEvict.state).toBe('evicted');
|
||||||
|
expect(afterEvict.processId).toBeUndefined();
|
||||||
|
expect(afterEvict.nativeSessionId).toBe(created.nativeSessionId);
|
||||||
|
|
||||||
|
const afterEnd = await service.end(SCOPE, {
|
||||||
|
conversationId: 'conversation-create',
|
||||||
|
reason: 'session_ended',
|
||||||
|
});
|
||||||
|
// End destructively terminates the native session.
|
||||||
|
expect(afterEnd.state).toBe('ended');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails typed when an unsupported capability is exercised', async () => {
|
||||||
|
const withoutExtensionUi = HARNESS_CAPABILITIES.filter(
|
||||||
|
(capability) => capability !== 'extensionUi',
|
||||||
|
);
|
||||||
|
const { service, adapter } = setup(withoutExtensionUi);
|
||||||
|
const selection = await availableSelection(adapter);
|
||||||
|
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await service.respondInteraction(SCOPE, {
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
response: { requestId: 'interaction-1', type: 'confirm', accepted: true },
|
||||||
|
});
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
HarnessActorContext,
|
||||||
|
HarnessAdapter,
|
||||||
|
HarnessCatalog,
|
||||||
|
HarnessCloseReason,
|
||||||
|
HarnessInteractionResponse,
|
||||||
|
HarnessSelection,
|
||||||
|
HarnessSessionHandle,
|
||||||
|
HarnessSessionSnapshot,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import {
|
||||||
|
HarnessAdapterUnavailableError,
|
||||||
|
HarnessRegistry,
|
||||||
|
operationError,
|
||||||
|
} from './harness.registry.js';
|
||||||
|
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trusted, server-derived authority. In production this is produced by the
|
||||||
|
* Gateway from the authenticated session — never from a browser/caller DTO.
|
||||||
|
*/
|
||||||
|
export interface TrustedGatewayScope {
|
||||||
|
readonly actorId: string;
|
||||||
|
readonly tenantId: string;
|
||||||
|
readonly seatId: string;
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-authority fields that must never arrive from an external request DTO. */
|
||||||
|
const FORBIDDEN_REQUEST_FIELDS = [
|
||||||
|
'actorId',
|
||||||
|
'tenantId',
|
||||||
|
'correlationId',
|
||||||
|
'seatId',
|
||||||
|
'seat',
|
||||||
|
'executable',
|
||||||
|
'executablePath',
|
||||||
|
'home',
|
||||||
|
'homeDir',
|
||||||
|
'cwd',
|
||||||
|
'workingDir',
|
||||||
|
'workingDirectory',
|
||||||
|
'nativeSessionPath',
|
||||||
|
'sessionPath',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Raised when an external request DTO smuggles a server-authority field. */
|
||||||
|
export class HarnessScopeViolationError extends Error {
|
||||||
|
constructor(readonly field: string) {
|
||||||
|
super(`External request supplied server-authority field "${field}".`);
|
||||||
|
this.name = 'HarnessScopeViolationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateHarnessSessionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumeHarnessSessionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly nativeSessionId: string;
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachClientRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetachClientRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvictSessionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly reason: HarnessCloseReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndSessionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly reason: HarnessCloseReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RespondInteractionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly response: HarnessInteractionResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveSession {
|
||||||
|
readonly harnessId: string;
|
||||||
|
readonly handle: HarnessSessionHandle;
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Harness-neutral service. It derives the {@link HarnessActorContext} strictly
|
||||||
|
* from trusted Gateway scope, validates the selected provider/model tuple with
|
||||||
|
* NO fallback substitution, and exposes distinct create/resume/detach/evict/end
|
||||||
|
* lifecycle operations.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HarnessService {
|
||||||
|
private readonly sessions = new Map<string, ActiveSession>();
|
||||||
|
|
||||||
|
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
|
||||||
|
|
||||||
|
async createSession(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: CreateHarnessSessionRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const { conversationId, selection } = request;
|
||||||
|
const adapter = this.resolveAdapter(scope, selection);
|
||||||
|
const context = deriveActorContext(scope);
|
||||||
|
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
|
||||||
|
|
||||||
|
const handle = await adapter.create({ context, conversationId, selection });
|
||||||
|
this.sessions.set(conversationId, {
|
||||||
|
harnessId: selection.harnessId,
|
||||||
|
handle,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
});
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async resumeSession(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: ResumeHarnessSessionRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const { conversationId, nativeSessionId, selection } = request;
|
||||||
|
const adapter = this.resolveAdapter(scope, selection);
|
||||||
|
const context = deriveActorContext(scope);
|
||||||
|
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
|
||||||
|
|
||||||
|
const handle = await adapter.resume({ context, conversationId, nativeSessionId, selection });
|
||||||
|
this.sessions.set(conversationId, {
|
||||||
|
harnessId: selection.harnessId,
|
||||||
|
handle,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
});
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async attach(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: AttachClientRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.attach({ clientId: request.clientId });
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: DetachClientRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.detach(request.clientId);
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async evict(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: EvictSessionRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.evictProcess(request.reason);
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async end(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: EndSessionRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.endSession(request.reason);
|
||||||
|
const snapshot = await handle.snapshot();
|
||||||
|
this.sessions.delete(request.conversationId);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async respondInteraction(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: RespondInteractionRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.respondInteraction(request.response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshot(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
const handle = this.requireHandle(scope, conversationId);
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveAdapter(scope: TrustedGatewayScope, selection: HarnessSelection): HarnessAdapter {
|
||||||
|
try {
|
||||||
|
return this.registry.get(selection.harnessId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HarnessAdapterUnavailableError) {
|
||||||
|
throw operationError('adapter_unavailable', error.message, selection, scope.correlationId);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertSelectionAvailable(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
catalogPromise: Promise<HarnessCatalog>,
|
||||||
|
selection: HarnessSelection,
|
||||||
|
): Promise<void> {
|
||||||
|
const catalog = await catalogPromise;
|
||||||
|
const entry = catalog.models.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.harnessId === selection.harnessId &&
|
||||||
|
candidate.providerId === selection.providerId &&
|
||||||
|
candidate.modelId === selection.modelId,
|
||||||
|
);
|
||||||
|
if (!entry) {
|
||||||
|
// No first-row fallback: reject the requested tuple unchanged.
|
||||||
|
throw operationError(
|
||||||
|
'selection_invalid',
|
||||||
|
'The requested harness/provider/model tuple is not in the catalog.',
|
||||||
|
selection,
|
||||||
|
scope.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.availability === 'unavailable') {
|
||||||
|
throw operationError(
|
||||||
|
'model_unavailable',
|
||||||
|
'The requested model is currently unavailable.',
|
||||||
|
selection,
|
||||||
|
scope.correlationId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireHandle(scope: TrustedGatewayScope, conversationId: string): HarnessSessionHandle {
|
||||||
|
const active = this.sessions.get(conversationId);
|
||||||
|
if (!active) {
|
||||||
|
throw operationError(
|
||||||
|
'session_not_found',
|
||||||
|
`No active harness session for conversation "${conversationId}".`,
|
||||||
|
{ harnessId: '', providerId: '', modelId: '' },
|
||||||
|
scope.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return active.handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the actor context strictly from trusted scope. No caller data leaks in. */
|
||||||
|
export function deriveActorContext(scope: TrustedGatewayScope): HarnessActorContext {
|
||||||
|
return {
|
||||||
|
actorId: scope.actorId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
seatId: scope.seatId,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject any request object that carries a server-authority field. */
|
||||||
|
function assertTrustedRequest(request: object): void {
|
||||||
|
for (const field of FORBIDDEN_REQUEST_FIELDS) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(request, field)) {
|
||||||
|
throw new HarnessScopeViolationError(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export the typed operation error so callers importing from the service
|
||||||
|
// have the discriminated failure type without reaching into the registry.
|
||||||
|
export { HarnessOperationError } from './harness.registry.js';
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Nest dependency-injection tokens for the harness-neutral registry and service.
|
||||||
|
*
|
||||||
|
* String tokens follow the existing Gateway convention (see `memory/memory.tokens.ts`)
|
||||||
|
* and remain valid Nest `InjectionToken`s for `@Inject(...)`.
|
||||||
|
*/
|
||||||
|
import type { HarnessConversationService } from '@mosaicstack/types';
|
||||||
|
|
||||||
|
export const HARNESS_REGISTRY = 'HARNESS_REGISTRY' as const;
|
||||||
|
export const HARNESS_SERVICE = 'HARNESS_SERVICE' as const;
|
||||||
|
|
||||||
|
export type HarnessRegistryToken = typeof HARNESS_REGISTRY;
|
||||||
|
export type HarnessServiceToken = typeof HARNESS_SERVICE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token for the {@link HarnessConversationService} that {@link HarnessChatRuntime}
|
||||||
|
* depends on. Until Task 14 provides a real implementation, `HarnessModule` binds
|
||||||
|
* the {@link HARNESS_CONVERSATION_SERVICE_UNAVAILABLE} sentinel here, and the
|
||||||
|
* `pi-rpc` router treats that sentinel as a hard, typed startup failure.
|
||||||
|
*/
|
||||||
|
export const HARNESS_CONVERSATION_SERVICE = 'HARNESS_CONVERSATION_SERVICE' as const;
|
||||||
|
|
||||||
|
export type HarnessConversationServiceToken = typeof HARNESS_CONVERSATION_SERVICE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit "not yet bound" value for {@link HARNESS_CONVERSATION_SERVICE}. It is a
|
||||||
|
* distinct sentinel — never `null`/`undefined` — so an unbound service is an
|
||||||
|
* intentional, checkable state rather than an accidental nil that could read as
|
||||||
|
* "present". Replaced by a real service in Task 14.
|
||||||
|
*/
|
||||||
|
export const HARNESS_CONVERSATION_SERVICE_UNAVAILABLE: unique symbol = Symbol(
|
||||||
|
'HARNESS_CONVERSATION_SERVICE_UNAVAILABLE',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** A binding for {@link HARNESS_CONVERSATION_SERVICE}: a real service or the sentinel. */
|
||||||
|
export type HarnessConversationServiceBinding =
|
||||||
|
| HarnessConversationService
|
||||||
|
| typeof HARNESS_CONVERSATION_SERVICE_UNAVAILABLE;
|
||||||
|
|
||||||
|
/** Narrows a binding to a usable service, excluding the unavailable sentinel. */
|
||||||
|
export function isHarnessConversationServiceAvailable(
|
||||||
|
binding: HarnessConversationServiceBinding,
|
||||||
|
): binding is HarnessConversationService {
|
||||||
|
return binding !== HARNESS_CONVERSATION_SERVICE_UNAVAILABLE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { HarnessActorContext, HarnessSelection } from '@mosaicstack/types';
|
||||||
|
import { HarnessOperationError } from '../harness.registry.js';
|
||||||
|
import { FakeHarnessAdapter } from './fake-harness.adapter.js';
|
||||||
|
import { runHarnessAdapterContract } from './harness-adapter.contract.js';
|
||||||
|
|
||||||
|
const CONTEXT: HarnessActorContext = {
|
||||||
|
actorId: 'actor-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
seatId: 'seat-1',
|
||||||
|
correlationId: 'correlation-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
// The reusable conformance suite. Task 13 re-runs it against the native Pi adapter.
|
||||||
|
runHarnessAdapterContract('FakeHarnessAdapter', () => new FakeHarnessAdapter({ id: 'fake' }));
|
||||||
|
|
||||||
|
describe('FakeHarnessAdapter no-substitution', () => {
|
||||||
|
it('never substitutes the first catalog row when a bogus selection is requested', async () => {
|
||||||
|
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const firstRow = catalog.models[0];
|
||||||
|
if (!firstRow) {
|
||||||
|
throw new Error('fixture requires a catalog model');
|
||||||
|
}
|
||||||
|
const available = catalog.models.find(
|
||||||
|
(entry) => entry.availability === 'available' && entry.modelId !== firstRow.modelId,
|
||||||
|
);
|
||||||
|
expect(available).toBeDefined();
|
||||||
|
const selected: HarnessSelection = {
|
||||||
|
harnessId: available!.harnessId,
|
||||||
|
providerId: available!.providerId,
|
||||||
|
modelId: available!.modelId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handle = await adapter.create({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
selection: selected,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bogus: HarnessSelection = {
|
||||||
|
harnessId: 'fake',
|
||||||
|
providerId: 'ghost-provider',
|
||||||
|
modelId: 'ghost-model',
|
||||||
|
};
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await handle.setModel(bogus);
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('selection_invalid');
|
||||||
|
// The DTO echoes the exact requested tuple, unchanged.
|
||||||
|
expect(dto.selection).toEqual(bogus);
|
||||||
|
// No substitution to the first catalog row.
|
||||||
|
expect(dto.selection).not.toEqual({
|
||||||
|
harnessId: firstRow.harnessId,
|
||||||
|
providerId: firstRow.providerId,
|
||||||
|
modelId: firstRow.modelId,
|
||||||
|
});
|
||||||
|
// The active selection is untouched by the rejected request.
|
||||||
|
expect((await handle.snapshot()).selection).toEqual(selected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports model_unavailable with the unchanged tuple for a known but unavailable model', async () => {
|
||||||
|
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
|
||||||
|
const available = catalog.models.find((entry) => entry.availability === 'available');
|
||||||
|
expect(unavailable).toBeDefined();
|
||||||
|
expect(available).toBeDefined();
|
||||||
|
|
||||||
|
const startingSelection: HarnessSelection = {
|
||||||
|
harnessId: available!.harnessId,
|
||||||
|
providerId: available!.providerId,
|
||||||
|
modelId: available!.modelId,
|
||||||
|
};
|
||||||
|
const handle = await adapter.create({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conversation-2',
|
||||||
|
selection: startingSelection,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requested: HarnessSelection = {
|
||||||
|
harnessId: unavailable!.harnessId,
|
||||||
|
providerId: unavailable!.providerId,
|
||||||
|
modelId: unavailable!.modelId,
|
||||||
|
};
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await handle.setModel(requested);
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('model_unavailable');
|
||||||
|
expect(dto.selection).toEqual(requested);
|
||||||
|
expect((await handle.snapshot()).selection).toEqual(startingSelection);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import type {
|
||||||
|
AttachClient,
|
||||||
|
CreateHarnessSession,
|
||||||
|
HarnessAdapter,
|
||||||
|
HarnessActorContext,
|
||||||
|
HarnessCapability,
|
||||||
|
HarnessCatalog,
|
||||||
|
HarnessCatalogEntry,
|
||||||
|
HarnessCloseReason,
|
||||||
|
HarnessDescriptor,
|
||||||
|
HarnessEvent,
|
||||||
|
HarnessInteractionResponse,
|
||||||
|
HarnessPrompt,
|
||||||
|
HarnessPromptReceipt,
|
||||||
|
HarnessSelection,
|
||||||
|
HarnessSessionHandle,
|
||||||
|
HarnessSessionSnapshot,
|
||||||
|
HarnessSessionState,
|
||||||
|
ResumeHarnessSession,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
|
||||||
|
import { operationError } from '../harness.registry.js';
|
||||||
|
|
||||||
|
export interface FakeHarnessAdapterOptions {
|
||||||
|
readonly id: string;
|
||||||
|
readonly capabilities?: readonly HarnessCapability[];
|
||||||
|
readonly catalog?: readonly HarnessCatalogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAKE_PROVIDER = 'fake-openai';
|
||||||
|
|
||||||
|
function defaultCatalog(harnessId: string): readonly HarnessCatalogEntry[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
harnessId,
|
||||||
|
providerId: FAKE_PROVIDER,
|
||||||
|
modelId: 'fake-mini',
|
||||||
|
displayName: 'Fake Mini',
|
||||||
|
reasoningCapability: false,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
harnessId,
|
||||||
|
providerId: FAKE_PROVIDER,
|
||||||
|
modelId: 'fake-pro',
|
||||||
|
displayName: 'Fake Pro',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text', 'image'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
harnessId,
|
||||||
|
providerId: FAKE_PROVIDER,
|
||||||
|
modelId: 'fake-legacy',
|
||||||
|
displayName: 'Fake Legacy',
|
||||||
|
reasoningCapability: false,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'unavailable',
|
||||||
|
availability: 'unavailable',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(entry: HarnessCatalogEntry, selection: HarnessSelection): boolean {
|
||||||
|
return (
|
||||||
|
entry.harnessId === selection.harnessId &&
|
||||||
|
entry.providerId === selection.providerId &&
|
||||||
|
entry.modelId === selection.modelId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory harness session handle used by the fake adapter and by the shared
|
||||||
|
* conformance suite. It enforces the two invariants the real adapters must also
|
||||||
|
* honor: model selection is validated against the catalog and is NEVER
|
||||||
|
* substituted, and unsupported capabilities fail with a typed error.
|
||||||
|
*/
|
||||||
|
export class FakeHarnessSessionHandle implements HarnessSessionHandle {
|
||||||
|
private state: HarnessSessionState = 'idle';
|
||||||
|
private processId: string | undefined;
|
||||||
|
private readonly attachedClientIds = new Set<string>();
|
||||||
|
private readonly listeners = new Set<(event: HarnessEvent) => void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly conversationId: string,
|
||||||
|
private readonly nativeSessionId: string,
|
||||||
|
private readonly seatId: string,
|
||||||
|
private selection: HarnessSelection,
|
||||||
|
private readonly correlationId: string,
|
||||||
|
private readonly capabilities: readonly HarnessCapability[],
|
||||||
|
private readonly catalog: readonly HarnessCatalogEntry[],
|
||||||
|
) {
|
||||||
|
this.processId = `process-${nativeSessionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshot(): Promise<HarnessSessionSnapshot> {
|
||||||
|
return {
|
||||||
|
conversationId: this.conversationId,
|
||||||
|
nativeSessionId: this.nativeSessionId,
|
||||||
|
processId: this.processId,
|
||||||
|
seatId: this.seatId,
|
||||||
|
selection: this.selection,
|
||||||
|
state: this.state,
|
||||||
|
attachedClientIds: [...this.attachedClientIds],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async attach(input: AttachClient): Promise<void> {
|
||||||
|
this.attachedClientIds.add(input.clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(clientId: string): Promise<void> {
|
||||||
|
// Removes the browser attachment only; the process and native session persist.
|
||||||
|
this.attachedClientIds.delete(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async prompt(input: HarnessPrompt & { idempotencyKey: string }): Promise<HarnessPromptReceipt> {
|
||||||
|
return {
|
||||||
|
conversationId: this.conversationId,
|
||||||
|
turnId: input.turnId,
|
||||||
|
correlationId: input.correlationId,
|
||||||
|
state: 'accepted',
|
||||||
|
selection: this.selection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async setModel(selection: HarnessSelection): Promise<HarnessSelection> {
|
||||||
|
const entry = this.catalog.find((candidate) => matches(candidate, selection));
|
||||||
|
if (!entry) {
|
||||||
|
// No fallback to the first catalog row: reject with the requested tuple, unchanged.
|
||||||
|
throw operationError(
|
||||||
|
'selection_invalid',
|
||||||
|
'The requested harness/provider/model tuple is not in the catalog.',
|
||||||
|
selection,
|
||||||
|
this.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.availability === 'unavailable') {
|
||||||
|
throw operationError(
|
||||||
|
'model_unavailable',
|
||||||
|
'The requested model is currently unavailable.',
|
||||||
|
selection,
|
||||||
|
this.correlationId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.selection = selection;
|
||||||
|
return this.selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
async abort(_turnId: string): Promise<void> {
|
||||||
|
// No active turn machinery in the fake; abort is a no-op acknowledgement.
|
||||||
|
}
|
||||||
|
|
||||||
|
async respondInteraction(_input: HarnessInteractionResponse): Promise<void> {
|
||||||
|
if (!this.capabilities.includes('extensionUi')) {
|
||||||
|
throw operationError(
|
||||||
|
'interaction_unsupported',
|
||||||
|
'This harness does not support interactive responses.',
|
||||||
|
this.selection,
|
||||||
|
this.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events(listener: (event: HarnessEvent) => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async evictProcess(_reason: HarnessCloseReason): Promise<void> {
|
||||||
|
// Stop the process but keep the resumable native session.
|
||||||
|
this.processId = undefined;
|
||||||
|
this.state = 'evicted';
|
||||||
|
}
|
||||||
|
|
||||||
|
async endSession(_reason: HarnessCloseReason): Promise<void> {
|
||||||
|
// Destructively end the native session.
|
||||||
|
this.processId = undefined;
|
||||||
|
this.state = 'ended';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal in-memory {@link HarnessAdapter} for Slice Zero. It mints a fresh
|
||||||
|
* native session id on `create` and binds the supplied one on `resume`, so the
|
||||||
|
* two paths are observably distinct.
|
||||||
|
*/
|
||||||
|
export class FakeHarnessAdapter implements HarnessAdapter {
|
||||||
|
readonly id: string;
|
||||||
|
private readonly capabilities: readonly HarnessCapability[];
|
||||||
|
private readonly catalogEntries: readonly HarnessCatalogEntry[];
|
||||||
|
private createdCount = 0;
|
||||||
|
|
||||||
|
constructor(options: FakeHarnessAdapterOptions) {
|
||||||
|
this.id = options.id;
|
||||||
|
this.capabilities = options.capabilities ?? [...HARNESS_CAPABILITIES];
|
||||||
|
this.catalogEntries = options.catalog ?? defaultCatalog(options.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async describe(_context: HarnessActorContext): Promise<HarnessDescriptor> {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
displayName: `Fake harness (${this.id})`,
|
||||||
|
capabilities: this.capabilities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async catalog(_context: HarnessActorContext): Promise<HarnessCatalog> {
|
||||||
|
return {
|
||||||
|
harnessId: this.id,
|
||||||
|
version: '1.0.0',
|
||||||
|
fingerprint: `fake-${this.id}-${this.catalogEntries.length}`,
|
||||||
|
models: this.catalogEntries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateHarnessSession): Promise<HarnessSessionHandle> {
|
||||||
|
this.createdCount += 1;
|
||||||
|
const nativeSessionId = `native-${input.conversationId}-${this.createdCount}`;
|
||||||
|
return new FakeHarnessSessionHandle(
|
||||||
|
input.conversationId,
|
||||||
|
nativeSessionId,
|
||||||
|
input.context.seatId,
|
||||||
|
input.selection,
|
||||||
|
input.context.correlationId,
|
||||||
|
this.capabilities,
|
||||||
|
this.catalogEntries,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resume(input: ResumeHarnessSession): Promise<HarnessSessionHandle> {
|
||||||
|
return new FakeHarnessSessionHandle(
|
||||||
|
input.conversationId,
|
||||||
|
input.nativeSessionId,
|
||||||
|
input.context.seatId,
|
||||||
|
input.selection,
|
||||||
|
input.context.correlationId,
|
||||||
|
this.capabilities,
|
||||||
|
this.catalogEntries,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type {
|
||||||
|
HarnessActorContext,
|
||||||
|
HarnessAdapter,
|
||||||
|
HarnessCatalogEntry,
|
||||||
|
HarnessSelection,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { HarnessOperationError } from '../harness.registry.js';
|
||||||
|
|
||||||
|
const CONTEXT: HarnessActorContext = {
|
||||||
|
actorId: 'contract-actor',
|
||||||
|
tenantId: 'contract-tenant',
|
||||||
|
seatId: 'contract-seat',
|
||||||
|
correlationId: 'contract-correlation',
|
||||||
|
};
|
||||||
|
|
||||||
|
function toSelection(entry: HarnessCatalogEntry): HarnessSelection {
|
||||||
|
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAvailable(models: readonly HarnessCatalogEntry[]): HarnessCatalogEntry {
|
||||||
|
const entry = models.find((candidate) => candidate.availability === 'available') ?? models[0];
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error('contract fixture requires at least one catalog model');
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureError(run: () => Promise<unknown>): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
return undefined;
|
||||||
|
} catch (caught) {
|
||||||
|
return caught;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared conformance suite every {@link HarnessAdapter} must pass. Slice Zero
|
||||||
|
* runs it against the fake adapter; Task 13 re-runs the identical suite against
|
||||||
|
* the native Pi adapter so both share one behavioral contract.
|
||||||
|
*/
|
||||||
|
export function runHarnessAdapterContract(
|
||||||
|
label: string,
|
||||||
|
createAdapter: () => HarnessAdapter,
|
||||||
|
): void {
|
||||||
|
describe(`harness adapter contract: ${label}`, () => {
|
||||||
|
it('mints a fresh native session on create and binds the supplied one on resume', async () => {
|
||||||
|
const adapter = createAdapter();
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const selection = toSelection(pickAvailable(catalog.models));
|
||||||
|
|
||||||
|
const created = await (
|
||||||
|
await adapter.create({ context: CONTEXT, conversationId: 'conv-create', selection })
|
||||||
|
).snapshot();
|
||||||
|
const resumed = await (
|
||||||
|
await adapter.resume({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conv-resume',
|
||||||
|
nativeSessionId: 'native-supplied-1',
|
||||||
|
selection,
|
||||||
|
})
|
||||||
|
).snapshot();
|
||||||
|
|
||||||
|
expect(created.nativeSessionId).toBeTruthy();
|
||||||
|
expect(resumed.nativeSessionId).toBe('native-supplied-1');
|
||||||
|
expect(created.nativeSessionId).not.toBe(resumed.nativeSessionId);
|
||||||
|
expect(created.seatId).toBe(CONTEXT.seatId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives detach, evict, and end distinct effects (not aliases)', async () => {
|
||||||
|
const adapter = createAdapter();
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const selection = toSelection(pickAvailable(catalog.models));
|
||||||
|
const handle = await adapter.create({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conv-lifecycle',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handle.attach({ clientId: 'browser-1' });
|
||||||
|
await handle.detach('browser-1');
|
||||||
|
const afterDetach = await handle.snapshot();
|
||||||
|
expect(afterDetach.attachedClientIds).toEqual([]);
|
||||||
|
expect(afterDetach.state).not.toBe('evicted');
|
||||||
|
expect(afterDetach.state).not.toBe('ended');
|
||||||
|
|
||||||
|
await handle.evictProcess('idle_timeout');
|
||||||
|
const afterEvict = await handle.snapshot();
|
||||||
|
expect(afterEvict.state).toBe('evicted');
|
||||||
|
// The native session survives eviction (resumable); the process does not.
|
||||||
|
expect(afterEvict.nativeSessionId).toBe(afterDetach.nativeSessionId);
|
||||||
|
expect(afterEvict.processId).toBeUndefined();
|
||||||
|
|
||||||
|
await handle.endSession('session_ended');
|
||||||
|
const afterEnd = await handle.snapshot();
|
||||||
|
expect(afterEnd.state).toBe('ended');
|
||||||
|
// End is not an alias of evict.
|
||||||
|
expect(afterEnd.state).not.toBe(afterEvict.state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never substitutes the first catalog row for an unknown selection', async () => {
|
||||||
|
const adapter = createAdapter();
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const firstRow = catalog.models[0];
|
||||||
|
if (!firstRow) {
|
||||||
|
throw new Error('contract fixture requires a catalog model');
|
||||||
|
}
|
||||||
|
const start = toSelection(pickAvailable(catalog.models));
|
||||||
|
const handle = await adapter.create({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conv-nosub',
|
||||||
|
selection: start,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bogus: HarnessSelection = {
|
||||||
|
harnessId: adapter.id,
|
||||||
|
providerId: 'contract-ghost-provider',
|
||||||
|
modelId: 'contract-ghost-model',
|
||||||
|
};
|
||||||
|
const error = await captureError(() => handle.setModel(bogus));
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('selection_invalid');
|
||||||
|
expect(dto.selection).toEqual(bogus);
|
||||||
|
expect(dto.selection).not.toEqual(toSelection(firstRow));
|
||||||
|
expect((await handle.snapshot()).selection).toEqual(start);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates capability-gated interactions with a typed error, not a silent no-op', async () => {
|
||||||
|
const adapter = createAdapter();
|
||||||
|
const descriptor = await adapter.describe(CONTEXT);
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const selection = toSelection(pickAvailable(catalog.models));
|
||||||
|
const handle = await adapter.create({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conv-interaction',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
requestId: 'interaction-1',
|
||||||
|
type: 'confirm',
|
||||||
|
accepted: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
if (descriptor.capabilities.includes('extensionUi')) {
|
||||||
|
await expect(handle.respondInteraction(response)).resolves.toBeUndefined();
|
||||||
|
} else {
|
||||||
|
const error = await captureError(() => handle.respondInteraction(response));
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import * as nodeOs from 'node:os';
|
||||||
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
|
import * as nodeUrl from 'node:url';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
|
import type * as MosaicStorage from '@mosaicstack/storage';
|
||||||
|
import { describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||||
|
|
||||||
|
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
||||||
|
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
function snapshotProcessEnv(): Record<string, string | undefined> {
|
||||||
|
return { ...process.env };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (!(key in snapshot)) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(snapshot)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
|
||||||
|
const relativePath = relative(tempRoot, path);
|
||||||
|
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
|
||||||
|
expectPathUnderTempRoot(path, tempRoot);
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, contents, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BootstrapPreflightResult {
|
||||||
|
capturedConfig: MosaicConfig | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBootstrapPreflight(
|
||||||
|
anchoredConfigContents: string,
|
||||||
|
ambientConfigContents: string,
|
||||||
|
): Promise<BootstrapPreflightResult> {
|
||||||
|
const originalEnv = snapshotProcessEnv();
|
||||||
|
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-main-preflight-'));
|
||||||
|
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||||
|
let exitSpy: MockInstance<typeof process.exit> | undefined;
|
||||||
|
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||||
|
let capturedConfig: MosaicConfig | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
|
||||||
|
const homePath = join(tempRoot, 'home');
|
||||||
|
const cwdPath = join(tempRoot, 'ambient', 'cwd');
|
||||||
|
const monorepoRootConfigPath = resolve(anchor, '../../..', 'mosaic.config.json');
|
||||||
|
|
||||||
|
await mkdir(anchor, { recursive: true });
|
||||||
|
await mkdir(cwdPath, { recursive: true });
|
||||||
|
|
||||||
|
await writeFixture(monorepoRootConfigPath, anchoredConfigContents, tempRoot);
|
||||||
|
await writeFixture(join(cwdPath, 'mosaic.config.json'), ambientConfigContents, tempRoot);
|
||||||
|
|
||||||
|
process.env['HOME'] = homePath;
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = 'fixture-secret';
|
||||||
|
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
delete process.env['DATABASE_URL'];
|
||||||
|
delete process.env['VALKEY_URL'];
|
||||||
|
|
||||||
|
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
||||||
|
const exitMock = vi.fn<typeof process.exit>();
|
||||||
|
exitSpy = vi.spyOn(process, 'exit').mockImplementation(exitMock);
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
|
||||||
|
vi.doMock('node:url', () => ({
|
||||||
|
...nodeUrl,
|
||||||
|
fileURLToPath: (url: string | URL): string => {
|
||||||
|
const actualPath = nodeUrl.fileURLToPath(url);
|
||||||
|
if (
|
||||||
|
actualPath.endsWith('/apps/gateway/src/env.ts') ||
|
||||||
|
actualPath.endsWith('/apps/gateway/src/env.js')
|
||||||
|
) {
|
||||||
|
return join(anchor, 'env.ts');
|
||||||
|
}
|
||||||
|
return actualPath;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
|
||||||
|
vi.doMock('./tracing.js', () => ({}));
|
||||||
|
|
||||||
|
const preflightSentinel = new Error('preflight-capture-sentinel');
|
||||||
|
vi.doMock('@mosaicstack/storage', async () => {
|
||||||
|
const actual = await vi.importActual<typeof MosaicStorage>('@mosaicstack/storage');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
detectAndAssertTier: vi.fn((config: MosaicConfig): Promise<void> => {
|
||||||
|
capturedConfig = config;
|
||||||
|
throw preflightSentinel;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await import('./main.js');
|
||||||
|
await vi.waitFor((): void => {
|
||||||
|
expect(exitSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { capturedConfig };
|
||||||
|
} finally {
|
||||||
|
cwdSpy?.mockRestore();
|
||||||
|
exitSpy?.mockRestore();
|
||||||
|
consoleInfoSpy?.mockRestore();
|
||||||
|
vi.doUnmock('@mosaicstack/storage');
|
||||||
|
vi.doUnmock('./tracing.js');
|
||||||
|
vi.doUnmock('node:url');
|
||||||
|
vi.doUnmock('node:os');
|
||||||
|
vi.resetModules();
|
||||||
|
restoreProcessEnv(originalEnv);
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('main bootstrap preflight config anchoring', (): void => {
|
||||||
|
it(
|
||||||
|
'passes the anchored monorepo-root config to detectAndAssertTier, not an ambient cwd config',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const anchoredConfig = JSON.stringify({
|
||||||
|
tier: 'local',
|
||||||
|
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
||||||
|
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
||||||
|
memory: { type: 'keyword' },
|
||||||
|
});
|
||||||
|
const ambientConfig = JSON.stringify({
|
||||||
|
tier: 'federated',
|
||||||
|
storage: {
|
||||||
|
type: 'postgres',
|
||||||
|
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
||||||
|
enableVector: true,
|
||||||
|
},
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: 'pgvector' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { capturedConfig } = await runBootstrapPreflight(anchoredConfig, ambientConfig);
|
||||||
|
|
||||||
|
expect(capturedConfig?.tier).toBe('local');
|
||||||
|
expect(capturedConfig?.storage).not.toEqual(
|
||||||
|
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,18 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { config } from 'dotenv';
|
import './env.js';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { resolve, join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
|
|
||||||
// Load .env from daemon config dir (global install / daemon mode).
|
|
||||||
// Loaded first so monorepo .env can override for local dev.
|
|
||||||
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
|
|
||||||
if (existsSync(daemonEnv)) config({ path: daemonEnv });
|
|
||||||
|
|
||||||
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
|
|
||||||
config({ path: resolve(process.cwd(), '../../.env') });
|
|
||||||
config(); // Also load apps/gateway/.env if present (overrides)
|
|
||||||
|
|
||||||
import './tracing.js';
|
import './tracing.js';
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
@@ -26,6 +13,7 @@ import { mountAuthHandler } from './auth/auth.controller.js';
|
|||||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||||
import { McpService } from './mcp/mcp.service.js';
|
import { McpService } from './mcp/mcp.service.js';
|
||||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||||
|
import { resolveGatewayConfigPath } from './env.js';
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const logger = new Logger('Bootstrap');
|
const logger = new Logger('Bootstrap');
|
||||||
@@ -37,7 +25,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
// Pre-flight: assert all external services required by the configured tier
|
// Pre-flight: assert all external services required by the configured tier
|
||||||
// are reachable. Runs before NestFactory.create() so failures are visible
|
// are reachable. Runs before NestFactory.create() so failures are visible
|
||||||
// immediately with actionable remediation hints.
|
// immediately with actionable remediation hints.
|
||||||
const mosaicConfig = loadConfig();
|
const mosaicConfig = loadConfig(resolveGatewayConfigPath());
|
||||||
try {
|
try {
|
||||||
await detectAndAssertTier(mosaicConfig);
|
await detectAndAssertTier(mosaicConfig);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { McpClientService } from './mcp-client.service.js';
|
||||||
|
|
||||||
|
const MCP_LEAK_MARKER = 'MCP_LEAK_MARKER /srv/secret';
|
||||||
|
|
||||||
|
describe('McpClientService — failed connect error sanitization', () => {
|
||||||
|
const originalMcpServers = process.env['MCP_SERVERS'];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env['MCP_SERVERS'] = JSON.stringify([
|
||||||
|
{ name: 'leaky-server', url: 'http://localhost:9999/mcp' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
if (originalMcpServers === undefined) {
|
||||||
|
delete process.env['MCP_SERVERS'];
|
||||||
|
} else {
|
||||||
|
process.env['MCP_SERVERS'] = originalMcpServers;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores a generic serverEntry.error while logging the raw exception server-side', async () => {
|
||||||
|
vi.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error(MCP_LEAK_MARKER));
|
||||||
|
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const service = new McpClientService();
|
||||||
|
await service.onModuleInit();
|
||||||
|
|
||||||
|
const statuses = service.getServerStatuses();
|
||||||
|
expect(statuses).toHaveLength(1);
|
||||||
|
expect(statuses[0]?.connected).toBe(false);
|
||||||
|
expect(statuses[0]?.error).toBe('Connection failed (see server logs).');
|
||||||
|
expect(statuses[0]?.error).not.toContain(MCP_LEAK_MARKER);
|
||||||
|
|
||||||
|
const loggedRawMarker = errorSpy.mock.calls.some((call) =>
|
||||||
|
call.some((arg) => typeof arg === 'string' && arg.includes(MCP_LEAK_MARKER)),
|
||||||
|
);
|
||||||
|
expect(loggedRawMarker).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -189,7 +189,7 @@ export class McpClientService implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
serverEntry.error = message;
|
serverEntry.error = 'Connection failed (see server logs).';
|
||||||
serverEntry.connected = false;
|
serverEntry.connected = false;
|
||||||
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { RuntimeProviderService } from '../agent/runtime-provider-registry.servi
|
|||||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||||
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
||||||
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
||||||
|
import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js';
|
||||||
|
import { EmbeddedChatRuntime } from '../chat/embedded-chat.runtime.js';
|
||||||
|
import { HarnessChatRuntime } from '../chat/harness-chat.runtime.js';
|
||||||
|
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||||
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
||||||
|
|
||||||
const SERVICE_TOKEN = 'test-service-token';
|
const SERVICE_TOKEN = 'test-service-token';
|
||||||
@@ -25,6 +29,7 @@ const ENV_KEYS = [
|
|||||||
'DISCORD_ALLOWED_USER_IDS',
|
'DISCORD_ALLOWED_USER_IDS',
|
||||||
'MOSAIC_AGENT_NAME',
|
'MOSAIC_AGENT_NAME',
|
||||||
'MOSAIC_AGENT_CONFIG_ID',
|
'MOSAIC_AGENT_CONFIG_ID',
|
||||||
|
'CHAT_HARNESS_RUNTIME',
|
||||||
] as const;
|
] as const;
|
||||||
const savedEnv = new Map<string, string | undefined>();
|
const savedEnv = new Map<string, string | undefined>();
|
||||||
|
|
||||||
@@ -150,6 +155,57 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 5 fence (C): the Discord SEND path runs through the exclusive {@link ChatRuntimeRouter},
|
||||||
|
* constructed here in `pi-rpc` mode with a fully-resolved runtime (`active` = harness). A verified
|
||||||
|
* Discord *service* turn must nonetheless execute on the {@link EmbeddedChatRuntime} — never the
|
||||||
|
* harness, never the routing engine — per the Q1/Q2 adjudication: the router owns a dedicated
|
||||||
|
* verified-ingress dispatch that delegates to embedded regardless of mode, with zero harness
|
||||||
|
* fallback. The gateway is given the router in the former direct-`AgentService` constructor slot.
|
||||||
|
*
|
||||||
|
* RED today: production still reads that slot as a bare `AgentService`, so `this.agentService`
|
||||||
|
* resolves to the router, `getSession(...)` is not a function, the send path throws and is caught
|
||||||
|
* (an `error` is emitted and the handler returns) BEFORE it ever reaches the embedded runtime. The
|
||||||
|
* failure is behavioural wiring — collection, DI, and `onModuleInit` all succeed. GREEN re-routes
|
||||||
|
* the verified Discord dispatch through the router into the embedded runtime, satisfying the
|
||||||
|
* preserved create/prompt assertions without weakening any control. `harnessConversations.append`
|
||||||
|
* proves the harness path is never touched even though the pi-rpc router resolved it as `active`.
|
||||||
|
*
|
||||||
|
* Correction #4 is proved behaviourally, not by naming an accessor: the verified-ingress dispatch
|
||||||
|
* is reachable only from the fully-verified `discordService` branch (the create/prompt tests below)
|
||||||
|
* and never from a browser-emittable socket event (the browser-forgery refusal test).
|
||||||
|
*/
|
||||||
|
function readyPiRpcRegistry(): HarnessRegistry {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
// A registered 'pi' adapter + an available (non-sentinel) conversation service let the pi-rpc
|
||||||
|
// router resolve `active` = harness instead of failing closed at init, so these tests model the
|
||||||
|
// real hostile condition — the harness runtime IS live — rather than a degraded router.
|
||||||
|
registry.register({ id: 'pi' } as never);
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function piRpcRouterFronting(
|
||||||
|
agentService: unknown,
|
||||||
|
harnessConversations: { append: ReturnType<typeof vi.fn> },
|
||||||
|
): ChatRuntimeRouter {
|
||||||
|
const routerConversationServiceTripwire = {
|
||||||
|
append: () => {
|
||||||
|
throw new Error('router conversation service must not be resolved on the Discord path');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const embedded = new EmbeddedChatRuntime(agentService as never);
|
||||||
|
const harness = new HarnessChatRuntime(harnessConversations as never);
|
||||||
|
const router = new ChatRuntimeRouter(
|
||||||
|
readyPiRpcRegistry(),
|
||||||
|
routerConversationServiceTripwire as never,
|
||||||
|
embedded,
|
||||||
|
harness,
|
||||||
|
'pi-rpc',
|
||||||
|
);
|
||||||
|
router.onModuleInit();
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
describe('Discord ingress security', () => {
|
describe('Discord ingress security', () => {
|
||||||
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
|
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
|
||||||
const [binding] = parseDiscordInteractionBindings(
|
const [binding] = parseDiscordInteractionBindings(
|
||||||
@@ -433,6 +489,7 @@ describe('Discord ingress security', () => {
|
|||||||
|
|
||||||
it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => {
|
it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => {
|
||||||
configureDiscordEnv();
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002';
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002';
|
||||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
{
|
{
|
||||||
@@ -489,8 +546,9 @@ describe('Discord ingress security', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const routingEngine = { resolve: vi.fn() };
|
const routingEngine = { resolve: vi.fn() };
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
const gateway = new ChatGateway(
|
const gateway = new ChatGateway(
|
||||||
agentService as never,
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
brain as never,
|
brain as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
@@ -531,6 +589,575 @@ describe('Discord ingress security', () => {
|
|||||||
expect.objectContaining({ agentConfigId: 'agent-config-orion' }),
|
expect.objectContaining({ agentConfigId: 'agent-config-orion' }),
|
||||||
);
|
);
|
||||||
expect(routingEngine.resolve).not.toHaveBeenCalled();
|
expect(routingEngine.resolve).not.toHaveBeenCalled();
|
||||||
|
// Even though the pi-rpc router resolved the harness as `active`, verified Discord ingress must
|
||||||
|
// never touch it — the create path stays on the embedded runtime.
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches a verified Discord SEND once and drops a byte-identical replay with zero additional dispatch/persist/ack (Task 5 G4)', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const session = {
|
||||||
|
provider: 'configured-provider',
|
||||||
|
modelId: 'configured-model',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
agentName: 'Nova',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const createSession = vi.fn().mockResolvedValue(session);
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
createSession,
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||||
|
const brain = {
|
||||||
|
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ resolve: vi.fn() } as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'discord-client-replay',
|
||||||
|
data: { discordService: true },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
const ackCount = (): number =>
|
||||||
|
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
|
||||||
|
|
||||||
|
// One fully-valid signed envelope; the replay reuses the SAME object (same messageId).
|
||||||
|
const envelope = ingressEnvelope('verified once', 'discord-replay-001', {
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
});
|
||||||
|
|
||||||
|
// First delivery: the verified-Discord SEND runs the full embedded dispatch exactly once.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
|
||||||
|
// Byte-identical replay: the messageId is already claimed, so resolveDiscordIngress returns
|
||||||
|
// null and the SEND handler bails before dispatch/persist/ack. Every effect stays at exactly one.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
// The harness runtime is never touched on either delivery.
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a verified SEND that fails the configured service identity consumes no replay claim, so a corrected byte-identical retry dispatches/persists/acks exactly once and a later duplicate stays fail-closed (Task 5 item 4 — claim ordering)', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const session = {
|
||||||
|
provider: 'configured-provider',
|
||||||
|
modelId: 'configured-model',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
agentName: 'Nova',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const createSession = vi.fn().mockResolvedValue(session);
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
createSession,
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||||
|
const brain = {
|
||||||
|
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ resolve: vi.fn() } as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'discord-client-claim-ordering',
|
||||||
|
data: { discordService: true },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
const ackCount = (): number =>
|
||||||
|
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
|
||||||
|
|
||||||
|
// A single fully-valid signed envelope, reused byte-for-byte across all three deliveries.
|
||||||
|
const envelope = ingressEnvelope('verified once with late identity', 'discord-order-001', {
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
});
|
||||||
|
|
||||||
|
// (1) Configured service identity is MISSING. The envelope is validly signed and passes the
|
||||||
|
// binding + route checks, but the SEND must refuse at the identity gate BEFORE any claim
|
||||||
|
// or effect. If the claim fires ahead of that gate, this delivery silently burns the
|
||||||
|
// replay claim for `discord-order-001` even though nothing dispatched.
|
||||||
|
delete process.env['DISCORD_SERVICE_USER_ID'];
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(0);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(0);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(0);
|
||||||
|
expect(ackCount()).toBe(0);
|
||||||
|
|
||||||
|
// (2) Identity is now configured; the operator resends the SAME envelope byte-for-byte. Because
|
||||||
|
// step (1) consumed no claim, this corrected retry claims once and runs the full embedded
|
||||||
|
// dispatch exactly once. (Under the pre-fix ordering the claim was already spent in step (1),
|
||||||
|
// so this retry is dropped as a replay and never dispatches — the RED this test drives.)
|
||||||
|
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
|
||||||
|
// (3) A genuine duplicate after a committed turn stays fail-closed: the claim taken in step (2)
|
||||||
|
// blocks it, so every effect remains at exactly one.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a verified SEND whose configured agent record fails reconciliation consumes no replay claim, so a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3)', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const session = {
|
||||||
|
provider: 'configured-provider',
|
||||||
|
modelId: 'configured-model',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
agentName: 'Nova',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const createSession = vi.fn().mockResolvedValue(session);
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
createSession,
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||||
|
// The durable agent record does not reconcile on the first delivery (its name no longer matches
|
||||||
|
// the verified binding's instance id), then reconciles cleanly on the corrected retry.
|
||||||
|
const findAgent = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ id: 'agent-config-nova', name: 'Renamed-Away' })
|
||||||
|
.mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' });
|
||||||
|
const brain = {
|
||||||
|
agents: { findById: findAgent },
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ resolve: vi.fn() } as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'discord-client-reconcile',
|
||||||
|
data: { discordService: true },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
const ackCount = (): number =>
|
||||||
|
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
|
||||||
|
|
||||||
|
const envelope = ingressEnvelope(
|
||||||
|
'verified once with stale agent record',
|
||||||
|
'discord-reconcile-001',
|
||||||
|
{
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// (1) The configured-agent reconcile runs BEFORE the replay claim. A mismatch refuses the turn
|
||||||
|
// and, crucially, consumes no claim for discord-reconcile-001 — nothing dispatches.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(0);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(0);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(0);
|
||||||
|
expect(ackCount()).toBe(0);
|
||||||
|
|
||||||
|
// (2) The record now reconciles; because step (1) took no claim, this byte-identical retry claims
|
||||||
|
// once and runs the full embedded dispatch exactly once. (Pre-fix, the claim was spent ahead
|
||||||
|
// of the reconcile in step (1), so this retry was dropped as a replay — the RED this drives.)
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
|
||||||
|
// (3) A genuine duplicate after the committed turn stays fail-closed.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a verified SEND refuses to reuse a same-scope embedded session minted under a different configured identity, with zero prompt/persist/ack (Task 5 finding 3)', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// A live session already exists for this conversation/scope, but it was minted under a DIFFERENT
|
||||||
|
// configured agent (Orion). The verified binding reconciles to Nova, so reusing this session would
|
||||||
|
// execute one agent's turn under another agent's verified label — the reuse guard must refuse it.
|
||||||
|
const foreignIdentitySession = {
|
||||||
|
provider: 'configured-provider',
|
||||||
|
modelId: 'configured-model',
|
||||||
|
agentConfigId: 'agent-config-orion',
|
||||||
|
agentName: 'Orion',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const createSession = vi.fn().mockResolvedValue(foreignIdentitySession);
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(foreignIdentitySession),
|
||||||
|
createSession,
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||||
|
const brain = {
|
||||||
|
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ resolve: vi.fn() } as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'discord-client-identity-swap',
|
||||||
|
data: { discordService: true },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleMessage(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('reuse under a different identity', 'discord-identity-swap-001', {
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refused at the embedded reuse guard: no prompt, no persist, no ack — only a typed refusal.
|
||||||
|
expect(prompt).not.toHaveBeenCalled();
|
||||||
|
expect(addMessage).not.toHaveBeenCalled();
|
||||||
|
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
||||||
|
expect(client.emit).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }),
|
||||||
|
);
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a verified SEND whose configured agent record resolves under a different id fails reconciliation, consumes no replay claim, and a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3 — id axis)', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const session = {
|
||||||
|
provider: 'configured-provider',
|
||||||
|
modelId: 'configured-model',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
agentName: 'Nova',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const createSession = vi.fn().mockResolvedValue(session);
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
createSession,
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||||
|
// The name matches the verified binding, but the record's own id is a DIFFERENT agent config —
|
||||||
|
// an aliased/substituted lookup. Exact-id reconciliation must refuse it on the first delivery,
|
||||||
|
// then admit the corrected record whose id matches the binding.
|
||||||
|
const findAgent = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ id: 'agent-config-elsewhere', name: 'Nova' })
|
||||||
|
.mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' });
|
||||||
|
const brain = {
|
||||||
|
agents: { findById: findAgent },
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ resolve: vi.fn() } as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'discord-client-reconcile-id',
|
||||||
|
data: { discordService: true },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
const ackCount = (): number =>
|
||||||
|
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
|
||||||
|
|
||||||
|
const envelope = ingressEnvelope(
|
||||||
|
'verified once with aliased agent id',
|
||||||
|
'discord-reconcile-id-001',
|
||||||
|
{
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// (1) The record's id differs from the binding's agentConfigId. Exact-id reconcile refuses the
|
||||||
|
// turn BEFORE the replay claim, so nothing dispatches and the claim stays available.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(0);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(0);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(0);
|
||||||
|
expect(ackCount()).toBe(0);
|
||||||
|
|
||||||
|
// (2) The record now reconciles on both id and name; because step (1) took no claim, this
|
||||||
|
// byte-identical retry claims once and runs the full embedded dispatch exactly once.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
|
||||||
|
// (3) A genuine duplicate after the committed turn stays fail-closed.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a verified SEND refuses a freshly minted same-scope session whose identity differs from the reconciled configured agent, with zero prompt/persist/ack (Task 5 finding 3 — post-create)', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// No live session exists for this scope, so the runtime MINTS one — but createSession returns a
|
||||||
|
// session carrying a DIFFERENT configured identity (Orion) than the reconciled binding (Nova).
|
||||||
|
// The post-create identity recheck must refuse it rather than dispatch one agent's turn under
|
||||||
|
// another agent's verified label. (The existing reuse test covers the getSession path; this
|
||||||
|
// covers the createSession path scrappy flagged as unvalidated.)
|
||||||
|
const mintedForeignSession = {
|
||||||
|
provider: 'configured-provider',
|
||||||
|
modelId: 'configured-model',
|
||||||
|
agentConfigId: 'agent-config-orion',
|
||||||
|
agentName: 'Orion',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const createSession = vi.fn().mockResolvedValue(mintedForeignSession);
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
createSession,
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||||
|
const brain = {
|
||||||
|
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ resolve: vi.fn() } as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'discord-client-postcreate-mismatch',
|
||||||
|
data: { discordService: true },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleMessage(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('mint under a different identity', 'discord-postcreate-001', {
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The freshly minted session failed the post-create identity recheck: refused with a typed
|
||||||
|
// error, no prompt, no persist, no ack.
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).not.toHaveBeenCalled();
|
||||||
|
expect(addMessage).not.toHaveBeenCalled();
|
||||||
|
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
||||||
|
expect(client.emit).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }),
|
||||||
|
);
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retains validated persisted attachments in resumed conversation history', async () => {
|
it('retains validated persisted attachments in resumed conversation history', async () => {
|
||||||
@@ -593,11 +1220,16 @@ describe('Discord ingress security', () => {
|
|||||||
|
|
||||||
it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => {
|
it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => {
|
||||||
configureDiscordEnv();
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
const prompt = vi.fn().mockResolvedValue(undefined);
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
const addMessage = vi.fn().mockResolvedValue(undefined);
|
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||||
const session = {
|
const session = {
|
||||||
provider: 'test-provider',
|
provider: 'test-provider',
|
||||||
modelId: 'test-model',
|
modelId: 'test-model',
|
||||||
|
// The reused embedded session carries the SAME reconciled identity as the verified binding,
|
||||||
|
// so the finding-3 session-reuse guard admits it rather than refusing an identity swap.
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
agentName: 'Nova',
|
||||||
piSession: {
|
piSession: {
|
||||||
thinkingLevel: 'medium',
|
thinkingLevel: 'medium',
|
||||||
getAvailableThinkingLevels: (): string[] => ['medium'],
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
@@ -611,6 +1243,7 @@ describe('Discord ingress security', () => {
|
|||||||
prompt,
|
prompt,
|
||||||
};
|
};
|
||||||
const brain = {
|
const brain = {
|
||||||
|
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
|
||||||
conversations: {
|
conversations: {
|
||||||
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -618,8 +1251,9 @@ describe('Discord ingress security', () => {
|
|||||||
addMessage,
|
addMessage,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
const gateway = new ChatGateway(
|
const gateway = new ChatGateway(
|
||||||
agentService as never,
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
brain as never,
|
brain as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
@@ -667,6 +1301,66 @@ describe('Discord ingress security', () => {
|
|||||||
}),
|
}),
|
||||||
'discord-service',
|
'discord-service',
|
||||||
);
|
);
|
||||||
|
// The verified Discord prompt dispatch stays on the embedded runtime; the pi-rpc harness that
|
||||||
|
// the router resolved as `active` is never reached.
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a browser-forged Discord ingress envelope in pi-rpc with a fixed typed refusal and zero dispatch', async () => {
|
||||||
|
// Correction #2 + #4 (behavioural). A browser socket is never `discordService` (that flag is
|
||||||
|
// set only on a valid service-token handshake), so it cannot forge the trusted Discord path by
|
||||||
|
// emitting an envelope-shaped payload. In pi-rpc it must receive a FIXED TYPED refusal
|
||||||
|
// (`runtime_unsupported`, the same typed code the sibling harness-fence uses) and reach neither
|
||||||
|
// the forced Discord service scope, the verified Discord operation, the embedded runtime, nor
|
||||||
|
// the harness. There is no dedicated socket event for verified ingress — the only ingress
|
||||||
|
// surface is the generic `message` handler, and a non-service client is refused there.
|
||||||
|
//
|
||||||
|
// RED today: a non-service client emitting an envelope-shaped payload falls to the browser
|
||||||
|
// branch, fails the chat-message shape check, and is dropped SILENTLY (a warn + return) with no
|
||||||
|
// typed refusal emitted — so the refusal assertion fails. Collection and construction succeed;
|
||||||
|
// the gap is behavioural. GREEN emits the fixed typed refusal before any dispatch.
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
createSession: vi.fn(),
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const routingEngine = { resolve: vi.fn() };
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
|
{} as never,
|
||||||
|
{ conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
routingEngine as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'browser-forging-discord',
|
||||||
|
data: { discordService: false },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleMessage(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('forged from a browser', 'browser-forgery-001', {
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const refusal = client.emit.mock.calls.find(
|
||||||
|
([, payload]) => (payload as { code?: string } | undefined)?.code === 'runtime_unsupported',
|
||||||
|
);
|
||||||
|
expect(refusal).toBeDefined();
|
||||||
|
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
||||||
|
expect(agentService.createSession).not.toHaveBeenCalled();
|
||||||
|
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
expect(routingEngine.resolve).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts a thread message through its allowed bound parent channel', () => {
|
it('accepts a thread message through its allowed bound parent channel', () => {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { SlashCommandPayload, SystemReloadPayload } from '@mosaicstack/types';
|
||||||
import { ReloadService } from './reload.service.js';
|
import { ReloadService } from './reload.service.js';
|
||||||
|
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||||
|
|
||||||
function createMockCommandRegistry() {
|
function createMockCommandRegistry() {
|
||||||
return {
|
return {
|
||||||
@@ -104,3 +107,85 @@ describe('ReloadService', () => {
|
|||||||
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
||||||
|
it('generic per-plugin errors reach the chat surface while raw markers stay server-side only', async () => {
|
||||||
|
const registry = {
|
||||||
|
getManifest: vi.fn().mockReturnValue({
|
||||||
|
version: 1,
|
||||||
|
commands: [
|
||||||
|
{ name: 'reload', aliases: [], scope: 'core', execution: 'socket', available: true },
|
||||||
|
],
|
||||||
|
skills: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const reloadService = new ReloadService(registry as never);
|
||||||
|
|
||||||
|
const RELOAD_LOAD_LEAK_MARKER = 'RELOAD_LOAD_LEAK_MARKER /srv/load-secret';
|
||||||
|
const RELOAD_UNLOAD_LEAK_MARKER = 'RELOAD_UNLOAD_LEAK_MARKER /srv/unload-secret';
|
||||||
|
|
||||||
|
reloadService.registerPlugin('unload-fails', {
|
||||||
|
pluginName: 'unload-fails',
|
||||||
|
onLoad: vi.fn().mockResolvedValue(undefined),
|
||||||
|
onUnload: vi.fn().mockRejectedValue(new Error(RELOAD_UNLOAD_LEAK_MARKER)),
|
||||||
|
});
|
||||||
|
reloadService.registerPlugin('load-fails', {
|
||||||
|
pluginName: 'load-fails',
|
||||||
|
onLoad: vi.fn().mockRejectedValue(new Error(RELOAD_LOAD_LEAK_MARKER)),
|
||||||
|
onUnload: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
const broadcastReload = vi.fn();
|
||||||
|
const mockChatGateway = { broadcastReload };
|
||||||
|
const mockAgentService = { getSession: vi.fn(), applyAgentConfig: vi.fn() };
|
||||||
|
const mockSystemOverride = { set: vi.fn(), get: vi.fn(), clear: vi.fn() };
|
||||||
|
const mockSessionGC = { sweepOrphans: vi.fn() };
|
||||||
|
const mockBrain = { agents: { findByName: vi.fn(), findById: vi.fn(), create: vi.fn() } };
|
||||||
|
|
||||||
|
const mockMcpClient = {
|
||||||
|
getServerStatuses: vi.fn(() => []),
|
||||||
|
getToolDefinitions: vi.fn(() => []),
|
||||||
|
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const executor = new CommandExecutorService(
|
||||||
|
registry as never,
|
||||||
|
mockAgentService as never,
|
||||||
|
mockSystemOverride as never,
|
||||||
|
mockSessionGC as never,
|
||||||
|
null,
|
||||||
|
mockBrain as never,
|
||||||
|
reloadService,
|
||||||
|
mockChatGateway as never,
|
||||||
|
mockMcpClient as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
||||||
|
const result = await executor.execute(payload, { userId: 'user-1', tenantId: 'user-1' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('unload-fails: unload failed (internal error)');
|
||||||
|
expect(result.message).toContain('load-fails: load failed (internal error)');
|
||||||
|
expect(result.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
|
||||||
|
expect(result.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
|
||||||
|
|
||||||
|
expect(broadcastReload).toHaveBeenCalledOnce();
|
||||||
|
const broadcastPayload = broadcastReload.mock.calls[0]?.[0] as SystemReloadPayload;
|
||||||
|
expect(broadcastPayload.message).toContain('unload-fails: unload failed (internal error)');
|
||||||
|
expect(broadcastPayload.message).toContain('load-fails: load failed (internal error)');
|
||||||
|
expect(broadcastPayload.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
|
||||||
|
expect(broadcastPayload.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
|
||||||
|
|
||||||
|
const loggedUnloadMarker = errorSpy.mock.calls.some((call) =>
|
||||||
|
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_UNLOAD_LEAK_MARKER)),
|
||||||
|
);
|
||||||
|
const loggedLoadMarker = errorSpy.mock.calls.some((call) =>
|
||||||
|
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_LOAD_LEAK_MARKER)),
|
||||||
|
);
|
||||||
|
expect(loggedUnloadMarker).toBe(true);
|
||||||
|
expect(loggedLoadMarker).toBe(true);
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
|
|||||||
await plugin.onUnload();
|
await plugin.onUnload();
|
||||||
reloaded.push(name);
|
reloaded.push(name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push(`${name}: unload failed — ${err}`);
|
this.logger.error(`Plugin "${name}" failed during onUnload: ${err}`);
|
||||||
|
errors.push(`${name}: unload failed (internal error)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +70,8 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
|
|||||||
try {
|
try {
|
||||||
await plugin.onLoad();
|
await plugin.onLoad();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push(`${name}: load failed — ${err}`);
|
this.logger.error(`Plugin "${name}" failed during onLoad: ${err}`);
|
||||||
|
errors.push(`${name}: load failed (internal error)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import {
|
||||||
|
type CanActivate,
|
||||||
|
type ExecutionContext,
|
||||||
|
type INestApplication,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||||
|
import { WorkspaceController } from './workspace.controller.js';
|
||||||
|
|
||||||
|
const bootstrapMock = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
projectId: 'project-1',
|
||||||
|
workspacePath: '/opt/mosaic/.workspaces/users/user-1/project-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authGuard: CanActivate = {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
||||||
|
requestContext.user = { id: 'user-1' };
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('POST /api/workspaces repoUrl validation', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
controllers: [WorkspaceController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: ProjectBootstrapService,
|
||||||
|
useValue: { bootstrap: bootstrapMock },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue(authGuard)
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bootstrapMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a leading-dash value', '--upload-pack=sh -c id'],
|
||||||
|
['an ext remote helper', 'ext::sh -c id'],
|
||||||
|
['a file URL', 'file:///tmp/repository'],
|
||||||
|
['an unparseable value', 'not a url'],
|
||||||
|
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
||||||
|
['a scheme without //', 'https:example.com/acme/repository.git'],
|
||||||
|
['a hostless git URL', 'git:///tmp/repository'],
|
||||||
|
])('returns 400 for %s', async (_description, repoUrl) => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/workspaces')
|
||||||
|
.send({ name: 'Example', repoUrl })
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(bootstrapMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a plain HTTPS repository URL', 'https://example.com/acme/repository.git'],
|
||||||
|
['a git protocol repository URL', 'git://example.com/acme/repository.git'],
|
||||||
|
])('accepts %s', async (_description, repoUrl) => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/workspaces')
|
||||||
|
.send({ name: 'Example', repoUrl })
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(bootstrapMock).toHaveBeenCalledWith({
|
||||||
|
name: 'Example',
|
||||||
|
description: undefined,
|
||||||
|
userId: 'user-1',
|
||||||
|
teamId: undefined,
|
||||||
|
repoUrl,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
import {
|
||||||
|
ProjectBootstrapService,
|
||||||
|
type BootstrapProjectResult,
|
||||||
|
} from './project-bootstrap.service.js';
|
||||||
|
import { CreateWorkspaceDto } from './workspace.dto.js';
|
||||||
|
|
||||||
@Controller('api/workspaces')
|
@Controller('api/workspaces')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -11,20 +15,14 @@ export class WorkspaceController {
|
|||||||
@Post()
|
@Post()
|
||||||
async create(
|
async create(
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
@Body()
|
@Body() dto: CreateWorkspaceDto,
|
||||||
body: {
|
): Promise<BootstrapProjectResult> {
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
teamId?: string;
|
|
||||||
repoUrl?: string;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
return this.bootstrap.bootstrap({
|
return this.bootstrap.bootstrap({
|
||||||
name: body.name,
|
name: dto.name,
|
||||||
description: body.description,
|
description: dto.description,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: body.teamId,
|
teamId: dto.teamId,
|
||||||
repoUrl: body.repoUrl,
|
repoUrl: dto.repoUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { IsOptional, IsString, IsUrl, Matches, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateWorkspaceDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10_000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
teamId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^(?:https|git):\/\//i, {
|
||||||
|
message: 'repoUrl must be a valid https:// or git:// URL',
|
||||||
|
})
|
||||||
|
@IsUrl(
|
||||||
|
{
|
||||||
|
protocols: ['https', 'git'],
|
||||||
|
require_host: true,
|
||||||
|
require_protocol: true,
|
||||||
|
require_tld: false,
|
||||||
|
require_valid_protocol: true,
|
||||||
|
},
|
||||||
|
{ message: 'repoUrl must be a valid https:// or git:// URL' },
|
||||||
|
)
|
||||||
|
repoUrl?: string;
|
||||||
|
}
|
||||||
@@ -1,11 +1,33 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { WorkspaceService } from './workspace.service.js';
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { WorkspaceService } from './workspace.service.js';
|
||||||
|
|
||||||
|
type ExecFileMock = (
|
||||||
|
command: string,
|
||||||
|
args: readonly string[],
|
||||||
|
options: { cwd: string },
|
||||||
|
callback: (error: Error | null, stdout: string, stderr: string) => void,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
const { execFileMock } = vi.hoisted(() => ({
|
||||||
|
execFileMock: vi.fn<ExecFileMock>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:child_process', () => ({
|
||||||
|
execFile: execFileMock,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('WorkspaceService', () => {
|
describe('WorkspaceService', () => {
|
||||||
let service: WorkspaceService;
|
let service: WorkspaceService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
execFileMock.mockReset();
|
||||||
|
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||||
|
callback(null, '', '');
|
||||||
|
});
|
||||||
service = new WorkspaceService();
|
service = new WorkspaceService();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,4 +98,69 @@ describe('WorkspaceService', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
const project = {
|
||||||
|
id: 'project-1',
|
||||||
|
ownerType: 'user',
|
||||||
|
userId: 'user-1',
|
||||||
|
teamId: null,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let originalRoot: string | undefined;
|
||||||
|
let temporaryRoot: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
originalRoot = process.env['MOSAIC_ROOT'];
|
||||||
|
temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mosaic-workspace-'));
|
||||||
|
process.env['MOSAIC_ROOT'] = temporaryRoot;
|
||||||
|
service = new WorkspaceService();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalRoot === undefined) {
|
||||||
|
delete process.env['MOSAIC_ROOT'];
|
||||||
|
} else {
|
||||||
|
process.env['MOSAIC_ROOT'] = originalRoot;
|
||||||
|
}
|
||||||
|
await fs.rm(temporaryRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a leading-dash URL', '--upload-pack=sh -c id'],
|
||||||
|
['an ext remote helper', 'ext::sh -c id'],
|
||||||
|
['a file URL', 'file:///tmp/repository'],
|
||||||
|
['an unparseable value', 'not a url'],
|
||||||
|
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
||||||
|
['a scheme without //', 'https:example.com/acme/repository.git'],
|
||||||
|
['a hostless git URL', 'git:///tmp/repository'],
|
||||||
|
])('rejects %s before invoking git', async (_description, repoUrl) => {
|
||||||
|
await expect(service.create(project, repoUrl)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(execFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['an HTTPS URL', 'https://example.com/acme/repository.git'],
|
||||||
|
['a git protocol URL', 'git://example.com/acme/repository.git'],
|
||||||
|
])('accepts %s and invokes hardened git clone arguments', async (_description, repoUrl) => {
|
||||||
|
const workspacePath = await service.create(project, repoUrl);
|
||||||
|
|
||||||
|
expect(execFileMock).toHaveBeenCalledOnce();
|
||||||
|
expect(execFileMock).toHaveBeenCalledWith(
|
||||||
|
'git',
|
||||||
|
[
|
||||||
|
'-c',
|
||||||
|
'protocol.ext.allow=never',
|
||||||
|
'-c',
|
||||||
|
'protocol.file.allow=never',
|
||||||
|
'clone',
|
||||||
|
'--',
|
||||||
|
repoUrl,
|
||||||
|
'.',
|
||||||
|
],
|
||||||
|
{ cwd: workspacePath },
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
const allowedRepositoryProtocols = new Set(['https:', 'git:']);
|
||||||
|
const repositoryUrlPrefixPattern = /^(?:https|git):\/\//i;
|
||||||
|
const repositoryUrlError = 'repoUrl must be a valid https:// or git:// URL';
|
||||||
|
|
||||||
|
function assertAllowedRepositoryUrl(repoUrl: string): void {
|
||||||
|
if (repoUrl.startsWith('-') || !repositoryUrlPrefixPattern.test(repoUrl)) {
|
||||||
|
throw new BadRequestException(repositoryUrlError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(repoUrl);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException(repositoryUrlError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedRepositoryProtocols.has(parsedUrl.protocol) || parsedUrl.hostname.length === 0) {
|
||||||
|
throw new BadRequestException(repositoryUrlError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkspaceProject {
|
export interface WorkspaceProject {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,14 +59,32 @@ export class WorkspaceService {
|
|||||||
* If repoUrl is provided, clone instead of init.
|
* If repoUrl is provided, clone instead of init.
|
||||||
*/
|
*/
|
||||||
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
||||||
|
if (repoUrl !== undefined) {
|
||||||
|
assertAllowedRepositoryUrl(repoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
const workspacePath = this.resolvePath(project);
|
const workspacePath = this.resolvePath(project);
|
||||||
|
|
||||||
// Create directory
|
// Create directory
|
||||||
await fs.mkdir(workspacePath, { recursive: true });
|
await fs.mkdir(workspacePath, { recursive: true });
|
||||||
|
|
||||||
if (repoUrl) {
|
if (repoUrl !== undefined) {
|
||||||
// Clone existing repo
|
// Clone existing repo. Defense in depth keeps dangerous local helpers
|
||||||
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath });
|
// disabled and terminates option parsing before positional arguments.
|
||||||
|
await execFileAsync(
|
||||||
|
'git',
|
||||||
|
[
|
||||||
|
'-c',
|
||||||
|
'protocol.ext.allow=never',
|
||||||
|
'-c',
|
||||||
|
'protocol.file.allow=never',
|
||||||
|
'clone',
|
||||||
|
'--',
|
||||||
|
repoUrl,
|
||||||
|
'.',
|
||||||
|
],
|
||||||
|
{ cwd: workspacePath },
|
||||||
|
);
|
||||||
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
||||||
} else {
|
} else {
|
||||||
// Init new git repo
|
// Init new git repo
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Mosaic</title>
|
||||||
|
<meta name="description" content="Mosaic Stack Dashboard" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
|
||||||
|
/>
|
||||||
|
<script>
|
||||||
|
// set data-theme before first paint so the stored theme never flashes
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
|
||||||
|
} catch (error) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,21 +4,25 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node ../../scripts/build-web.mjs",
|
"build": "node ../../scripts/build-web.mjs",
|
||||||
"dev": "next dev",
|
"build:vite": "vite build",
|
||||||
|
"dev": "next dev -p 3101",
|
||||||
|
"dev:vite": "vite",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"start": "next start"
|
"start": "next start -p 3101"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/design-tokens": "workspace:^",
|
"@mosaicstack/design-tokens": "workspace:^",
|
||||||
|
"@mosaicstack/types": "workspace:^",
|
||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router-dom": "^7.18.2",
|
||||||
"socket.io-client": "^4.8.0",
|
"socket.io-client": "^4.8.0",
|
||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
@@ -28,9 +32,11 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^2.0.0"
|
"vite": "^8.2.1",
|
||||||
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,41 +3,56 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useSearchParams } from 'next/navigation';
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
||||||
import { signIn } from '@/lib/auth-client';
|
import { signIn } from '@/lib/auth-client';
|
||||||
import { getSsoProvider } from '@/lib/sso-providers';
|
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||||
|
|
||||||
export default function AuthProviderRedirectPage(): React.ReactElement {
|
export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||||
const params = useParams<{ provider: string }>();
|
const params = useParams<{ provider: string }>();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
||||||
const provider = getSsoProvider(providerId);
|
const requestedCallbackURL = searchParams.get('callbackURL');
|
||||||
const callbackURL = searchParams.get('callbackURL') ?? '/chat';
|
const [providerName, setProviderName] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentProvider = provider;
|
|
||||||
|
|
||||||
if (!currentProvider) {
|
|
||||||
setError('Unknown SSO provider.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentProvider.enabled) {
|
|
||||||
setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeProvider = currentProvider;
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function redirectToProvider(): Promise<void> {
|
async function redirectToProvider(): Promise<void> {
|
||||||
const result = await signIn.oauth2({
|
try {
|
||||||
providerId: activeProvider.id,
|
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
||||||
callbackURL,
|
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
||||||
});
|
if (cancelled) return;
|
||||||
|
|
||||||
if (!cancelled && result?.error) {
|
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||||
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
|
if (!provider) {
|
||||||
|
setError('Unknown SSO provider.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProviderName(provider.name);
|
||||||
|
if (!provider.configured) {
|
||||||
|
setError(`${provider.name} is not enabled in this deployment.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (provider.loginMode !== 'oidc') {
|
||||||
|
setError(`${provider.name} is not available for OIDC sign in.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await signIn.oauth2({
|
||||||
|
providerId: provider.id,
|
||||||
|
callbackURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cancelled && result?.error) {
|
||||||
|
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
||||||
|
}
|
||||||
|
} catch (caught: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,19 +61,22 @@ export default function AuthProviderRedirectPage(): React.ReactElement {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [callbackURL, provider]);
|
}, [providerId, requestedCallbackURL]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||||
<p className="mt-2 text-sm text-text-secondary">
|
<p className="mt-2 text-sm text-text-secondary">
|
||||||
{provider
|
{providerName
|
||||||
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...`
|
? `Redirecting you to ${providerName}...`
|
||||||
: 'Preparing your sign-in request...'}
|
: 'Preparing your sign-in request...'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error">
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||||
|
>
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
describe('api', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches the supplied relative path with credentials and a JSON body', async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
api<{ ok: boolean }>('/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name: 'Mosaic' },
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'/api/projects',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ name: 'Mosaic' }),
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws the gateway JSON error with its statusCode', async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ statusCode: 403, message: 'Forbidden' }), {
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await expect(api('/api/admin/users')).rejects.toMatchObject({
|
||||||
|
name: 'Error',
|
||||||
|
message: 'Forbidden',
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
|
||||||
|
|
||||||
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
}
|
}
|
||||||
@@ -25,7 +23,7 @@ export async function api<T>(path: string, init?: ApiRequestInit): Promise<T> {
|
|||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`${GATEWAY_URL}${path}`, {
|
const res = await fetch(path, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
...rest,
|
...rest,
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
describe('auth client origin contract', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the same-origin BetterAuth mount at /api/auth', async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ session: null, user: null }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const { authClient } = await import('./auth-client');
|
||||||
|
await authClient.getSession();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
const firstCall = fetchMock.mock.calls.at(0);
|
||||||
|
expect(firstCall).toBeDefined();
|
||||||
|
const requestURL = new URL(String(firstCall?.[0]), window.location.origin);
|
||||||
|
expect(requestURL.origin).toBe(window.location.origin);
|
||||||
|
expect(requestURL.pathname).toBe('/api/auth/get-session');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createAuthClient } from 'better-auth/react';
|
import { createAuthClient } from 'better-auth/react';
|
||||||
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
||||||
|
|
||||||
|
// The gateway and BetterAuth client both use /api/auth. Omitting baseURL keeps
|
||||||
|
// every browser request on the current origin in development and production.
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242',
|
|
||||||
plugins: [adminClient(), genericOAuthClient()],
|
plugins: [adminClient(), genericOAuthClient()],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveAuthCallbackURL } from './auth-redirect';
|
||||||
|
|
||||||
|
const CURRENT_ORIGIN = 'https://mosaic.example';
|
||||||
|
|
||||||
|
describe('resolveAuthCallbackURL', () => {
|
||||||
|
it('preserves a canonical same-origin path with search and hash', () => {
|
||||||
|
expect(resolveAuthCallbackURL('/projects?view=active#current', CURRENT_ORIGIN)).toBe(
|
||||||
|
'/projects?view=active#current',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
null,
|
||||||
|
'chat',
|
||||||
|
'//evil.example',
|
||||||
|
'/..//evil.com',
|
||||||
|
'/..//evil.com/x',
|
||||||
|
'/./..//evil.com',
|
||||||
|
'/../..//evil.com',
|
||||||
|
'/foo/..//evil.com',
|
||||||
|
'/\\evil.example',
|
||||||
|
'/\n//evil.example',
|
||||||
|
'/\r//evil.example',
|
||||||
|
'/\t//evil.example',
|
||||||
|
'https://evil.example/phish',
|
||||||
|
])('falls back to chat for an unsafe callback target %#', (candidate) => {
|
||||||
|
expect(resolveAuthCallbackURL(candidate, CURRENT_ORIGIN)).toBe('/chat');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const DEFAULT_AUTH_CALLBACK_URL = '/chat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a canonical same-origin path for post-auth navigation.
|
||||||
|
*
|
||||||
|
* Parsing before comparing origins rejects protocol-relative URLs, backslash
|
||||||
|
* variants, and control characters that the WHATWG parser normalizes away.
|
||||||
|
*/
|
||||||
|
export function resolveAuthCallbackURL(candidate: string | null, currentOrigin: string): string {
|
||||||
|
if (!candidate?.startsWith('/')) return DEFAULT_AUTH_CALLBACK_URL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expectedOrigin = new URL(currentOrigin).origin;
|
||||||
|
const resolved = new URL(candidate, expectedOrigin);
|
||||||
|
if (resolved.origin !== expectedOrigin || resolved.pathname.startsWith('//')) {
|
||||||
|
return DEFAULT_AUTH_CALLBACK_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_AUTH_CALLBACK_URL;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Centralizes the type-only import of the shared `/chat` Socket.IO contract from
|
||||||
|
// the public `@mosaicstack/types` package. `import type` is erased at compile
|
||||||
|
// time, so this introduces no runtime dependency — it only reuses the exact
|
||||||
|
// payload shapes instead of redeclaring them.
|
||||||
|
import type { Socket } from 'socket.io-client';
|
||||||
|
import type {
|
||||||
|
AbortPayload,
|
||||||
|
AgentEndPayload,
|
||||||
|
AgentStartPayload,
|
||||||
|
AgentTextPayload,
|
||||||
|
AgentThinkingPayload,
|
||||||
|
ChatMessagePayload,
|
||||||
|
ChatSendCapabilityPayload,
|
||||||
|
ChatSendProtocol,
|
||||||
|
ClientToServerEvents,
|
||||||
|
CommandDef,
|
||||||
|
CommandManifest,
|
||||||
|
CommandManifestPayload,
|
||||||
|
ErrorPayload,
|
||||||
|
HarnessSelection,
|
||||||
|
HarnessTurnAckPayload,
|
||||||
|
HarnessTurnSendPayload,
|
||||||
|
MessageAckPayload,
|
||||||
|
RoutingDecisionInfo,
|
||||||
|
ServerToClientEvents,
|
||||||
|
SessionInfoPayload,
|
||||||
|
SessionUsagePayload,
|
||||||
|
SetThinkingPayload,
|
||||||
|
SkillCommandDef,
|
||||||
|
SlashCommandApprovalResultPayload,
|
||||||
|
SlashCommandPayload,
|
||||||
|
SlashCommandResultPayload,
|
||||||
|
SystemReloadPayload,
|
||||||
|
ToolEndPayload,
|
||||||
|
ToolStartPayload,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AbortPayload,
|
||||||
|
AgentEndPayload,
|
||||||
|
AgentStartPayload,
|
||||||
|
AgentTextPayload,
|
||||||
|
AgentThinkingPayload,
|
||||||
|
ChatMessagePayload,
|
||||||
|
ChatSendCapabilityPayload,
|
||||||
|
ChatSendProtocol,
|
||||||
|
ClientToServerEvents,
|
||||||
|
CommandDef,
|
||||||
|
CommandManifest,
|
||||||
|
CommandManifestPayload,
|
||||||
|
ErrorPayload,
|
||||||
|
HarnessSelection,
|
||||||
|
HarnessTurnAckPayload,
|
||||||
|
HarnessTurnSendPayload,
|
||||||
|
MessageAckPayload,
|
||||||
|
RoutingDecisionInfo,
|
||||||
|
ServerToClientEvents,
|
||||||
|
SessionInfoPayload,
|
||||||
|
SessionUsagePayload,
|
||||||
|
SetThinkingPayload,
|
||||||
|
SkillCommandDef,
|
||||||
|
SlashCommandApprovalResultPayload,
|
||||||
|
SlashCommandPayload,
|
||||||
|
SlashCommandResultPayload,
|
||||||
|
SystemReloadPayload,
|
||||||
|
ToolEndPayload,
|
||||||
|
ToolStartPayload,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The `/chat` namespace socket, narrowed to the exact typed event contract. */
|
||||||
|
export type ChatSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { ioMock } = vi.hoisted(() => ({
|
||||||
|
ioMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('socket.io-client', () => ({
|
||||||
|
io: ioMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { destroySocket, getSocket } from './socket';
|
||||||
|
|
||||||
|
interface MockChatSocket {
|
||||||
|
on: ReturnType<typeof vi.fn>;
|
||||||
|
offAny: ReturnType<typeof vi.fn>;
|
||||||
|
disconnect: ReturnType<typeof vi.fn>;
|
||||||
|
/** Test-only helper: fires every handler registered for `event` via
|
||||||
|
* `.on`, mirroring how a real socket.io-client instance invokes its own
|
||||||
|
* listeners (e.g. calling the registered `disconnect` handler(s) on a
|
||||||
|
* real transient disconnect). */
|
||||||
|
trigger(event: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockSocket(): MockChatSocket {
|
||||||
|
const handlers = new Map<string, Set<() => void>>();
|
||||||
|
const mockSocket: MockChatSocket = {
|
||||||
|
on: vi.fn((event: string, handler: () => void) => {
|
||||||
|
if (!handlers.has(event)) handlers.set(event, new Set());
|
||||||
|
handlers.get(event)?.add(handler);
|
||||||
|
return mockSocket;
|
||||||
|
}),
|
||||||
|
offAny: vi.fn(() => mockSocket),
|
||||||
|
disconnect: vi.fn(() => mockSocket),
|
||||||
|
trigger(event: string): void {
|
||||||
|
for (const handler of handlers.get(event) ?? []) handler();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mockSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentMock!: MockChatSocket;
|
||||||
|
|
||||||
|
describe('chat socket', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
ioMock.mockReset();
|
||||||
|
// A fresh object per io() call so identity assertions (same singleton vs.
|
||||||
|
// a genuinely new instance) are meaningful.
|
||||||
|
ioMock.mockImplementation(() => {
|
||||||
|
currentMock = createMockSocket();
|
||||||
|
return currentMock;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
destroySocket();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates one same-origin /chat namespace socket', () => {
|
||||||
|
const first = getSocket();
|
||||||
|
const second = getSocket();
|
||||||
|
|
||||||
|
expect(first).toBe(second);
|
||||||
|
expect(ioMock).toHaveBeenCalledOnce();
|
||||||
|
expect(ioMock).toHaveBeenCalledWith('/chat', {
|
||||||
|
withCredentials: true,
|
||||||
|
autoConnect: false,
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the same singleton instance across a transient disconnect', () => {
|
||||||
|
const first = getSocket();
|
||||||
|
|
||||||
|
// socket.ts must not react to a real socket's `disconnect` event by
|
||||||
|
// nulling the singleton — it registers no such handler at all now.
|
||||||
|
// Actually fire every handler registered via `.on('disconnect', ...)`
|
||||||
|
// (mirroring a real socket.io-client reconnect) instead of merely
|
||||||
|
// calling getSocket() again: this is what makes the test fail if
|
||||||
|
// production reintroduces `socket.on('disconnect', () => { socket =
|
||||||
|
// null; })`, since that handler would run here and null the singleton
|
||||||
|
// before the next getSocket() call.
|
||||||
|
currentMock.trigger('disconnect');
|
||||||
|
const second = getSocket();
|
||||||
|
|
||||||
|
expect(second).toBe(first);
|
||||||
|
expect(ioMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only creates a new singleton after an explicit destroySocket()', () => {
|
||||||
|
const first = getSocket();
|
||||||
|
|
||||||
|
destroySocket();
|
||||||
|
const second = getSocket();
|
||||||
|
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect(ioMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
+17
-13
@@ -1,23 +1,27 @@
|
|||||||
import { io, type Socket } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
|
import type { ChatSocket } from './chat-contract';
|
||||||
|
|
||||||
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
let socket: ChatSocket | null = null;
|
||||||
|
|
||||||
let socket: Socket | null = null;
|
export function getSocket(): ChatSocket {
|
||||||
|
|
||||||
export function getSocket(): Socket {
|
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
socket = io(`${GATEWAY_URL}/chat`, {
|
// socket.io-client 4.8.3's `io()` factory declaration always returns the
|
||||||
|
// default unparameterized Socket (it accepts no <ListenEvents, EmitEvents>
|
||||||
|
// generics), so this one cast is the unavoidable boundary between that and the
|
||||||
|
// typed `/chat` contract. Every other call site uses the resulting ChatSocket
|
||||||
|
// with no further assertions.
|
||||||
|
socket = io('/chat', {
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
});
|
}) as unknown as ChatSocket;
|
||||||
|
|
||||||
// Reset singleton reference when socket is fully closed so the next
|
// A transient `disconnect` (network blip, server restart) must NOT null
|
||||||
// getSocket() call creates a fresh instance instead of returning a
|
// the singleton: socket.io-client auto-reconnects this same instance,
|
||||||
// closed/dead socket.
|
// and its listeners stay registered across that reconnect. Nulling here
|
||||||
socket.on('disconnect', () => {
|
// previously orphaned those listeners on the next getSocket() call by
|
||||||
socket = null;
|
// handing back a brand-new, unconnected instance. Only destroySocket()
|
||||||
});
|
// (an explicit, intentional teardown) may reset the singleton.
|
||||||
}
|
}
|
||||||
return socket;
|
return socket;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { getEnabledSsoProviders, getSsoProvider } from './sso-providers';
|
|
||||||
|
|
||||||
describe('sso-providers', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the enabled providers in login button order', () => {
|
|
||||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
|
||||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true');
|
|
||||||
|
|
||||||
expect(getEnabledSsoProviders()).toEqual([
|
|
||||||
{
|
|
||||||
id: 'workos',
|
|
||||||
buttonLabel: 'Continue with WorkOS',
|
|
||||||
description: 'Enterprise SSO via WorkOS',
|
|
||||||
enabled: true,
|
|
||||||
href: '/auth/provider/workos',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'keycloak',
|
|
||||||
buttonLabel: 'Continue with Keycloak',
|
|
||||||
description: 'Enterprise SSO via Keycloak',
|
|
||||||
enabled: true,
|
|
||||||
href: '/auth/provider/keycloak',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks disabled providers without exposing them in the enabled list', () => {
|
|
||||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
|
||||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false');
|
|
||||||
|
|
||||||
expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']);
|
|
||||||
expect(getSsoProvider('keycloak')).toEqual({
|
|
||||||
id: 'keycloak',
|
|
||||||
buttonLabel: 'Continue with Keycloak',
|
|
||||||
description: 'Enterprise SSO via Keycloak',
|
|
||||||
enabled: false,
|
|
||||||
href: '/auth/provider/keycloak',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for unknown providers', () => {
|
|
||||||
expect(getSsoProvider('authentik')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
export type SsoProviderId = 'workos' | 'keycloak';
|
|
||||||
|
|
||||||
export interface SsoProvider {
|
|
||||||
id: SsoProviderId;
|
|
||||||
buttonLabel: string;
|
|
||||||
description: string;
|
|
||||||
enabled: boolean;
|
|
||||||
href: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROVIDER_METADATA: Record<SsoProviderId, Omit<SsoProvider, 'enabled' | 'href'>> = {
|
|
||||||
workos: {
|
|
||||||
id: 'workos',
|
|
||||||
buttonLabel: 'Continue with WorkOS',
|
|
||||||
description: 'Enterprise SSO via WorkOS',
|
|
||||||
},
|
|
||||||
keycloak: {
|
|
||||||
id: 'keycloak',
|
|
||||||
buttonLabel: 'Continue with Keycloak',
|
|
||||||
description: 'Enterprise SSO via Keycloak',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getEnabledSsoProviders(): SsoProvider[] {
|
|
||||||
return (Object.keys(PROVIDER_METADATA) as SsoProviderId[])
|
|
||||||
.map((providerId) => getSsoProvider(providerId))
|
|
||||||
.filter((provider): provider is SsoProvider => provider?.enabled === true);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSsoProvider(providerId: string): SsoProvider | null {
|
|
||||||
if (!isSsoProviderId(providerId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...PROVIDER_METADATA[providerId],
|
|
||||||
enabled: isSsoProviderEnabled(providerId),
|
|
||||||
href: `/auth/provider/${providerId}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSsoProviderId(value: string): value is SsoProviderId {
|
|
||||||
return value === 'workos' || value === 'keycloak';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSsoProviderEnabled(providerId: SsoProviderId): boolean {
|
|
||||||
switch (providerId) {
|
|
||||||
case 'workos':
|
|
||||||
return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
|
|
||||||
case 'keycloak':
|
|
||||||
return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,42 @@
|
|||||||
|
import type {
|
||||||
|
HarnessAuthState,
|
||||||
|
HarnessModelAvailability,
|
||||||
|
HarnessSelection,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
|
||||||
|
// The exact harness/provider/model tuple and its closed enum companions are the
|
||||||
|
// shared domain types — re-exported here so web consumers (and the runtime
|
||||||
|
// guards) import one shape, never a divergent local redefinition.
|
||||||
|
export type { HarnessSelection, HarnessAuthState, HarnessModelAvailability };
|
||||||
|
|
||||||
|
/** Harness summary row from `GET /api/harnesses` (the `HarnessSummaryDto`). The
|
||||||
|
* harness id is kept distinct from any provider id — they are never merged. */
|
||||||
|
export interface HarnessSummary {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
capabilities: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One selectable model in a harness catalog. Extends the `{harnessId,
|
||||||
|
* providerId, modelId}` tuple with the display/availability metadata the UI
|
||||||
|
* needs; `inputTypes` is kept as a plain `string[]` on the client boundary
|
||||||
|
* because it arrives from untrusted JSON and is only ever displayed. */
|
||||||
|
export interface HarnessCatalogEntry extends HarnessSelection {
|
||||||
|
displayName: string;
|
||||||
|
reasoningCapability: boolean;
|
||||||
|
inputTypes: string[];
|
||||||
|
authState: HarnessAuthState;
|
||||||
|
availability: HarnessModelAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Harness-scoped catalog from `GET /api/harnesses/:harnessId/catalog`. */
|
||||||
|
export interface HarnessCatalog {
|
||||||
|
harnessId: string;
|
||||||
|
version: string;
|
||||||
|
fingerprint: string;
|
||||||
|
models: HarnessCatalogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Conversation returned by the gateway API. */
|
/** Conversation returned by the gateway API. */
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
import { ThemeProvider } from '@/providers/theme-provider';
|
||||||
|
import { createAppRouter } from '@/routes';
|
||||||
|
import '@/app/globals.css';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (!container) {
|
||||||
|
throw new Error('missing #root element');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(container).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<RouterProvider router={createAppRouter()} />
|
||||||
|
</ThemeProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-router-dom';
|
||||||
|
import { LoginPage } from '@/spa/pages/login';
|
||||||
|
import { RegisterPage } from '@/spa/pages/register';
|
||||||
|
import { SsoCallbackPage } from '@/spa/pages/sso-callback';
|
||||||
|
import { ChatPage } from '@/spa/pages/chat';
|
||||||
|
import { ChatRouteErrorBoundary } from '@/spa/pages/chat-error-boundary';
|
||||||
|
import { ProjectDetailPage } from '@/spa/pages/project-detail';
|
||||||
|
import { ProjectsPage } from '@/spa/pages/projects';
|
||||||
|
import {
|
||||||
|
ProjectDetailRouteErrorBoundary,
|
||||||
|
ProjectsRouteErrorBoundary,
|
||||||
|
TasksRouteErrorBoundary,
|
||||||
|
} from '@/spa/pages/resource-route-error-boundaries';
|
||||||
|
import { TasksPage } from '@/spa/pages/tasks';
|
||||||
|
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
||||||
|
import { Placeholder } from '@/spa/placeholder';
|
||||||
|
|
||||||
|
function GuestLayout(): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-surface-bg px-4 py-8">
|
||||||
|
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routes: RouteObject[] = [
|
||||||
|
{
|
||||||
|
element: <GuestGuard />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
element: <GuestLayout />,
|
||||||
|
children: [
|
||||||
|
{ path: '/login', element: <LoginPage /> },
|
||||||
|
{ path: '/register', element: <RegisterPage /> },
|
||||||
|
{ path: '/auth/provider/:provider', element: <SsoCallbackPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <AuthGuard />,
|
||||||
|
children: [
|
||||||
|
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||||
|
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
||||||
|
{
|
||||||
|
path: '/projects',
|
||||||
|
element: <ProjectsPage />,
|
||||||
|
errorElement: <ProjectsRouteErrorBoundary />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/projects/:id',
|
||||||
|
element: <ProjectDetailPage />,
|
||||||
|
errorElement: <ProjectDetailRouteErrorBoundary />,
|
||||||
|
},
|
||||||
|
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
||||||
|
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
||||||
|
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createAppRouter(): ReturnType<typeof createBrowserRouter> {
|
||||||
|
return createBrowserRouter(routes);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
fetchCatalog,
|
||||||
|
fetchHarnesses,
|
||||||
|
fetchPersistedSelection,
|
||||||
|
persistSelection,
|
||||||
|
} from './chat-api';
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubFetch(): ReturnType<typeof vi.fn> {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
return fetchMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every URL the client actually requested, across all calls. */
|
||||||
|
function requestedUrls(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
||||||
|
return fetchMock.mock.calls.map((call) => String(call[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('chat-api', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchHarnesses GETs /api/harnesses and returns typed summaries (harness id separate from provider)', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json([
|
||||||
|
{ id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
|
||||||
|
{ id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const harnesses = await fetchHarnesses();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses');
|
||||||
|
expect(harnesses).toEqual([
|
||||||
|
{ id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
|
||||||
|
{ id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchCatalog GETs the harness-scoped catalog and returns only its model entries', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json({
|
||||||
|
harnessId: 'pi',
|
||||||
|
version: '2026-08-11',
|
||||||
|
fingerprint: 'abc123',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
displayName: 'GPT-5',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await fetchCatalog('pi');
|
||||||
|
|
||||||
|
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses/pi/catalog');
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) throw new Error('expected ok catalog');
|
||||||
|
expect(result.catalog.harnessId).toBe('pi');
|
||||||
|
expect(result.catalog.models).toHaveLength(1);
|
||||||
|
expect(result.catalog.models[0]).toMatchObject({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
availability: 'available',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes a catalog 404 into a typed catalog_unavailable result without surfacing the raw body', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json(
|
||||||
|
{
|
||||||
|
code: 'adapter_unavailable',
|
||||||
|
message: 'raw gateway detail that must not leak verbatim',
|
||||||
|
harnessId: 'attacker-echo',
|
||||||
|
extra: { hostile: 'blob' },
|
||||||
|
},
|
||||||
|
404,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await fetchCatalog('ghost');
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) throw new Error('expected unavailable result');
|
||||||
|
expect(result.code).toBe('catalog_unavailable');
|
||||||
|
// harnessId comes from the request, never the (untrusted) response body.
|
||||||
|
expect(result.harnessId).toBe('ghost');
|
||||||
|
expect(typeof result.message).toBe('string');
|
||||||
|
// The raw response body is never rendered/returned verbatim.
|
||||||
|
expect(JSON.stringify(result)).not.toContain('hostile');
|
||||||
|
expect(JSON.stringify(result)).not.toContain('attacker-echo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchPersistedSelection returns the stored tuple, or null when unset', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
||||||
|
);
|
||||||
|
await expect(fetchPersistedSelection()).resolves.toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/chat/preferences/selection');
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValueOnce(json({ selection: null }));
|
||||||
|
await expect(fetchPersistedSelection()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persistSelection PUTs the structured tuple (not free text) and returns the confirmed selection', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await persistSelection({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
const call = fetchMock.mock.calls[0];
|
||||||
|
expect(String(call?.[0])).toBe('/api/chat/preferences/selection');
|
||||||
|
const init = call?.[1] as RequestInit;
|
||||||
|
expect(String(init.method).toUpperCase()).toBe('PUT');
|
||||||
|
// The body is exactly the structured tuple — harness/provider/model kept distinct.
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes a selection 422 into a typed error preserving the requested tuple exactly', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json(
|
||||||
|
{
|
||||||
|
code: 'model_unavailable',
|
||||||
|
message: 'raw detail that must not leak',
|
||||||
|
selection: { harnessId: 'x', providerId: 'y', modelId: 'z' },
|
||||||
|
},
|
||||||
|
422,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const requested = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' };
|
||||||
|
const result = await persistSelection(requested);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) throw new Error('expected failed persist');
|
||||||
|
expect(['selection_invalid', 'model_unavailable']).toContain(result.code);
|
||||||
|
// The requested tuple is preserved unchanged — not replaced by the body's echo.
|
||||||
|
expect(result.requested).toEqual(requested);
|
||||||
|
expect(JSON.stringify(result)).not.toContain('raw detail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never requests any /api/providers* endpoint', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(json([]));
|
||||||
|
await fetchHarnesses();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json({ harnessId: 'pi', version: '1', fingerprint: 'f', models: [] }),
|
||||||
|
);
|
||||||
|
await fetchCatalog('pi');
|
||||||
|
fetchMock.mockResolvedValue(json({ selection: null }));
|
||||||
|
await fetchPersistedSelection();
|
||||||
|
|
||||||
|
for (const url of requestedUrls(fetchMock)) {
|
||||||
|
expect(url).not.toContain('/api/providers');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Typed fetch wrappers for the Task-3 harness HTTP contract the chat selection
|
||||||
|
* UI depends on. Every response body is untrusted and is normalized through the
|
||||||
|
* runtime guards before it reaches state — a 404 (catalog) and a 422 (selection)
|
||||||
|
* are mapped to typed, body-free error results so a raw gateway body is never
|
||||||
|
* rendered, and the caller's requested tuple is preserved verbatim on failure.
|
||||||
|
*
|
||||||
|
* This module talks ONLY to the harness/chat-preferences endpoints. It never
|
||||||
|
* calls `/api/providers*` — provider identity lives inside the harness catalog.
|
||||||
|
*/
|
||||||
|
import { asHarnessCatalog, asHarnessSelection, asHarnessSummaries } from './runtime-guards';
|
||||||
|
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
|
||||||
|
|
||||||
|
/** A catalog fetch either yields the typed catalog or a typed unavailability —
|
||||||
|
* never a thrown raw body. */
|
||||||
|
export type CatalogResult =
|
||||||
|
| { ok: true; catalog: HarnessCatalog }
|
||||||
|
| { ok: false; code: 'catalog_unavailable'; harnessId: string; message: string };
|
||||||
|
|
||||||
|
export type SelectionErrorCode = 'selection_invalid' | 'model_unavailable';
|
||||||
|
|
||||||
|
/** A persist either confirms the stored tuple or reports a typed domain failure
|
||||||
|
* that echoes back the exact tuple the caller requested. */
|
||||||
|
export type SelectionPersistResult =
|
||||||
|
| { ok: true; selection: HarnessSelection }
|
||||||
|
| { ok: false; code: SelectionErrorCode; message: string; requested: HarnessSelection };
|
||||||
|
|
||||||
|
/** A safe, generic message for an unavailable catalog — the raw 404 body is
|
||||||
|
* never surfaced. */
|
||||||
|
const CATALOG_UNAVAILABLE_MESSAGE = 'This harness catalog is currently unavailable.';
|
||||||
|
|
||||||
|
/** A safe, generic message for a rejected selection. The untrusted 422 body's
|
||||||
|
* own `message` is deliberately NEVER surfaced — only this fixed copy — so a
|
||||||
|
* raw gateway detail can never leak into the UI. Only the closed `code` enum is
|
||||||
|
* read from the body. */
|
||||||
|
const SELECTION_REJECTED_MESSAGE = 'This selection was rejected.';
|
||||||
|
|
||||||
|
async function readJson(response: Response): Promise<unknown> {
|
||||||
|
return response.json().catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeSelectionCode(body: unknown): SelectionErrorCode {
|
||||||
|
if (typeof body === 'object' && body !== null && 'code' in body) {
|
||||||
|
const code = (body as { code: unknown }).code;
|
||||||
|
if (code === 'selection_invalid' || code === 'model_unavailable') return code;
|
||||||
|
}
|
||||||
|
// Default to the more conservative "invalid" classification for anything
|
||||||
|
// unrecognized rather than guessing "model_unavailable".
|
||||||
|
return 'selection_invalid';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/harnesses` → the list of harness summaries. A non-OK response
|
||||||
|
* normalizes to an empty list (the UI then has no harness to select). */
|
||||||
|
export async function fetchHarnesses(): Promise<HarnessSummary[]> {
|
||||||
|
const response = await fetch('/api/harnesses', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) return [];
|
||||||
|
return asHarnessSummaries(await readJson(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/harnesses/:harnessId/catalog` → the harness-scoped catalog. A 404
|
||||||
|
* (or any non-OK) becomes a typed `catalog_unavailable` result rather than a
|
||||||
|
* fallback catalog or a rendered raw body. */
|
||||||
|
export async function fetchCatalog(harnessId: string): Promise<CatalogResult> {
|
||||||
|
const response = await fetch(`/api/harnesses/${encodeURIComponent(harnessId)}/catalog`, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'catalog_unavailable',
|
||||||
|
// Scoped to the requested harness id, never the untrusted body's echo.
|
||||||
|
harnessId,
|
||||||
|
message: CATALOG_UNAVAILABLE_MESSAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, catalog: asHarnessCatalog(await readJson(response), harnessId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/chat/preferences/selection` → the persisted tuple, or null when
|
||||||
|
* unset or malformed. */
|
||||||
|
export async function fetchPersistedSelection(): Promise<HarnessSelection | null> {
|
||||||
|
const response = await fetch('/api/chat/preferences/selection', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const body = await readJson(response);
|
||||||
|
if (typeof body !== 'object' || body === null) return null;
|
||||||
|
return asHarnessSelection((body as { selection?: unknown }).selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `PUT /api/chat/preferences/selection` with the structured tuple as the body.
|
||||||
|
* On success returns the confirmed selection; on a typed domain failure (422)
|
||||||
|
* or validation error, returns a typed result carrying the EXACT requested
|
||||||
|
* tuple — never the body's echo — and never the raw body text. */
|
||||||
|
export async function persistSelection(
|
||||||
|
selection: HarnessSelection,
|
||||||
|
): Promise<SelectionPersistResult> {
|
||||||
|
const requested: HarnessSelection = {
|
||||||
|
harnessId: selection.harnessId,
|
||||||
|
providerId: selection.providerId,
|
||||||
|
modelId: selection.modelId,
|
||||||
|
};
|
||||||
|
const response = await fetch('/api/chat/preferences/selection', {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requested),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await readJson(response);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: safeSelectionCode(body),
|
||||||
|
// Fixed copy only — the untrusted body's message is never surfaced.
|
||||||
|
message: SELECTION_REJECTED_MESSAGE,
|
||||||
|
requested,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const body = await readJson(response);
|
||||||
|
const confirmed =
|
||||||
|
typeof body === 'object' && body !== null
|
||||||
|
? asHarnessSelection((body as { selection?: unknown }).selection)
|
||||||
|
: null;
|
||||||
|
// A malformed 2xx body is treated as a confirmation of exactly what we sent —
|
||||||
|
// the server accepted the tuple, so the requested tuple is the source of truth.
|
||||||
|
return { ok: true, selection: confirmed ?? requested };
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { CommandsPanel } from './commands-panel';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: Root | null;
|
||||||
|
let container: HTMLElement | null;
|
||||||
|
|
||||||
|
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CommandsPanel', () => {
|
||||||
|
it('shows the frozen local pendingApproval args in the confirmation area, regardless of misleading server message text', async () => {
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={null}
|
||||||
|
results={[]}
|
||||||
|
approval={{
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy', // matches pendingApproval — this is a legitimately approved request
|
||||||
|
success: true,
|
||||||
|
approvalId: 'ap1',
|
||||||
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
// Free-text server message claims a different, less alarming target
|
||||||
|
// than what will actually be sent — the UI must not rely on this.
|
||||||
|
message: 'This will only affect the staging environment.',
|
||||||
|
}}
|
||||||
|
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||||
|
hasConversation
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The exact frozen combined action is visible...
|
||||||
|
expect(container?.textContent).toContain('/deploy');
|
||||||
|
expect(container?.textContent).toContain('prod');
|
||||||
|
// ...and the misleading server free-text is never shown next to it.
|
||||||
|
expect(container?.textContent).not.toContain('staging environment');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when a manifest commands entry is null', async () => {
|
||||||
|
const manifest = {
|
||||||
|
commands: [
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
name: 'model',
|
||||||
|
aliases: [],
|
||||||
|
description: 'Change the active model',
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [null],
|
||||||
|
version: 1,
|
||||||
|
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={manifest}
|
||||||
|
results={[]}
|
||||||
|
approval={null}
|
||||||
|
pendingApproval={null}
|
||||||
|
hasConversation={false}
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain('model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an explicit no-args fallback when the frozen pendingApproval has no args', async () => {
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={null}
|
||||||
|
results={[]}
|
||||||
|
approval={{
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: true,
|
||||||
|
approvalId: 'ap1',
|
||||||
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
}}
|
||||||
|
pendingApproval={{ command: 'deploy' }}
|
||||||
|
hasConversation
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container?.textContent?.toLowerCase()).toContain('no args');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders skills from a skills-only manifest', async () => {
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={{
|
||||||
|
commands: [],
|
||||||
|
skills: [{ name: 'brave-search', description: 'Search the web', available: true }],
|
||||||
|
version: 1,
|
||||||
|
}}
|
||||||
|
results={[]}
|
||||||
|
approval={null}
|
||||||
|
pendingApproval={null}
|
||||||
|
hasConversation={false}
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain('brave-search');
|
||||||
|
expect(container?.textContent).toContain('Search the web');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show the Run affordance when approval.success/approvalId are objects, even though command matches pendingApproval', async () => {
|
||||||
|
const approval = {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: { truthy: 'object' },
|
||||||
|
approvalId: { also: 'object' },
|
||||||
|
} as unknown as Parameters<typeof CommandsPanel>[0]['approval'];
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={null}
|
||||||
|
results={[]}
|
||||||
|
approval={approval}
|
||||||
|
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||||
|
hasConversation
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
[...(container?.querySelectorAll('button') ?? [])].some((button) =>
|
||||||
|
button.textContent?.includes('Run approved command'),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the guarded server-provided denial reason for a denied approval', async () => {
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={null}
|
||||||
|
results={[]}
|
||||||
|
approval={{
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: false,
|
||||||
|
message: 'Not authorized',
|
||||||
|
}}
|
||||||
|
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||||
|
hasConversation
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain('Not authorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a stable "Denied." copy when a denial has no usable message', async () => {
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={null}
|
||||||
|
results={[]}
|
||||||
|
approval={{ conversationId: 'c1', command: 'deploy', success: false }}
|
||||||
|
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||||
|
hasConversation
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain('Denied.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the guarded contract-provided reason for a failed command result, falling back to a stable copy only when absent', async () => {
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={null}
|
||||||
|
results={[
|
||||||
|
{ conversationId: 'c1', command: 'model', success: false, message: 'Unknown model' },
|
||||||
|
{ conversationId: 'c1', command: 'deploy', success: false },
|
||||||
|
]}
|
||||||
|
approval={null}
|
||||||
|
pendingApproval={null}
|
||||||
|
hasConversation={false}
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain('Unknown model');
|
||||||
|
expect(container?.textContent).toContain('Command failed.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds an oversized command result message at the render site as defense-in-depth', async () => {
|
||||||
|
const hostileMessage = 'y'.repeat(50_000);
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={null}
|
||||||
|
results={[
|
||||||
|
{ conversationId: 'c1', command: 'model', success: false, message: hostileMessage },
|
||||||
|
]}
|
||||||
|
approval={null}
|
||||||
|
pendingApproval={null}
|
||||||
|
hasConversation={false}
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = container?.textContent ?? '';
|
||||||
|
expect(text.length).toBeLessThan(hostileMessage.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when the manifest fields are malformed (non-array commands/skills)', async () => {
|
||||||
|
const manifest = {
|
||||||
|
commands: 'not-an-array',
|
||||||
|
skills: null,
|
||||||
|
version: 1,
|
||||||
|
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={manifest}
|
||||||
|
results={[]}
|
||||||
|
approval={null}
|
||||||
|
pendingApproval={null}
|
||||||
|
hasConversation={false}
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useState, type ReactElement } from 'react';
|
||||||
|
import type { PendingApproval } from './use-chat-connection';
|
||||||
|
import { MAX_COMMAND_MESSAGE_CHARS } from './limits';
|
||||||
|
import { asNonEmptyString, asString } from './runtime-guards';
|
||||||
|
import type {
|
||||||
|
CommandManifest,
|
||||||
|
SlashCommandApprovalResultPayload,
|
||||||
|
SlashCommandResultPayload,
|
||||||
|
} from '@/lib/chat-contract';
|
||||||
|
|
||||||
|
/** Stable fallback copy shown for a failed command only when the server's
|
||||||
|
* own guarded, non-empty `message` (e.g. "Unknown model") is absent or
|
||||||
|
* malformed — the structured contract reason itself is otherwise shown
|
||||||
|
* directly, never a raw thrown exception, stack trace, or object value. */
|
||||||
|
const COMMAND_FAILURE_COPY = 'Command failed.';
|
||||||
|
|
||||||
|
/** Render-site defense-in-depth: `use-chat-connection.ts` already bounds a
|
||||||
|
* stored command:result message at ingestion, but this component must never
|
||||||
|
* assume every caller went through that path — bounding again here means a
|
||||||
|
* hostile/oversized message can never force an unbounded render. */
|
||||||
|
function boundMessage(value: string): string {
|
||||||
|
return value.length > MAX_COMMAND_MESSAGE_CHARS
|
||||||
|
? value.slice(0, MAX_COMMAND_MESSAGE_CHARS)
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandsPanelProps {
|
||||||
|
manifest: CommandManifest | null;
|
||||||
|
results: SlashCommandResultPayload[];
|
||||||
|
approval: SlashCommandApprovalResultPayload | null;
|
||||||
|
pendingApproval: PendingApproval | null;
|
||||||
|
hasConversation: boolean;
|
||||||
|
onExecute: (input: { command: string; args?: string }) => void;
|
||||||
|
onApprove: (input: { command: string; args?: string }) => void;
|
||||||
|
onRunApproved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandsPanel({
|
||||||
|
manifest,
|
||||||
|
results,
|
||||||
|
approval,
|
||||||
|
pendingApproval,
|
||||||
|
hasConversation,
|
||||||
|
onExecute,
|
||||||
|
onApprove,
|
||||||
|
onRunApproved,
|
||||||
|
}: CommandsPanelProps): ReactElement {
|
||||||
|
const [command, setCommand] = useState('');
|
||||||
|
const [args, setArgs] = useState('');
|
||||||
|
|
||||||
|
// Defense-in-depth: the reducer already normalizes success/approvalId
|
||||||
|
// before storing `approval`, but a matching command string alone must
|
||||||
|
// never be trusted here either — require the literal boolean `true` and a
|
||||||
|
// non-empty string approvalId, not merely truthy values.
|
||||||
|
const canRunApproved =
|
||||||
|
approval?.success === true &&
|
||||||
|
typeof approval.approvalId === 'string' &&
|
||||||
|
approval.approvalId.length > 0 &&
|
||||||
|
!!pendingApproval &&
|
||||||
|
pendingApproval.command === approval.command;
|
||||||
|
|
||||||
|
// A manifest arrives from the server as untyped JSON at runtime — guard
|
||||||
|
// both collections before mapping so a malformed manifest cannot throw.
|
||||||
|
const commands = Array.isArray(manifest?.commands) ? manifest.commands : [];
|
||||||
|
const skills = Array.isArray(manifest?.skills) ? manifest.skills : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Commands" className="flex flex-col gap-2 border-b px-4 py-3 text-xs">
|
||||||
|
{commands.length > 0 ? (
|
||||||
|
<ul aria-label="Available commands" className="flex flex-col gap-1">
|
||||||
|
{commands.map((cmd, index) => (
|
||||||
|
<li key={asString(cmd?.name) || `cmd-${index}`}>
|
||||||
|
<strong>/{asString(cmd?.name)}</strong> — {asString(cmd?.description)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{skills.length > 0 ? (
|
||||||
|
<ul aria-label="Available skills" className="flex flex-col gap-1">
|
||||||
|
{skills.map((skill, index) => (
|
||||||
|
<li key={asString(skill?.name) || `skill-${index}`}>
|
||||||
|
<strong>/skill:{asString(skill?.name)}</strong> — {asString(skill?.description)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
aria-label="Command name"
|
||||||
|
value={command}
|
||||||
|
onChange={(event) => setCommand(event.target.value)}
|
||||||
|
placeholder="command"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label="Command arguments"
|
||||||
|
value={args}
|
||||||
|
onChange={(event) => setArgs(event.target.value)}
|
||||||
|
placeholder="args (optional)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!hasConversation || !command.trim()}
|
||||||
|
onClick={() => onExecute({ command: command.trim(), args: args.trim() || undefined })}
|
||||||
|
>
|
||||||
|
Run command
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!hasConversation || !command.trim()}
|
||||||
|
onClick={() => onApprove({ command: command.trim(), args: args.trim() || undefined })}
|
||||||
|
>
|
||||||
|
Request approval
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{approval ? (
|
||||||
|
<div role={approval.success ? 'status' : 'alert'} className="flex items-center gap-2">
|
||||||
|
{/* A successful approval shows stable client copy only — never
|
||||||
|
the server-controlled approval.message or echoed
|
||||||
|
approval.command as the primary confirmation. The frozen local
|
||||||
|
pendingApproval below (not this line) is the sole authoritative
|
||||||
|
statement of what will run. A denial, by contrast, is not an
|
||||||
|
execution authority and safely surfaces the guarded structured
|
||||||
|
reason the server gave (e.g. "Not authorized"), falling back to
|
||||||
|
a stable copy only when absent/malformed. */}
|
||||||
|
<span>
|
||||||
|
{approval.success ? 'Approved.' : asNonEmptyString(approval.message, 'Denied.')}
|
||||||
|
</span>
|
||||||
|
{canRunApproved && pendingApproval ? (
|
||||||
|
<>
|
||||||
|
{/* Authoritative frozen local command+args — what the click below
|
||||||
|
will actually emit. The server's `approval` above is display-only
|
||||||
|
and must never be trusted to represent the executed payload. */}
|
||||||
|
<span>
|
||||||
|
Will run: /{pendingApproval.command}{' '}
|
||||||
|
{pendingApproval.args ? pendingApproval.args : '(no args)'}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onRunApproved}>
|
||||||
|
Run approved command
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{results.length > 0 ? (
|
||||||
|
<ul aria-label="Command results" className="flex flex-col gap-1">
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<li key={`${result.command}-${index}`} role={result.success ? 'status' : 'alert'}>
|
||||||
|
/{asString(result.command)}: {result.success ? 'success' : 'failed'}
|
||||||
|
{result.success
|
||||||
|
? typeof result.message === 'string' && result.message
|
||||||
|
? ` — ${boundMessage(result.message)}`
|
||||||
|
: ''
|
||||||
|
: ` — ${boundMessage(asNonEmptyString(result.message, COMMAND_FAILURE_COPY))}`}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
||||||
|
import type { HarnessSelection } from '@/lib/types';
|
||||||
|
import type { HarnessSelectionValue } from './use-harness-selection';
|
||||||
|
|
||||||
|
interface ComposerProps {
|
||||||
|
onSend: (input: { content: string; selection: HarnessSelection }) => boolean;
|
||||||
|
onStop: () => void;
|
||||||
|
streaming: boolean;
|
||||||
|
/** True from local send time through server turn startup/ack and
|
||||||
|
* throughout streaming — a superset of `streaming` that also covers the
|
||||||
|
* pre-ack window where a second send could otherwise slip through. */
|
||||||
|
sending: boolean;
|
||||||
|
hasConversation: boolean;
|
||||||
|
/** Structured harness/provider/model selection state. The composer never
|
||||||
|
* accepts free-text provider/model — every sendable tuple is a validated,
|
||||||
|
* persisted catalog entry, and the send projection is derived from it. */
|
||||||
|
harness: HarnessSelectionValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The distinct provider ids present in the current catalog, in first-seen
|
||||||
|
* order — the provider select is catalog-derived, never a hardcoded list. */
|
||||||
|
function providerOptions(harness: HarnessSelectionValue): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const model of harness.catalog?.models ?? []) {
|
||||||
|
if (seen.has(model.providerId)) continue;
|
||||||
|
seen.add(model.providerId);
|
||||||
|
out.push(model.providerId);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Composer({
|
||||||
|
onSend,
|
||||||
|
onStop,
|
||||||
|
streaming,
|
||||||
|
sending,
|
||||||
|
hasConversation,
|
||||||
|
harness,
|
||||||
|
}: ComposerProps): ReactElement {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const busy = streaming || sending;
|
||||||
|
|
||||||
|
function submit(): void {
|
||||||
|
if (busy) return;
|
||||||
|
// Send is gated on a validated, persisted catalog tuple — a draft or unset
|
||||||
|
// selection can never emit, so provider/model never travel as free text.
|
||||||
|
if (!harness.canSend || harness.persistedSelection === null) return;
|
||||||
|
const trimmed = content.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
// Pass the validated, persisted selection tuple only. The hook derives the
|
||||||
|
// wire projection (legacy `message` provider/model, or `turn:send`) from the
|
||||||
|
// negotiated `chat:send-capability` protocol — never from flat caller input.
|
||||||
|
const selection = harness.persistedSelection;
|
||||||
|
const ok = onSend({ content: trimmed, selection });
|
||||||
|
// Clear the input only when the send was accepted — a refused turn (e.g. a
|
||||||
|
// failed idempotency mint) must retain the user's text so it is not lost.
|
||||||
|
if (ok) setContent('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope the model options to the intentionally selected provider. With no
|
||||||
|
// provider chosen (`providerId === ''`) nothing matches, so the model select
|
||||||
|
// offers only the placeholder — never a cross-provider row.
|
||||||
|
const models = (harness.catalog?.models ?? []).filter(
|
||||||
|
(model) => model.providerId === harness.providerId,
|
||||||
|
);
|
||||||
|
// A collision-safe composite option identity covering the full provider+model
|
||||||
|
// tuple. The controlled select mirrors the same identity so the exact catalog
|
||||||
|
// row highlights (a bare modelId would collide across providers).
|
||||||
|
const modelOptionValue = (model: { providerId: string; modelId: string }): string =>
|
||||||
|
`${model.providerId}:${model.modelId}`;
|
||||||
|
const selectedModelValue = harness.modelId ? `${harness.providerId}:${harness.modelId}` : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
submit();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-2 border-t p-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<select
|
||||||
|
aria-label="Harness"
|
||||||
|
value={harness.harnessId}
|
||||||
|
onChange={(event) => harness.selectHarness(event.target.value)}
|
||||||
|
className="rounded border px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<option value="">Select a harness…</option>
|
||||||
|
{harness.harnesses.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label="Provider"
|
||||||
|
value={harness.providerId}
|
||||||
|
onChange={(event) => harness.selectProvider(event.target.value)}
|
||||||
|
disabled={harness.catalogUnavailable || providerOptions(harness).length === 0}
|
||||||
|
className="rounded border px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<option value="">Select a provider…</option>
|
||||||
|
{providerOptions(harness).map((providerId) => (
|
||||||
|
<option key={providerId} value={providerId}>
|
||||||
|
{providerId}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label="Model"
|
||||||
|
value={selectedModelValue}
|
||||||
|
onChange={(event) => {
|
||||||
|
// Resolve the composite option identity back to the exact catalog
|
||||||
|
// row and persist that row's own provider+model — never a bare id.
|
||||||
|
const selected = models.find((model) => modelOptionValue(model) === event.target.value);
|
||||||
|
if (selected) harness.selectModel(selected.providerId, selected.modelId);
|
||||||
|
}}
|
||||||
|
disabled={harness.catalogUnavailable || models.length === 0}
|
||||||
|
className="rounded border px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<option value="">Select a model…</option>
|
||||||
|
{models.map((model) => (
|
||||||
|
<option key={modelOptionValue(model)} value={modelOptionValue(model)}>
|
||||||
|
{model.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{harness.catalogUnavailable ? (
|
||||||
|
<p role="status" className="text-xs opacity-70">
|
||||||
|
This harness catalog is currently unavailable.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{harness.isStale ? (
|
||||||
|
<p role="status" className="text-xs opacity-70">
|
||||||
|
The saved model is no longer available — pick another to continue.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{harness.persistError ? (
|
||||||
|
<p role="alert" className="text-xs">
|
||||||
|
{harness.persistError.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<textarea
|
||||||
|
aria-label="Message"
|
||||||
|
value={content}
|
||||||
|
onChange={(event) => setContent(event.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Message… (Enter to send, Shift+Enter for a new line)"
|
||||||
|
className="flex-1 resize-none rounded border px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!content.trim() || busy || !harness.canSend}
|
||||||
|
className="rounded px-3 py-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Stop"
|
||||||
|
disabled={!hasConversation || !streaming}
|
||||||
|
onClick={onStop}
|
||||||
|
className="rounded px-3 py-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Bounds on server-fed chat state. A hostile or malfunctioning gateway can
|
||||||
|
* flood any of these collections; caps keep memory/render cost flat instead
|
||||||
|
* of growing unboundedly for the lifetime of the connection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Max characters retained for the in-flight streamed text/thinking buffers. */
|
||||||
|
export const MAX_STREAM_CHARS = 20_000;
|
||||||
|
/** Max transcript turns retained (oldest dropped first). */
|
||||||
|
export const MAX_MESSAGES = 500;
|
||||||
|
/** Max tool-call entries (including anomaly entries) retained per turn history. */
|
||||||
|
export const MAX_TOOLS = 200;
|
||||||
|
/** Max slash-command results retained. */
|
||||||
|
export const MAX_COMMAND_RESULTS = 200;
|
||||||
|
/** Max commands/skills accepted from a single manifest push. */
|
||||||
|
export const MAX_MANIFEST_ITEMS = 500;
|
||||||
|
/** Max executed approval IDs remembered for single-flight dedup. */
|
||||||
|
export const MAX_EXECUTED_APPROVAL_IDS = 200;
|
||||||
|
/** Max characters retained for a single command:result message — a hostile
|
||||||
|
* or malfunctioning gateway must not be able to push an unbounded curated
|
||||||
|
* success/failure reason into state (or, defensively, onto the page). */
|
||||||
|
export const MAX_COMMAND_MESSAGE_CHARS = 1_000;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import type { ChatTranscriptMessage } from './use-chat-connection';
|
||||||
|
|
||||||
|
interface MessageTranscriptProps {
|
||||||
|
messages: ChatTranscriptMessage[];
|
||||||
|
streaming: boolean;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageTranscript({
|
||||||
|
messages,
|
||||||
|
streaming,
|
||||||
|
text,
|
||||||
|
}: MessageTranscriptProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="log"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label="Conversation"
|
||||||
|
className="flex flex-1 flex-col gap-3 overflow-y-auto p-4"
|
||||||
|
>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<div key={message.id} data-role={message.role} className="whitespace-pre-wrap text-sm">
|
||||||
|
<span className="font-medium">{message.role === 'user' ? 'You' : 'Assistant'}: </span>
|
||||||
|
<span>{message.text}</span>
|
||||||
|
{message.thinking ? (
|
||||||
|
<div className="pt-1 text-xs italic opacity-70">{message.thinking}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{streaming ? (
|
||||||
|
<div data-role="assistant-streaming" className="whitespace-pre-wrap text-sm">
|
||||||
|
<span className="font-medium">Assistant: </span>
|
||||||
|
<span>{text || 'Thinking…'}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Socket.IO payloads are only statically typed at the call site — a
|
||||||
|
* misbehaving or compromised gateway can send anything at runtime. These
|
||||||
|
* guards protect the dereference sites that would otherwise throw (`.map` on
|
||||||
|
* a non-array, `.toFixed` on a non-number) or render an object as a React
|
||||||
|
* child.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
HarnessAuthState,
|
||||||
|
HarnessCatalog,
|
||||||
|
HarnessCatalogEntry,
|
||||||
|
HarnessModelAvailability,
|
||||||
|
HarnessSelection,
|
||||||
|
HarnessSummary,
|
||||||
|
} from '@/lib/types';
|
||||||
|
|
||||||
|
export function asString(value: unknown, fallback = ''): string {
|
||||||
|
return typeof value === 'string' ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like `asString`, but an empty string also falls back — used for guarded
|
||||||
|
* contract-provided reason strings (e.g. a denial or failure message) where
|
||||||
|
* an empty string is not a meaningful value to display in place of the
|
||||||
|
* stable fallback copy. */
|
||||||
|
export function asNonEmptyString(value: unknown, fallback: string): string {
|
||||||
|
return typeof value === 'string' && value.length > 0 ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asFiniteNumber(value: unknown, fallback = 0): number {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like `asFiniteNumber`, but returns `null` on failure instead of a numeric
|
||||||
|
* fallback — callers that must not fabricate a plausible-looking value (e.g.
|
||||||
|
* `0 tokens` / `$0.0000` for genuinely unknown usage) use this to render an
|
||||||
|
* honest "unavailable" label instead. */
|
||||||
|
export function asFiniteNumberOrNull(value: unknown): number | null {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asStringArray(value: unknown): string[] {
|
||||||
|
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HTTP harness/catalog/selection JSON bodies are as untrusted as the socket
|
||||||
|
* payloads above — a misbehaving or compromised gateway can send anything. The
|
||||||
|
* guards below normalize those bodies into the typed client shapes without ever
|
||||||
|
* rendering a raw body, so a 404/422/malformed response can never inject an
|
||||||
|
* object into React or a non-tuple into the selection state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Normalizes an untrusted `authState` to the closed set, defaulting to the
|
||||||
|
* safest value (`unavailable`) for anything unrecognized. */
|
||||||
|
export function asHarnessAuthState(value: unknown): HarnessAuthState {
|
||||||
|
return value === 'ready' || value === 'auth_required' || value === 'unavailable'
|
||||||
|
? value
|
||||||
|
: 'unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes an untrusted `availability` to the closed set, defaulting to
|
||||||
|
* `unavailable` so a malformed row can never present as sendable. */
|
||||||
|
export function asHarnessAvailability(value: unknown): HarnessModelAvailability {
|
||||||
|
return value === 'available' ? 'available' : 'unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tuple is valid only when all three ids are non-empty strings — a partial
|
||||||
|
* or malformed selection is rejected (null) rather than half-adopted. */
|
||||||
|
export function asHarnessSelection(value: unknown): HarnessSelection | null {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
const harnessId = value.harnessId;
|
||||||
|
const providerId = value.providerId;
|
||||||
|
const modelId = value.modelId;
|
||||||
|
if (
|
||||||
|
typeof harnessId !== 'string' ||
|
||||||
|
typeof providerId !== 'string' ||
|
||||||
|
typeof modelId !== 'string' ||
|
||||||
|
harnessId.length === 0 ||
|
||||||
|
providerId.length === 0 ||
|
||||||
|
modelId.length === 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { harnessId, providerId, modelId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes an untrusted array into typed harness summaries, dropping any row
|
||||||
|
* without a usable id. */
|
||||||
|
export function asHarnessSummaries(value: unknown): HarnessSummary[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const out: HarnessSummary[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
if (!isRecord(item)) continue;
|
||||||
|
const id = asString(item.id);
|
||||||
|
if (id.length === 0) continue;
|
||||||
|
out.push({
|
||||||
|
id,
|
||||||
|
displayName: asNonEmptyString(item.displayName, id),
|
||||||
|
capabilities: asStringArray(item.capabilities),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asHarnessCatalogEntry(value: unknown): HarnessCatalogEntry | null {
|
||||||
|
const selection = asHarnessSelection(value);
|
||||||
|
if (selection === null || !isRecord(value)) return null;
|
||||||
|
return {
|
||||||
|
...selection,
|
||||||
|
displayName: asNonEmptyString(value.displayName, selection.modelId),
|
||||||
|
reasoningCapability: value.reasoningCapability === true,
|
||||||
|
inputTypes: asStringArray(value.inputTypes),
|
||||||
|
authState: asHarnessAuthState(value.authState),
|
||||||
|
availability: asHarnessAvailability(value.availability),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes an untrusted catalog body into the typed client catalog. The
|
||||||
|
* caller supplies `harnessId` (from the request path) so the returned catalog
|
||||||
|
* is scoped to the harness that was actually requested, never a body-echoed id.
|
||||||
|
* Malformed model rows are dropped rather than invalidating the whole catalog. */
|
||||||
|
export function asHarnessCatalog(value: unknown, harnessId: string): HarnessCatalog {
|
||||||
|
const record = isRecord(value) ? value : {};
|
||||||
|
const rawModels = Array.isArray(record.models) ? record.models : [];
|
||||||
|
const models: HarnessCatalogEntry[] = [];
|
||||||
|
for (const row of rawModels) {
|
||||||
|
const entry = asHarnessCatalogEntry(row);
|
||||||
|
if (entry !== null) models.push(entry);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
harnessId,
|
||||||
|
version: asString(record.version),
|
||||||
|
fingerprint: asString(record.fingerprint),
|
||||||
|
models,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single point of truth for what counts as a valid conversation ID
|
||||||
|
* anywhere a scoped server event may adopt one into state — a non-empty
|
||||||
|
* string, nothing else. Every site that establishes or compares
|
||||||
|
* `state.conversationId` against a raw socket payload must route through
|
||||||
|
* this guard so a malformed first frame (null/object/number/empty string)
|
||||||
|
* can never be adopted verbatim. */
|
||||||
|
export function asConversationId(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import type { SessionInfoPayload } from '@/lib/chat-contract';
|
||||||
|
import { MAX_MANIFEST_ITEMS } from './limits';
|
||||||
|
import { asString, asStringArray } from './runtime-guards';
|
||||||
|
|
||||||
|
interface SessionPanelProps {
|
||||||
|
sessionInfo: SessionInfoPayload | null;
|
||||||
|
onSetThinking: (level: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THINKING_LEVEL_UNAVAILABLE = '';
|
||||||
|
|
||||||
|
export function SessionPanel({
|
||||||
|
sessionInfo,
|
||||||
|
onSetThinking,
|
||||||
|
}: SessionPanelProps): ReactElement | null {
|
||||||
|
if (!sessionInfo) return null;
|
||||||
|
|
||||||
|
// The reducer already caps this before storing it, but the render site
|
||||||
|
// defends independently — a hostile payload must never be able to force
|
||||||
|
// this <select> to lay out an unbounded number of options.
|
||||||
|
const availableThinkingLevels = asStringArray(sessionInfo.availableThinkingLevels).slice(
|
||||||
|
0,
|
||||||
|
MAX_MANIFEST_ITEMS,
|
||||||
|
);
|
||||||
|
const hasThinkingLevels = availableThinkingLevels.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Session info"
|
||||||
|
className="flex flex-wrap items-center gap-3 border-b px-4 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<span>{asString(sessionInfo.provider, 'unknown')}</span>
|
||||||
|
<span>{asString(sessionInfo.modelId, 'unknown')}</span>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<span>Thinking level</span>
|
||||||
|
<select
|
||||||
|
aria-label="Thinking level"
|
||||||
|
value={
|
||||||
|
hasThinkingLevels ? asString(sessionInfo.thinkingLevel) : THINKING_LEVEL_UNAVAILABLE
|
||||||
|
}
|
||||||
|
onChange={(event) => {
|
||||||
|
// The placeholder option is not a real, settable level — a
|
||||||
|
// malformed availableThinkingLevels list must never let the
|
||||||
|
// client emit set:thinking for it.
|
||||||
|
if (!hasThinkingLevels) return;
|
||||||
|
onSetThinking(event.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasThinkingLevels ? (
|
||||||
|
availableThinkingLevels.map((level) => (
|
||||||
|
<option key={level} value={level}>
|
||||||
|
{level}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<option value={THINKING_LEVEL_UNAVAILABLE}>Thinking level unavailable</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{sessionInfo.routingDecision ? (
|
||||||
|
<span title={asString(sessionInfo.routingDecision.ruleName)}>
|
||||||
|
{asString(sessionInfo.routingDecision.reason)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { vi } from 'vitest';
|
||||||
|
import type { ClientToServerEvents, ServerToClientEvents } from '@/lib/chat-contract';
|
||||||
|
|
||||||
|
type ServerEvent = keyof ServerToClientEvents;
|
||||||
|
type ClientEvent = keyof ClientToServerEvents;
|
||||||
|
type ServerHandler<K extends ServerEvent> = ServerToClientEvents[K];
|
||||||
|
type ClientPayload<K extends ClientEvent> = Parameters<ClientToServerEvents[K]>[0];
|
||||||
|
|
||||||
|
export interface EmittedEvent<K extends ClientEvent = ClientEvent> {
|
||||||
|
event: K;
|
||||||
|
payload: ClientPayload<K>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */
|
||||||
|
export interface FakeChatSocket {
|
||||||
|
connected: boolean;
|
||||||
|
/** Mirrors socket.io-client's `Socket.id`: the connection identity the server
|
||||||
|
* echoes in a `chat:send-capability` payload. The generation-bound send
|
||||||
|
* protocol accepts an advertisement only when `payload.connectionId === id`. */
|
||||||
|
id: string;
|
||||||
|
connect(): FakeChatSocket;
|
||||||
|
on<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||||
|
off<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||||
|
emit<K extends ClientEvent>(event: K, payload: ClientPayload<K>): FakeChatSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A typed in-memory stand-in for `getSocket()`. Unlike a bare
|
||||||
|
* `(event: string, payload: unknown) => void` mock, every public method here is
|
||||||
|
* checked against the real `/chat` contract — a typo'd event name or a payload
|
||||||
|
* missing a required field fails to compile instead of silently no-op'ing at
|
||||||
|
* runtime.
|
||||||
|
*/
|
||||||
|
/** Socket.IO's built-in connection-state events. Not part of the app-level
|
||||||
|
* ServerToClientEvents contract, but real sockets always support them and
|
||||||
|
* `useChatConnection` registers a `disconnect` handler on the real socket. */
|
||||||
|
type LifecycleEvent = 'connect' | 'disconnect';
|
||||||
|
|
||||||
|
export function createFakeChatSocket(): {
|
||||||
|
socket: FakeChatSocket;
|
||||||
|
listeners: Map<ServerEvent, Set<(payload: never) => void>>;
|
||||||
|
emitted: EmittedEvent[];
|
||||||
|
serverEmit<K extends ServerEvent>(
|
||||||
|
event: K,
|
||||||
|
payload: Parameters<ServerToClientEvents[K]>[0],
|
||||||
|
): void;
|
||||||
|
/** Escape hatch for malformed-payload tests: bypasses the compile-time
|
||||||
|
* payload contract to simulate a genuinely untrusted runtime value from the
|
||||||
|
* server, e.g. a `session:info` with a non-array `availableThinkingLevels`. */
|
||||||
|
serverEmitRaw(event: ServerEvent, payload: unknown): void;
|
||||||
|
/** Simulates a transient Socket.IO `disconnect` — fires any handler(s)
|
||||||
|
* registered via `socket.on('disconnect', ...)` without clearing any
|
||||||
|
* listeners, mirroring how a real reconnecting socket behaves. */
|
||||||
|
simulateDisconnect(): void;
|
||||||
|
/** Simulates socket.io-client's automatic reconnect of the *same*
|
||||||
|
* instance after a transient disconnect: marks the socket connected again
|
||||||
|
* and fires any handler(s) registered via `socket.on('connect', ...)`,
|
||||||
|
* without clearing or replacing any listeners. A real reconnect is assigned
|
||||||
|
* a fresh `Socket.id`; pass `nextId` to model that new connection identity
|
||||||
|
* (defaults to the current id so existing callers are unaffected). */
|
||||||
|
simulateReconnect(nextId?: string): void;
|
||||||
|
} {
|
||||||
|
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
|
||||||
|
const emitted: EmittedEvent[] = [];
|
||||||
|
|
||||||
|
// Internal storage is intentionally keyed loosely (the per-event handler shape
|
||||||
|
// varies by K, which a single Map can't express); the generic signatures on the
|
||||||
|
// exported `socket`/`serverEmit` above and below are what keep test call sites
|
||||||
|
// type-checked against ServerToClientEvents/ClientToServerEvents.
|
||||||
|
const socket = {
|
||||||
|
connected: false,
|
||||||
|
id: 'socket-a',
|
||||||
|
connect: vi.fn(function connect(this: void) {
|
||||||
|
socket.connected = true;
|
||||||
|
return socket;
|
||||||
|
}),
|
||||||
|
on: vi.fn(function on(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
||||||
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
||||||
|
listeners.get(event)?.add(handler);
|
||||||
|
return socket;
|
||||||
|
}),
|
||||||
|
off: vi.fn(function off(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
||||||
|
listeners.get(event)?.delete(handler);
|
||||||
|
return socket;
|
||||||
|
}),
|
||||||
|
emit: vi.fn(function emit(this: void, event: ClientEvent, payload: unknown) {
|
||||||
|
emitted.push({ event, payload } as EmittedEvent);
|
||||||
|
return socket;
|
||||||
|
}),
|
||||||
|
} as unknown as FakeChatSocket;
|
||||||
|
|
||||||
|
function serverEmit<K extends ServerEvent>(
|
||||||
|
event: K,
|
||||||
|
payload: Parameters<ServerToClientEvents[K]>[0],
|
||||||
|
): void {
|
||||||
|
for (const handler of listeners.get(event) ?? []) {
|
||||||
|
(handler as (payload: Parameters<ServerToClientEvents[K]>[0]) => void)(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverEmitRaw(event: ServerEvent, payload: unknown): void {
|
||||||
|
for (const handler of listeners.get(event) ?? []) {
|
||||||
|
(handler as (payload: unknown) => void)(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateDisconnect(): void {
|
||||||
|
socket.connected = false;
|
||||||
|
const lifecycleKey = 'disconnect' satisfies LifecycleEvent as unknown as ServerEvent;
|
||||||
|
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
||||||
|
(handler as () => void)();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateReconnect(nextId: string = socket.id): void {
|
||||||
|
socket.connected = true;
|
||||||
|
socket.id = nextId;
|
||||||
|
const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent;
|
||||||
|
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
||||||
|
(handler as () => void)();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
socket,
|
||||||
|
listeners,
|
||||||
|
emitted,
|
||||||
|
serverEmit,
|
||||||
|
serverEmitRaw,
|
||||||
|
simulateDisconnect,
|
||||||
|
simulateReconnect,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ToolCallList } from './tool-call-list';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: Root | null;
|
||||||
|
let container: HTMLElement | null;
|
||||||
|
|
||||||
|
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ToolCallList', () => {
|
||||||
|
it('renders two entries independently, without a duplicate-key warning, when a valid toolCallId is shared', async () => {
|
||||||
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<ToolCallList
|
||||||
|
tools={[
|
||||||
|
{ toolCallId: 'dup', toolName: 'search', status: 'success' },
|
||||||
|
{ toolCallId: 'dup', toolName: 'search', status: 'running' },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = [...(container?.querySelectorAll('li') ?? [])];
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
expect(items[0]?.textContent).toContain('success');
|
||||||
|
expect(items[1]?.textContent).toContain('running');
|
||||||
|
|
||||||
|
const duplicateKeyWarning = consoleError.mock.calls.some((args) =>
|
||||||
|
args.some((arg) => typeof arg === 'string' && arg.includes('same key')),
|
||||||
|
);
|
||||||
|
expect(duplicateKeyWarning).toBe(false);
|
||||||
|
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import type { ToolCallState } from './use-chat-connection';
|
||||||
|
|
||||||
|
export function ToolCallList({ tools }: { tools: ToolCallState[] }): ReactElement | null {
|
||||||
|
if (tools.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul aria-label="Tool calls" className="flex flex-col gap-1 px-4 pb-2 text-xs">
|
||||||
|
{tools.map((tool, index) => (
|
||||||
|
<li
|
||||||
|
// A valid server-controlled toolCallId can legitimately repeat
|
||||||
|
// (e.g. two tool:start events sharing one id) — keying on it alone
|
||||||
|
// would give React two identical keys. Pairing it with its
|
||||||
|
// (stable, append-only) render index keeps every key unique.
|
||||||
|
key={`${tool.toolCallId}-${index}`}
|
||||||
|
role={tool.status === 'error' || tool.status === 'anomaly' ? 'alert' : 'status'}
|
||||||
|
>
|
||||||
|
{tool.toolName} —{' '}
|
||||||
|
{tool.status === 'anomaly' ? 'unexpected end (unknown tool call)' : tool.status}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,450 @@
|
|||||||
|
import { act, type ReactElement } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { useHarnessSelection, type HarnessSelectionValue } from './use-harness-selection';
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Scenario {
|
||||||
|
harnesses?: unknown;
|
||||||
|
catalog?: { body: unknown; status?: number };
|
||||||
|
selection?: unknown;
|
||||||
|
/** When set, the PUT resolves only when this is called (for race tests). */
|
||||||
|
deferPut?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Deferred<T> {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defer<T>(): Deferred<T> {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
let putBodies: unknown[] = [];
|
||||||
|
let putDeferred: Deferred<Response> | null = null;
|
||||||
|
|
||||||
|
function installFetch(scenario: Scenario): ReturnType<typeof vi.fn> {
|
||||||
|
putBodies = [];
|
||||||
|
putDeferred = scenario.deferPut ? defer<Response>() : null;
|
||||||
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = String(init?.method ?? 'GET').toUpperCase();
|
||||||
|
if (url === '/api/harnesses') return json(scenario.harnesses ?? []);
|
||||||
|
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
|
||||||
|
const spec = scenario.catalog ?? {
|
||||||
|
body: { harnessId: 'pi', version: '1', fingerprint: 'f', models: [] },
|
||||||
|
};
|
||||||
|
return json(spec.body, spec.status ?? 200);
|
||||||
|
}
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'GET') {
|
||||||
|
return json({ selection: scenario.selection ?? null });
|
||||||
|
}
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
||||||
|
putBodies.push(JSON.parse(String(init?.body)));
|
||||||
|
const ok = json({ selection: JSON.parse(String(init?.body)) });
|
||||||
|
if (putDeferred) return putDeferred.promise;
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
return fetchMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
let latest: HarnessSelectionValue | null = null;
|
||||||
|
|
||||||
|
function Probe(): ReactElement | null {
|
||||||
|
latest = useHarnessSelection();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let root: Root | null;
|
||||||
|
let container: HTMLElement;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
latest = null;
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mount(): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<Probe />);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush(times = 5): Promise<void> {
|
||||||
|
for (let i = 0; i < times; i += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function value(): HarnessSelectionValue {
|
||||||
|
if (!latest) throw new Error('hook value not captured');
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PI_CATALOG = {
|
||||||
|
harnessId: 'pi',
|
||||||
|
version: '2026-08-11',
|
||||||
|
fingerprint: 'fp',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
displayName: 'GPT-5',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
displayName: 'Claude',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useHarnessSelection', () => {
|
||||||
|
it('loads harnesses and, once a harness is chosen, the model options come only from its catalog', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
expect(value().harnesses).toEqual([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
||||||
|
expect(value().catalog).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(value().catalog?.harnessId).toBe('pi');
|
||||||
|
expect(value().catalog?.models.map((m) => m.modelId)).toEqual(['gpt-5', 'claude']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not auto-select any catalog row when there is no persisted selection (no first-row fallback)', async () => {
|
||||||
|
const fetchMock = installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(value().modelId).toBe('');
|
||||||
|
expect(value().persistedSelection).toBeNull();
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
// Nothing was persisted — no PUT fired for an unset selection.
|
||||||
|
const putCalls = fetchMock.mock.calls.filter(
|
||||||
|
(c) => String((c[1] as RequestInit)?.method).toUpperCase() === 'PUT',
|
||||||
|
);
|
||||||
|
expect(putCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the structured tuple and only enables send AFTER the PUT resolves (no race ahead of persistence)', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: null,
|
||||||
|
deferPut: true,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('openai');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('openai', 'gpt-5');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// PUT is in flight (deferred) — send MUST NOT be enabled yet.
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
putDeferred?.resolve(
|
||||||
|
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(putBodies).toContainEqual({ harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' });
|
||||||
|
expect(value().persistedSelection).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
expect(value().canSend).toBe(true);
|
||||||
|
// Task Five: the composer sends the nested `persistedSelection` tuple directly.
|
||||||
|
// The Task-Four compat flat `projection` ({provider, modelId}) is removed — the
|
||||||
|
// harnessId must never be dropped on the way to the wire.
|
||||||
|
expect('projection' in value()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a stale/unavailable persisted selection visibly displayed rather than silently dropping it', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'retired-model' },
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
// The persisted tuple is displayed even though its model is gone from the catalog.
|
||||||
|
expect(value().persistedSelection).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'retired-model',
|
||||||
|
});
|
||||||
|
expect(value().modelId).toBe('retired-model');
|
||||||
|
expect(value().isStale).toBe(true);
|
||||||
|
// A stale model is not a valid catalog option, so send stays disabled.
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables send for an empty catalog (no viable model) and never fabricates one', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: { harnessId: 'pi', version: '1', fingerprint: 'f', models: [] } },
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(value().catalog?.models ?? []).toHaveLength(0);
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the catalog unavailable and disables send when the catalog request 404s', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: {
|
||||||
|
body: { code: 'adapter_unavailable', message: 'x', harnessId: 'pi' },
|
||||||
|
status: 404,
|
||||||
|
},
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(value().catalogUnavailable).toBe(true);
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on a 422 persist, keeps the requested tuple visible, surfaces a typed error, and leaves send disabled', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: {
|
||||||
|
body: {
|
||||||
|
...PI_CATALOG,
|
||||||
|
models: [{ ...PI_CATALOG.models[0], availability: 'unavailable' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
// Override PUT to 422.
|
||||||
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = String(init?.method ?? 'GET').toUpperCase();
|
||||||
|
if (url === '/api/harnesses')
|
||||||
|
return json([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
||||||
|
if (url.endsWith('/catalog')) return json(PI_CATALOG);
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'GET')
|
||||||
|
return json({ selection: null });
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
code: 'model_unavailable',
|
||||||
|
message: 'nope',
|
||||||
|
selection: { harnessId: 'a', providerId: 'b', modelId: 'c' },
|
||||||
|
},
|
||||||
|
422,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response('nf', { status: 404 });
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('openai');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('openai', 'gpt-5');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(value().modelId).toBe('gpt-5');
|
||||||
|
expect(value().persistError?.code).toBe('model_unavailable');
|
||||||
|
expect(value().persistError?.requested).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
expect(value().persistedSelection).toBeNull();
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates the model on a provider change and keeps send disabled until the new tuple persists', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('openai');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('openai', 'gpt-5');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
// A valid provider-A tuple has persisted.
|
||||||
|
expect(value().canSend).toBe(true);
|
||||||
|
expect(value().persistedSelection).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switching provider clears the model that no longer belongs to it.
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('anthropic');
|
||||||
|
});
|
||||||
|
expect(value().modelId).toBe('');
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
|
||||||
|
// Send stays disabled until the new exact provider-B tuple persists.
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('anthropic', 'claude');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(value().canSend).toBe(true);
|
||||||
|
expect(value().persistedSelection).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
});
|
||||||
|
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
|
||||||
|
expect('projection' in value()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not enable send on a model pick until the PUT for that exact new tuple resolves', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
||||||
|
deferPut: true,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
// The persisted, in-catalog tuple is sendable after mount (no PUT needed).
|
||||||
|
expect(value().canSend).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('anthropic');
|
||||||
|
});
|
||||||
|
expect(value().modelId).toBe('');
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('anthropic', 'claude');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
// PUT for the new tuple is still in flight — send MUST stay disabled.
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
putDeferred?.resolve(
|
||||||
|
json({ selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(value().canSend).toBe(true);
|
||||||
|
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
|
||||||
|
expect('projection' in value()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never requests any /api/providers* endpoint across the whole flow', async () => {
|
||||||
|
const fetchMock = installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('anthropic');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('anthropic', 'claude');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
for (const call of fetchMock.mock.calls) {
|
||||||
|
expect(String(call[0])).not.toContain('/api/providers');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
fetchCatalog,
|
||||||
|
fetchHarnesses,
|
||||||
|
fetchPersistedSelection,
|
||||||
|
persistSelection,
|
||||||
|
type SelectionErrorCode,
|
||||||
|
} from './chat-api';
|
||||||
|
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
|
||||||
|
|
||||||
|
export interface HarnessPersistError {
|
||||||
|
code: SelectionErrorCode;
|
||||||
|
message: string;
|
||||||
|
/** The exact tuple the user requested — preserved so the failed selection
|
||||||
|
* stays visible rather than being silently dropped. */
|
||||||
|
requested: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessSelectionValue {
|
||||||
|
harnesses: HarnessSummary[];
|
||||||
|
catalog: HarnessCatalog | null;
|
||||||
|
/** True when the selected harness has no usable catalog (404/error). */
|
||||||
|
catalogUnavailable: boolean;
|
||||||
|
/** The working (displayed) selection, kept as three distinct ids. Empty
|
||||||
|
* strings mean "not chosen yet" — there is deliberately no first-row default. */
|
||||||
|
harnessId: string;
|
||||||
|
providerId: string;
|
||||||
|
modelId: string;
|
||||||
|
/** The last tuple confirmed persisted by the server, or null. */
|
||||||
|
persistedSelection: HarnessSelection | null;
|
||||||
|
/** True when a persisted selection references a model no longer present as an
|
||||||
|
* available catalog entry — it stays visibly displayed rather than dropped. */
|
||||||
|
isStale: boolean;
|
||||||
|
/** True ONLY once a full tuple has been confirmed persisted AND it is a
|
||||||
|
* currently-available catalog entry. Send stays disabled otherwise, so a send
|
||||||
|
* can never race ahead of successful persistence. */
|
||||||
|
canSend: boolean;
|
||||||
|
persistError: HarnessPersistError | null;
|
||||||
|
selectHarness: (harnessId: string) => void;
|
||||||
|
selectProvider: (providerId: string) => void;
|
||||||
|
/** Persist the EXACT catalog row's `{providerId, modelId}` — the caller
|
||||||
|
* resolves the composite option identity to the real entry and passes both
|
||||||
|
* ids, so a bare model id is never combined with ambient provider state. */
|
||||||
|
selectModel: (providerId: string, modelId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tuple is a currently-usable catalog option only when the catalog holds a
|
||||||
|
* matching, available entry — the single gate that keeps a stale/unavailable
|
||||||
|
* model from ever counting as sendable. */
|
||||||
|
function isAvailableInCatalog(
|
||||||
|
selection: HarnessSelection | null,
|
||||||
|
catalog: HarnessCatalog | null,
|
||||||
|
): boolean {
|
||||||
|
if (selection === null || catalog === null) return false;
|
||||||
|
return catalog.models.some(
|
||||||
|
(model) =>
|
||||||
|
model.providerId === selection.providerId &&
|
||||||
|
model.modelId === selection.modelId &&
|
||||||
|
model.availability === 'available',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tuplesEqual(a: HarnessSelection | null, b: HarnessSelection | null): boolean {
|
||||||
|
if (a === null || b === null) return a === b;
|
||||||
|
return a.harnessId === b.harnessId && a.providerId === b.providerId && a.modelId === b.modelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the harness/catalog/selection state for the chat composer: loads the
|
||||||
|
* harness list and any persisted tuple on mount, loads a harness's catalog when
|
||||||
|
* chosen, and PUT-persists the full `{harnessId, providerId, modelId}` tuple
|
||||||
|
* when a model is picked. It never auto-selects a catalog row, keeps a
|
||||||
|
* stale/unavailable persisted tuple visible, and only reports `canSend` true
|
||||||
|
* once a full tuple has actually persisted as an available catalog entry.
|
||||||
|
*/
|
||||||
|
export function useHarnessSelection(): HarnessSelectionValue {
|
||||||
|
const [harnesses, setHarnesses] = useState<HarnessSummary[]>([]);
|
||||||
|
const [catalog, setCatalog] = useState<HarnessCatalog | null>(null);
|
||||||
|
const [catalogUnavailable, setCatalogUnavailable] = useState(false);
|
||||||
|
const [harnessId, setHarnessId] = useState('');
|
||||||
|
const [providerId, setProviderId] = useState('');
|
||||||
|
const [modelId, setModelId] = useState('');
|
||||||
|
const [persistedSelection, setPersistedSelection] = useState<HarnessSelection | null>(null);
|
||||||
|
const [persistError, setPersistError] = useState<HarnessPersistError | null>(null);
|
||||||
|
|
||||||
|
// Monotonic request ids so a slow in-flight catalog/persist response can never
|
||||||
|
// overwrite the result of a newer request the user has since triggered.
|
||||||
|
const catalogRequestRef = useRef(0);
|
||||||
|
const persistRequestRef = useRef(0);
|
||||||
|
|
||||||
|
const loadCatalog = useCallback(async (id: string): Promise<void> => {
|
||||||
|
const requestId = catalogRequestRef.current + 1;
|
||||||
|
catalogRequestRef.current = requestId;
|
||||||
|
setCatalog(null);
|
||||||
|
setCatalogUnavailable(false);
|
||||||
|
const result = await fetchCatalog(id);
|
||||||
|
if (catalogRequestRef.current !== requestId) return;
|
||||||
|
if (result.ok) {
|
||||||
|
setCatalog(result.catalog);
|
||||||
|
setCatalogUnavailable(false);
|
||||||
|
} else {
|
||||||
|
setCatalog(null);
|
||||||
|
setCatalogUnavailable(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
void (async (): Promise<void> => {
|
||||||
|
const [list, persisted] = await Promise.all([fetchHarnesses(), fetchPersistedSelection()]);
|
||||||
|
if (!active) return;
|
||||||
|
setHarnesses(list);
|
||||||
|
if (persisted !== null) {
|
||||||
|
// Adopt the persisted tuple as the displayed selection and load its
|
||||||
|
// catalog. If the model has since been retired, it still shows (stale).
|
||||||
|
setHarnessId(persisted.harnessId);
|
||||||
|
setProviderId(persisted.providerId);
|
||||||
|
setModelId(persisted.modelId);
|
||||||
|
setPersistedSelection(persisted);
|
||||||
|
await loadCatalog(persisted.harnessId);
|
||||||
|
}
|
||||||
|
// No persisted selection → nothing is auto-selected; the user must choose.
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [loadCatalog]);
|
||||||
|
|
||||||
|
const selectHarness = useCallback(
|
||||||
|
(id: string): void => {
|
||||||
|
setHarnessId(id);
|
||||||
|
// Changing harness invalidates the provider/model draft — never carry a
|
||||||
|
// model across harnesses.
|
||||||
|
setProviderId('');
|
||||||
|
setModelId('');
|
||||||
|
setPersistError(null);
|
||||||
|
void loadCatalog(id);
|
||||||
|
},
|
||||||
|
[loadCatalog],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectProvider = useCallback((id: string): void => {
|
||||||
|
setProviderId(id);
|
||||||
|
// A new provider invalidates the chosen model — no cross-provider carryover.
|
||||||
|
setModelId('');
|
||||||
|
setPersistError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectModel = useCallback(
|
||||||
|
(selectedProviderId: string, selectedModelId: string): void => {
|
||||||
|
// Bind the model to the EXACT catalog row's provider — never to ambient
|
||||||
|
// provider state — so two providers exposing the same modelId can never
|
||||||
|
// collide or mis-resolve. Keep the displayed provider consistent with the
|
||||||
|
// resolved row.
|
||||||
|
setProviderId(selectedProviderId);
|
||||||
|
setModelId(selectedModelId);
|
||||||
|
setPersistError(null);
|
||||||
|
const requested: HarnessSelection = {
|
||||||
|
harnessId,
|
||||||
|
providerId: selectedProviderId,
|
||||||
|
modelId: selectedModelId,
|
||||||
|
};
|
||||||
|
const requestId = persistRequestRef.current + 1;
|
||||||
|
persistRequestRef.current = requestId;
|
||||||
|
void (async (): Promise<void> => {
|
||||||
|
const result = await persistSelection(requested);
|
||||||
|
if (persistRequestRef.current !== requestId) return;
|
||||||
|
if (result.ok) {
|
||||||
|
setPersistedSelection(result.selection);
|
||||||
|
setPersistError(null);
|
||||||
|
} else {
|
||||||
|
// Leave persistedSelection unchanged (send stays disabled) and surface
|
||||||
|
// the typed error carrying the exact requested tuple.
|
||||||
|
setPersistError({
|
||||||
|
code: result.code,
|
||||||
|
message: result.message,
|
||||||
|
requested: result.requested,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
[harnessId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const draft: HarnessSelection = { harnessId, providerId, modelId };
|
||||||
|
const isStale = persistedSelection !== null && !isAvailableInCatalog(persistedSelection, catalog);
|
||||||
|
const canSend =
|
||||||
|
persistedSelection !== null &&
|
||||||
|
!catalogUnavailable &&
|
||||||
|
tuplesEqual(draft, persistedSelection) &&
|
||||||
|
isAvailableInCatalog(persistedSelection, catalog);
|
||||||
|
|
||||||
|
return {
|
||||||
|
harnesses,
|
||||||
|
catalog,
|
||||||
|
catalogUnavailable,
|
||||||
|
harnessId,
|
||||||
|
providerId,
|
||||||
|
modelId,
|
||||||
|
persistedSelection,
|
||||||
|
isStale,
|
||||||
|
canSend,
|
||||||
|
persistError,
|
||||||
|
selectHarness,
|
||||||
|
selectProvider,
|
||||||
|
selectModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { useSessionMock } = vi.hoisted(() => ({
|
||||||
|
useSessionMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/auth-client', () => ({
|
||||||
|
useSession: useSessionMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { AuthGuard, GuestGuard } from './guards';
|
||||||
|
|
||||||
|
interface RenderedRouter {
|
||||||
|
container: HTMLDivElement;
|
||||||
|
router: ReturnType<typeof createMemoryRouter>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountedRoots: Root[] = [];
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderRouter(
|
||||||
|
routeObjects: RouteObject[],
|
||||||
|
initialEntry: string,
|
||||||
|
): Promise<RenderedRouter> {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
const router = createMemoryRouter(routeObjects, { initialEntries: [initialEntry] });
|
||||||
|
const root = createRoot(container);
|
||||||
|
mountedRoots.push(root);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<RouterProvider router={router} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { container, router };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const root of mountedRoots.splice(0)) {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.body.replaceChildren();
|
||||||
|
useSessionMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const guestRoutes: RouteObject[] = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
element: <GuestGuard />,
|
||||||
|
children: [{ index: true, element: <p>Guest page</p> }],
|
||||||
|
},
|
||||||
|
{ path: '/chat', element: <p>Chat page</p> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const authenticatedRoutes: RouteObject[] = [
|
||||||
|
{
|
||||||
|
path: '/chat',
|
||||||
|
element: <AuthGuard />,
|
||||||
|
children: [{ index: true, element: <p>Private page</p> }],
|
||||||
|
},
|
||||||
|
{ path: '/login', element: <p>Login page</p> },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('GuestGuard', () => {
|
||||||
|
it('renders the guest outlet while session lookup is pending', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
||||||
|
|
||||||
|
const view = await renderRouter(guestRoutes, '/login');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Guest page');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the guest outlet when no session exists', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||||
|
|
||||||
|
const view = await renderRouter(guestRoutes, '/login');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Guest page');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects an authenticated session to chat', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||||
|
|
||||||
|
const view = await renderRouter(guestRoutes, '/login');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Chat page');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/chat');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AuthGuard', () => {
|
||||||
|
it('renders the existing loading treatment while session lookup is pending', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
||||||
|
|
||||||
|
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Loading...');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/chat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects an unauthenticated visitor to login', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||||
|
|
||||||
|
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Login page');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the authenticated outlet when a session exists', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||||
|
|
||||||
|
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Private page');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/chat');
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user