Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74b3b9e869 |
@@ -11,87 +11,48 @@
|
|||||||
|
|
||||||
## Project Context
|
## Project Context
|
||||||
|
|
||||||
Mosaic Stack is a self-hosted, multi-user AI agent platform. It is a TypeScript monorepo with a NestJS gateway, Next.js dashboard, Pi SDK agent runtime, and Discord/Telegram plugin architecture.
|
Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo with NestJS gateway, Next.js web dashboard, Pi SDK agent runtime, and plugin architecture for Discord/Telegram.
|
||||||
|
|
||||||
### Stack
|
## Package Map
|
||||||
|
|
||||||
- **API:** NestJS with Fastify (`apps/gateway`)
|
|
||||||
- **Web:** Next.js 16 with React 19 (`apps/web`)
|
|
||||||
- **ORM and database:** Drizzle ORM, PostgreSQL 17, and pgvector (`packages/db`)
|
|
||||||
- **Authentication:** BetterAuth (`packages/auth`)
|
|
||||||
- **Agent runtime:** Pi SDK (`apps/gateway`, `packages/mosaic`)
|
|
||||||
- **Queue:** Valkey 8 (`packages/queue`)
|
|
||||||
- **Build:** pnpm workspaces and Turborepo
|
|
||||||
- **CI:** Woodpecker CI
|
|
||||||
- **Observability:** OpenTelemetry and Jaeger
|
|
||||||
|
|
||||||
### Package Map
|
|
||||||
|
|
||||||
| Package | Purpose | Key Dependencies |
|
| Package | Purpose | Key Dependencies |
|
||||||
| ------------------ | ----------------------------- | -------------------------------- |
|
| ------------------ | ------------------------------- | -------------------------------- |
|
||||||
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
||||||
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
||||||
| `packages/types` | Shared TypeScript contracts | class-validator |
|
| `packages/types` | Shared TypeScript contracts | class-validator |
|
||||||
| `packages/db` | Drizzle schema and migrations | drizzle-orm, postgres |
|
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
|
||||||
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
||||||
| `packages/brain` | Structured data layer | @mosaicstack/db |
|
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
|
||||||
| `packages/queue` | Valkey task queue and MCP | ioredis |
|
| `packages/queue` | Valkey task queue + MCP | ioredis |
|
||||||
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
||||||
| `packages/mosaic` | Unified `mosaic` CLI and TUI | Ink, Pi SDK, commander |
|
| `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander |
|
||||||
| `plugins/discord` | Discord channel plugin | discord.js |
|
| `plugins/discord` | Discord channel plugin | discord.js |
|
||||||
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
||||||
|
|
||||||
## Architecture and Code Conventions
|
## Architecture Rules
|
||||||
|
|
||||||
1. Gateway is the single API surface; all clients connect through it.
|
1. Gateway is the single API surface — all clients connect through it
|
||||||
2. Pi SDK is ESM-only; gateway and CLI code must remain ESM.
|
2. Pi SDK is ESM-only — gateway and CLI must use ESM
|
||||||
3. Use `"type": "module"`, NodeNext module resolution, and `.js` extensions in imports.
|
3. Socket.IO typed events defined in `@mosaicstack/types` enforce compile-time contracts
|
||||||
4. Keep typed Socket.IO events in `@mosaicstack/types` to enforce client/server contracts.
|
4. OTEL auto-instrumentation loads before NestJS bootstrap
|
||||||
5. Import OTEL tracing before NestJS bootstrap (`import './tracing.js'`).
|
5. BetterAuth manages auth tables; schema defined in `@mosaicstack/db`
|
||||||
6. Use explicit `@Inject()` decorators in NestJS because tsx/esbuild does not emit decorator metadata.
|
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
|
||||||
7. Keep DTOs in `*.dto.ts` files at module boundaries.
|
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
|
||||||
8. BetterAuth owns authentication tables; their schema is defined in `@mosaicstack/db`.
|
|
||||||
9. Create a task-specific scratchpad for non-trivial work.
|
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
Requirements: Node.js 20+, pnpm 10.6.2, and Docker Compose when optional local services are needed.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install --frozen-lockfile
|
docker compose up -d # Infrastructure
|
||||||
pnpm preflight
|
pnpm install # Dependencies
|
||||||
|
pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
||||||
# Optional local queue service only; do not start the full Compose stack.
|
|
||||||
docker compose up -d valkey
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The pre-push hook requires:
|
## Repo-Specific Notes
|
||||||
|
|
||||||
```bash
|
- DTOs in `*.dto.ts` files at module boundaries
|
||||||
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
||||||
```
|
- NodeNext module resolution in all tsconfigs
|
||||||
|
- Scratchpads are mandatory for non-trivial tasks
|
||||||
Software delivery also requires the applicable tests. Common repository commands are:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm typecheck # TypeScript checks across the workspace
|
|
||||||
pnpm lint # ESLint across the workspace
|
|
||||||
pnpm test # Checkout tests and package Vitest suites
|
|
||||||
pnpm format:check # Prettier check
|
|
||||||
pnpm build # Build all packages and applications
|
|
||||||
```
|
|
||||||
|
|
||||||
## Database and Local Runtime Safety
|
|
||||||
|
|
||||||
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
|
|
||||||
- PostgreSQL execution is held until KBN-101-00, KBN-101-03, and KBN-101-05 land.
|
|
||||||
- Do not invoke a migration runner, initialization SQL, or the Compose PostgreSQL service from this checkout.
|
|
||||||
- Do not start Gateway/Web or run root `pnpm dev` as a local PGlite route. The current dotenv loader can inherit a daemon PostgreSQL DSN; KBN-101-02 must make that path fail closed first.
|
|
||||||
- Migration artifact generation is offline and does not authorize PostgreSQL access:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm --filter @mosaicstack/db db:generate
|
|
||||||
```
|
|
||||||
|
|
||||||
## docs/TASKS.md — Schema (CANONICAL)
|
## docs/TASKS.md — Schema (CANONICAL)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,46 @@
|
|||||||
# Claude Compatibility Pointer
|
# CLAUDE.md — Mosaic Stack
|
||||||
|
|
||||||
@AGENTS.md
|
## Project
|
||||||
|
|
||||||
Do not add project guidance here. Keep `AGENTS.md` authoritative so every agent runtime receives the same instructions.
|
Self-hosted, multi-user AI agent platform. TypeScript monorepo.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **API**: NestJS + Fastify adapter (`apps/gateway`)
|
||||||
|
- **Web**: Next.js 16 + React 19 (`apps/web`)
|
||||||
|
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
|
||||||
|
- **Auth**: BetterAuth (`packages/auth`)
|
||||||
|
- **Agent**: Pi SDK (`packages/agent`, `packages/mosaic`)
|
||||||
|
- **Queue**: Valkey 8 (`packages/queue`)
|
||||||
|
- **Build**: pnpm workspaces + Turborepo
|
||||||
|
- **CI**: Woodpecker CI
|
||||||
|
- **Observability**: OpenTelemetry → Jaeger
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm typecheck # TypeScript check (all packages)
|
||||||
|
pnpm lint # ESLint (all packages)
|
||||||
|
pnpm format:check # Prettier check
|
||||||
|
pnpm test # Vitest (all packages)
|
||||||
|
pnpm build # Build all packages
|
||||||
|
|
||||||
|
# Database
|
||||||
|
pnpm --filter @mosaicstack/db db:generate # Offline migration artifact generation only
|
||||||
|
# PostgreSQL execution is held until KBN-101-00/-03/-05 land. Do not invoke a runner,
|
||||||
|
# init SQL, or Compose PostgreSQL service from this checkout.
|
||||||
|
|
||||||
|
# Dev: local PGlite data-layer work needs no PostgreSQL. Optional local queue service only:
|
||||||
|
docker compose up -d valkey
|
||||||
|
# Do not start Gateway/Web or root pnpm dev as a local PGlite route: the current unguarded dotenv
|
||||||
|
# loader can inherit a daemon PostgreSQL DSN. KBN-101-02 must make that state fail closed first.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
||||||
|
- NodeNext module resolution
|
||||||
|
- Explicit `@Inject()` decorators in NestJS (tsx/esbuild doesn't support emitDecoratorMetadata)
|
||||||
|
- DTOs in `*.dto.ts` files at module boundaries
|
||||||
|
- OTEL tracing imported before NestJS bootstrap (`import './tracing.js'`)
|
||||||
|
- All three gates must pass before push: typecheck, lint, format:check
|
||||||
|
|||||||
@@ -50,11 +50,7 @@ mosaic wizard # Full guided setup (gateway install → verify)
|
|||||||
|
|
||||||
- Node.js ≥ 20
|
- Node.js ≥ 20
|
||||||
- npm (for global @mosaicstack/mosaic install)
|
- npm (for global @mosaicstack/mosaic install)
|
||||||
- One or more runtimes:
|
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
||||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
|
||||||
- [Codex](https://github.com/openai/codex)
|
|
||||||
- [OpenCode](https://opencode.ai)
|
|
||||||
- [Pi](https://pi.dev)
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|||||||
@@ -1,624 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import * as nodeOs from 'node:os';
|
|
||||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
||||||
import * as nodeUrl from 'node:url';
|
|
||||||
import { MODULE_METADATA } from '@nestjs/common/constants.js';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
|
|
||||||
interface ComposedModuleGraph {
|
|
||||||
imports: readonly unknown[];
|
|
||||||
federationModule: unknown;
|
|
||||||
bootLogLines: readonly string[];
|
|
||||||
mosaicConfig: MosaicConfig;
|
|
||||||
resolvedConfigPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type StorageTier = 'local' | 'standalone' | 'federated';
|
|
||||||
|
|
||||||
interface ModuleGraphFixture {
|
|
||||||
tempRoot: string;
|
|
||||||
anchor: string;
|
|
||||||
homePath: string;
|
|
||||||
cwdPath: string;
|
|
||||||
monorepoRootEnvPath: string;
|
|
||||||
gatewayLocalEnvPath: string;
|
|
||||||
daemonEnvPath: string;
|
|
||||||
monorepoRootConfigPath: string;
|
|
||||||
gatewayLocalConfigPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModuleGraphFixtureOptions {
|
|
||||||
rootEnvMode?: 'present' | 'absent';
|
|
||||||
rootTier?: StorageTier;
|
|
||||||
rootEnvContents?: string;
|
|
||||||
redactionMarker?: string;
|
|
||||||
gatewayLocalTier?: StorageTier;
|
|
||||||
gatewayLocalEnvContents?: string;
|
|
||||||
daemonEnvContents?: string;
|
|
||||||
inheritedTier?: StorageTier;
|
|
||||||
expectedProcessTier?: string;
|
|
||||||
setup?: (fixture: ModuleGraphFixture) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
|
||||||
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
|
|
||||||
const MONOREPO_ROOT_DOTENV_LABEL = 'monorepo-root .env';
|
|
||||||
const DAEMON_DOTENV_LABEL = 'daemon .env';
|
|
||||||
|
|
||||||
function configJson(tier: StorageTier): string {
|
|
||||||
if (tier === 'local') {
|
|
||||||
return JSON.stringify({
|
|
||||||
tier,
|
|
||||||
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
|
||||||
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
|
||||||
memory: { type: 'keyword' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify({
|
|
||||||
tier,
|
|
||||||
storage: { type: 'postgres', url: 'postgresql://fixture.invalid/mosaic' },
|
|
||||||
queue: { type: 'bullmq' },
|
|
||||||
memory: { type: tier === 'federated' ? 'pgvector' : 'keyword' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotProcessEnv(): Record<string, string | undefined> {
|
|
||||||
return { ...process.env };
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
|
||||||
for (const key of Object.keys(process.env)) {
|
|
||||||
if (!(key in snapshot)) {
|
|
||||||
delete process.env[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(snapshot)) {
|
|
||||||
if (value === undefined) {
|
|
||||||
delete process.env[key];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
|
|
||||||
const relativePath = relative(tempRoot, path);
|
|
||||||
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
|
|
||||||
expectPathUnderTempRoot(path, tempRoot);
|
|
||||||
await mkdir(dirname(path), { recursive: true });
|
|
||||||
await writeFile(path, contents, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConfigModuleProvider {
|
|
||||||
provide: string;
|
|
||||||
useFactory: () => MosaicConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isConfigModuleProvider(value: unknown): value is ConfigModuleProvider {
|
|
||||||
if (typeof value !== 'object' || value === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!('provide' in value) || typeof value.provide !== 'string') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'useFactory' in value && typeof value.useFactory === 'function';
|
|
||||||
}
|
|
||||||
|
|
||||||
function singleBootLogLine(bootLogLines: readonly string[]): string {
|
|
||||||
expect(bootLogLines).toHaveLength(1);
|
|
||||||
const [bootLogLine] = bootLogLines;
|
|
||||||
if (bootLogLine === undefined) {
|
|
||||||
throw new Error('Expected a single boot log line');
|
|
||||||
}
|
|
||||||
|
|
||||||
return bootLogLine;
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectBootLogLine(
|
|
||||||
bootLogLines: readonly string[],
|
|
||||||
tier: StorageTier,
|
|
||||||
source: string,
|
|
||||||
): void {
|
|
||||||
const bootLogLine = singleBootLogLine(bootLogLines);
|
|
||||||
|
|
||||||
expect(bootLogLine).toContain(`storage tier=${tier}`);
|
|
||||||
expect(bootLogLine).toContain(`source=${source}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadModuleGraphFromDotenv(
|
|
||||||
options: ModuleGraphFixtureOptions,
|
|
||||||
): Promise<ComposedModuleGraph> {
|
|
||||||
const originalEnv = snapshotProcessEnv();
|
|
||||||
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-module-'));
|
|
||||||
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
||||||
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
|
|
||||||
const homePath = join(tempRoot, 'home');
|
|
||||||
const cwdPath = join(tempRoot, 'ambient', 'parent', 'cwd');
|
|
||||||
const fixture: ModuleGraphFixture = {
|
|
||||||
tempRoot,
|
|
||||||
anchor,
|
|
||||||
homePath,
|
|
||||||
cwdPath,
|
|
||||||
monorepoRootEnvPath: resolve(anchor, '../../..', '.env'),
|
|
||||||
gatewayLocalEnvPath: resolve(anchor, '..', '.env'),
|
|
||||||
daemonEnvPath: join(homePath, '.config', 'mosaic', 'gateway', '.env'),
|
|
||||||
monorepoRootConfigPath: resolve(anchor, '../../..', 'mosaic.config.json'),
|
|
||||||
gatewayLocalConfigPath: resolve(anchor, '..', 'mosaic.config.json'),
|
|
||||||
};
|
|
||||||
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
|
||||||
|
|
||||||
for (const path of Object.values(fixture)) {
|
|
||||||
expectPathUnderTempRoot(path, tempRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
await mkdir(anchor, { recursive: true });
|
|
||||||
await mkdir(cwdPath, { recursive: true });
|
|
||||||
|
|
||||||
if ((options.rootEnvMode ?? 'present') === 'absent') {
|
|
||||||
if (
|
|
||||||
options.rootEnvContents !== undefined ||
|
|
||||||
options.rootTier !== undefined ||
|
|
||||||
options.redactionMarker !== undefined
|
|
||||||
) {
|
|
||||||
throw new Error('Expected no root env fixture values when rootEnvMode is absent');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (options.rootEnvContents === undefined && options.rootTier === undefined) {
|
|
||||||
throw new Error('Expected rootTier or rootEnvContents');
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`;
|
|
||||||
const rootFixtureWithMarker = options.redactionMarker
|
|
||||||
? `${rootFixture}BETTER_AUTH_SECRET=${options.redactionMarker}\n`
|
|
||||||
: rootFixture;
|
|
||||||
await writeFixture(fixture.monorepoRootEnvPath, rootFixtureWithMarker, tempRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.daemonEnvContents !== undefined) {
|
|
||||||
await writeFixture(fixture.daemonEnvPath, options.daemonEnvContents, tempRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.gatewayLocalEnvContents !== undefined) {
|
|
||||||
await writeFixture(fixture.gatewayLocalEnvPath, options.gatewayLocalEnvContents, tempRoot);
|
|
||||||
} else if (options.gatewayLocalTier !== undefined) {
|
|
||||||
await writeFixture(
|
|
||||||
fixture.gatewayLocalEnvPath,
|
|
||||||
`MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`,
|
|
||||||
tempRoot,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.env['HOME'] = homePath;
|
|
||||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
|
||||||
delete process.env['DATABASE_URL'];
|
|
||||||
delete process.env['VALKEY_URL'];
|
|
||||||
delete process.env['MOSAIC_GATEWAY_HOME'];
|
|
||||||
|
|
||||||
await options.setup?.(fixture);
|
|
||||||
|
|
||||||
if (options.inheritedTier !== undefined) {
|
|
||||||
process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier;
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.resetModules();
|
|
||||||
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
|
|
||||||
vi.doMock('node:url', () => ({
|
|
||||||
...nodeUrl,
|
|
||||||
fileURLToPath: (url: string | URL): string => {
|
|
||||||
const actualPath = nodeUrl.fileURLToPath(url);
|
|
||||||
if (
|
|
||||||
actualPath.endsWith('/apps/gateway/src/env.ts') ||
|
|
||||||
actualPath.endsWith('/apps/gateway/src/env.js')
|
|
||||||
) {
|
|
||||||
return join(anchor, 'env.ts');
|
|
||||||
}
|
|
||||||
return actualPath;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
|
|
||||||
|
|
||||||
if (options.inheritedTier === undefined) {
|
|
||||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
|
|
||||||
} else {
|
|
||||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier);
|
|
||||||
}
|
|
||||||
|
|
||||||
const envModule = await import('./env.js');
|
|
||||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(
|
|
||||||
options.expectedProcessTier ?? options.rootTier,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { AppModule } = await import('./app.module.js');
|
|
||||||
const { FederationModule } = await import('./federation/federation.module.js');
|
|
||||||
const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule);
|
|
||||||
|
|
||||||
if (!Array.isArray(imports)) {
|
|
||||||
throw new Error('AppModule imports metadata is not an array');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { ConfigModule, MOSAIC_CONFIG } = await import('./config/config.module.js');
|
|
||||||
const providers: unknown = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, ConfigModule);
|
|
||||||
|
|
||||||
if (!Array.isArray(providers)) {
|
|
||||||
throw new Error('ConfigModule providers metadata is not an array');
|
|
||||||
}
|
|
||||||
|
|
||||||
const configProvider = providers
|
|
||||||
.filter(isConfigModuleProvider)
|
|
||||||
.find((provider: ConfigModuleProvider): boolean => provider.provide === MOSAIC_CONFIG);
|
|
||||||
|
|
||||||
if (!configProvider) {
|
|
||||||
throw new Error('MOSAIC_CONFIG provider factory not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
imports,
|
|
||||||
federationModule: FederationModule,
|
|
||||||
bootLogLines: consoleInfoSpy.mock.calls.map((args: readonly unknown[]): string =>
|
|
||||||
args.map((value: unknown): string => String(value)).join(' '),
|
|
||||||
),
|
|
||||||
mosaicConfig: configProvider.useFactory(),
|
|
||||||
resolvedConfigPath: envModule.resolveGatewayConfigPath(),
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
cwdSpy?.mockRestore();
|
|
||||||
vi.doUnmock('node:url');
|
|
||||||
vi.doUnmock('node:os');
|
|
||||||
vi.resetModules();
|
|
||||||
consoleInfoSpy?.mockRestore();
|
|
||||||
restoreProcessEnv(originalEnv);
|
|
||||||
await rm(tempRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AppModule federation gating', (): void => {
|
|
||||||
it('loads dotenv before tracing and AppModule evaluation', async (): Promise<void> => {
|
|
||||||
const mainSource = await readFile(new URL('./main.ts', import.meta.url), 'utf8');
|
|
||||||
const envImportIndex = mainSource.indexOf("import './env.js';");
|
|
||||||
const tracingImportIndex = mainSource.indexOf("import './tracing.js';");
|
|
||||||
const appModuleImportIndex = mainSource.indexOf("import { AppModule } from './app.module.js';");
|
|
||||||
|
|
||||||
expect(envImportIndex).toBeGreaterThan(-1);
|
|
||||||
expect(envImportIndex).toBeLessThan(tracingImportIndex);
|
|
||||||
expect(envImportIndex).toBeLessThan(appModuleImportIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(
|
|
||||||
'ignores ambient cwd/.env and cwd/../.env files',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'local',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
await writeFixture(
|
|
||||||
join(fixture.cwdPath, '.env'),
|
|
||||||
'MOSAIC_STORAGE_TIER=federated\n',
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
await writeFixture(
|
|
||||||
resolve(fixture.cwdPath, '..', '.env'),
|
|
||||||
'MOSAIC_STORAGE_TIER=federated\n',
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'ignores an ambient cwd/mosaic.config.json federated config',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'local',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
await writeFixture(
|
|
||||||
join(fixture.cwdPath, 'mosaic.config.json'),
|
|
||||||
configJson('federated'),
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'ignores an ambient cwd/../../mosaic.config.json federated config',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'local',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
await writeFixture(
|
|
||||||
resolve(fixture.cwdPath, '../..', 'mosaic.config.json'),
|
|
||||||
configJson('federated'),
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'anchored gateway-local config wins monorepo-root config and registers FederationModule',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
let gatewayLocalConfigPath = '';
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'local',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
gatewayLocalConfigPath = fixture.gatewayLocalConfigPath;
|
|
||||||
await writeFixture(
|
|
||||||
fixture.gatewayLocalConfigPath,
|
|
||||||
configJson('federated'),
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.resolvedConfigPath).toBe(gatewayLocalConfigPath);
|
|
||||||
expect(graph.mosaicConfig.tier).toBe('federated');
|
|
||||||
expect(graph.imports).toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'resolves the daemon-installed GATEWAY_HOME/mosaic.config.json ahead of gateway-local and monorepo-root configs',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
let daemonConfigPath = '';
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootEnvMode: 'absent',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
const externalGatewayHome = join(fixture.tempRoot, 'external-gateway-home');
|
|
||||||
daemonConfigPath = join(externalGatewayHome, 'mosaic.config.json');
|
|
||||||
await writeFixture(daemonConfigPath, configJson('federated'), fixture.tempRoot);
|
|
||||||
await writeFixture(
|
|
||||||
fixture.gatewayLocalConfigPath,
|
|
||||||
configJson('standalone'),
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
|
||||||
process.env['MOSAIC_GATEWAY_HOME'] = externalGatewayHome;
|
|
||||||
process.env['DATABASE_URL'] = 'postgresql://fixture.invalid/mosaic';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.resolvedConfigPath).toBe(daemonConfigPath);
|
|
||||||
expect(graph.mosaicConfig.tier).toBe('federated');
|
|
||||||
expect(graph.imports).toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'logs mosaic.config.json when anchored config and env tiers are both federated',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'federated',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
await writeFixture(
|
|
||||||
fixture.monorepoRootConfigPath,
|
|
||||||
configJson('federated'),
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'logs standalone from a monorepo-root .env DATABASE_URL fallback',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'attributes an invalid monorepo-root dotenv tier to the default',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
|
|
||||||
expectedProcessTier: 'invalid',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'local', 'default');
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'attributes DATABASE_URL fallback to daemon .env ahead of inherited local tier',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootEnvMode: 'absent',
|
|
||||||
daemonEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
|
||||||
inheritedTier: 'local',
|
|
||||||
expectedProcessTier: 'local',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'daemon .env wins over monorepo-root and gateway-local tier values',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'local',
|
|
||||||
gatewayLocalTier: 'federated',
|
|
||||||
daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n',
|
|
||||||
expectedProcessTier: 'standalone',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'local',
|
|
||||||
gatewayLocalTier: 'federated',
|
|
||||||
daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n',
|
|
||||||
inheritedTier: 'standalone',
|
|
||||||
expectedProcessTier: 'standalone',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment');
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'gateway-local .env configures the tier and source when the monorepo-root .env is absent',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootEnvMode: 'absent',
|
|
||||||
gatewayLocalTier: 'federated',
|
|
||||||
expectedProcessTier: 'federated',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env');
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'monorepo-root .env wins over gateway-local tier values',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'standalone',
|
|
||||||
gatewayLocalTier: 'federated',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each(['local', 'standalone'] as const)(
|
|
||||||
'does not register FederationModule for the %s tier',
|
|
||||||
async (tier): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({ rootTier: tier });
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'registers FederationModule when federated tier is supplied by the anchored monorepo root .env',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const redactionMarker = 'redaction-fixture-marker';
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'federated',
|
|
||||||
redactionMarker,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
expect(singleBootLogLine(graph.bootLogLines)).not.toContain(redactionMarker);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'MOSAIC_CONFIG provider ignores an ambient cwd/mosaic.config.json config',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'local',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
await writeFixture(
|
|
||||||
join(fixture.cwdPath, 'mosaic.config.json'),
|
|
||||||
JSON.stringify({
|
|
||||||
tier: 'federated',
|
|
||||||
storage: {
|
|
||||||
type: 'postgres',
|
|
||||||
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
|
||||||
enableVector: true,
|
|
||||||
},
|
|
||||||
queue: { type: 'bullmq' },
|
|
||||||
memory: { type: 'pgvector' },
|
|
||||||
}),
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.mosaicConfig.tier).toBe('local');
|
|
||||||
expect(graph.mosaicConfig.storage).not.toEqual(
|
|
||||||
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'MOSAIC_CONFIG provider resolves from the anchored monorepo-root mosaic.config.json',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'local',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
await writeFixture(
|
|
||||||
fixture.monorepoRootConfigPath,
|
|
||||||
configJson('federated'),
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.mosaicConfig.tier).toBe('federated');
|
|
||||||
expect(graph.mosaicConfig.storage).toEqual(
|
|
||||||
expect.objectContaining({ url: 'postgresql://fixture.invalid/mosaic' }),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -26,16 +26,6 @@ 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: [
|
||||||
@@ -63,7 +53,7 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
|||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
...(federationEnabled ? [FederationModule] : []),
|
FederationModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||||
@@ -13,7 +12,6 @@ const mockRegistry = {
|
|||||||
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
||||||
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
{ name: 'mcp', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
|
||||||
],
|
],
|
||||||
skills: [],
|
skills: [],
|
||||||
})),
|
})),
|
||||||
@@ -74,14 +72,7 @@ const mockChatGateway = {
|
|||||||
broadcastSessionInfo: vi.fn(),
|
broadcastSessionInfo: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildService(
|
function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService {
|
||||||
redis: typeof mockRedis | null = mockRedis,
|
|
||||||
mcpClient: {
|
|
||||||
reconnectServer: ReturnType<typeof vi.fn>;
|
|
||||||
getServerStatuses: ReturnType<typeof vi.fn>;
|
|
||||||
getToolDefinitions: ReturnType<typeof vi.fn>;
|
|
||||||
} | null = null,
|
|
||||||
): CommandExecutorService {
|
|
||||||
return new CommandExecutorService(
|
return new CommandExecutorService(
|
||||||
mockRegistry as never,
|
mockRegistry as never,
|
||||||
mockAgentService as never,
|
mockAgentService as never,
|
||||||
@@ -91,7 +82,7 @@ function buildService(
|
|||||||
mockBrain as never,
|
mockBrain as never,
|
||||||
null,
|
null,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
mcpClient as never,
|
null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,124 +258,4 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
expect(result.command).toBe('tools');
|
expect(result.command).toBe('tools');
|
||||||
expect(result.message).toContain('tools');
|
expect(result.message).toContain('tools');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Top-level catch sanitization (P3-4 re-review finding #1): a rejected
|
|
||||||
// Redis `set` inside /provider login is the only reachable path into the
|
|
||||||
// top-level catch in `execute()`. The raw exception must be logged
|
|
||||||
// server-side but never handed back to the socket client.
|
|
||||||
it('sanitizes the top-level command catch, logging the raw exception but never returning it to the client', async () => {
|
|
||||||
const distinctiveRawFailure = 'ECONNREFUSED distinctive-raw-redis-failure-token-9f31';
|
|
||||||
const rawError = new Error(distinctiveRawFailure);
|
|
||||||
const failingRedis = {
|
|
||||||
set: vi.fn().mockRejectedValue(rawError),
|
|
||||||
get: vi.fn(),
|
|
||||||
del: vi.fn(),
|
|
||||||
};
|
|
||||||
const failingService = buildService(failingRedis as unknown as typeof mockRedis);
|
|
||||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
const payload: SlashCommandPayload = {
|
|
||||||
command: 'provider',
|
|
||||||
args: 'login anthropic',
|
|
||||||
conversationId,
|
|
||||||
};
|
|
||||||
const result = await failingService.execute(payload, userScope);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.command).toBe('provider');
|
|
||||||
expect(result.message).toBe('Command failed due to an internal error.');
|
|
||||||
expect(result.message).not.toContain(distinctiveRawFailure);
|
|
||||||
expect(result.message).not.toContain('ECONNREFUSED');
|
|
||||||
|
|
||||||
// The real exception is still logged server-side, as the raw Error
|
|
||||||
// object itself (not stringified/interpolated into the log message).
|
|
||||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
|
||||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(rawError));
|
|
||||||
expect(loggedRawError).toBe(true);
|
|
||||||
|
|
||||||
loggerErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inner catch sanitization (P3-5 operator ruling): every catch in
|
|
||||||
// command-executor.service.ts that returns a SlashCommandResultPayload
|
|
||||||
// must sanitize the client-facing message the same way the top-level
|
|
||||||
// catch does, while still logging the raw exception server-side.
|
|
||||||
it('/agent new sanitizes agent-creation failures, logging the raw exception but never returning it to the client', async () => {
|
|
||||||
const marker = new Error('distinctive-agent-create-failure-token-A17f');
|
|
||||||
mockBrain.agents.create.mockRejectedValueOnce(marker);
|
|
||||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
const payload: SlashCommandPayload = {
|
|
||||||
command: 'agent',
|
|
||||||
args: 'new my-new-agent',
|
|
||||||
conversationId,
|
|
||||||
};
|
|
||||||
const result = await service.execute(payload, userScope);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.command).toBe('agent');
|
|
||||||
expect(result.message).toBe('Failed to create agent due to an internal error.');
|
|
||||||
expect(result.message).not.toContain('distinctive-agent-create-failure-token-A17f');
|
|
||||||
|
|
||||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
|
||||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
|
||||||
expect(loggedRawError).toBe(true);
|
|
||||||
|
|
||||||
loggerErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('/agent <name> switch sanitizes agent-lookup failures, logging the raw exception but never returning it to the client', async () => {
|
|
||||||
const marker = new Error('distinctive-agent-switch-failure-token-B29c');
|
|
||||||
mockBrain.agents.findByName.mockRejectedValueOnce(marker);
|
|
||||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
const payload: SlashCommandPayload = {
|
|
||||||
command: 'agent',
|
|
||||||
args: 'some-other-agent',
|
|
||||||
conversationId,
|
|
||||||
};
|
|
||||||
const result = await service.execute(payload, userScope);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.command).toBe('agent');
|
|
||||||
expect(result.message).toBe('Failed to switch agent due to an internal error.');
|
|
||||||
expect(result.message).not.toContain('distinctive-agent-switch-failure-token-B29c');
|
|
||||||
|
|
||||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
|
||||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
|
||||||
expect(loggedRawError).toBe(true);
|
|
||||||
|
|
||||||
loggerErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('/mcp reconnect sanitizes MCP client failures, logging the raw exception but never returning it to the client', async () => {
|
|
||||||
const marker = new Error('distinctive-mcp-reconnect-failure-token-C33e');
|
|
||||||
const mockMcpClient = {
|
|
||||||
reconnectServer: vi.fn().mockRejectedValue(marker),
|
|
||||||
getServerStatuses: vi.fn(() => []),
|
|
||||||
getToolDefinitions: vi.fn(() => []),
|
|
||||||
};
|
|
||||||
const mcpService = buildService(mockRedis, mockMcpClient);
|
|
||||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
const payload: SlashCommandPayload = {
|
|
||||||
command: 'mcp',
|
|
||||||
args: 'reconnect my-server',
|
|
||||||
conversationId,
|
|
||||||
};
|
|
||||||
const result = await mcpService.execute(payload, userScope);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.command).toBe('mcp');
|
|
||||||
expect(result.message).toBe(
|
|
||||||
'Failed to reconnect MCP server "my-server" due to an internal error.',
|
|
||||||
);
|
|
||||||
expect(result.message).not.toContain('distinctive-mcp-reconnect-failure-token-C33e');
|
|
||||||
|
|
||||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
|
||||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
|
||||||
expect(loggedRawError).toBe(true);
|
|
||||||
|
|
||||||
loggerErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -159,13 +159,8 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Command /${command} failed`, err);
|
this.logger.error(`Command /${command} failed: ${err}`);
|
||||||
return {
|
return { command, conversationId, success: false, message: String(err) };
|
||||||
command,
|
|
||||||
conversationId,
|
|
||||||
success: false,
|
|
||||||
message: 'Command failed due to an internal error.',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,11 +336,11 @@ export class CommandExecutorService {
|
|||||||
data: { agentId: newAgent.id, agentName: newAgent.name },
|
data: { agentId: newAgent.id, agentName: newAgent.name },
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to create agent "${namePart}" for user ${userId}`, err);
|
this.logger.error(`Failed to create agent: ${err}`);
|
||||||
return {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to create agent due to an internal error.',
|
message: `Failed to create agent: ${String(err)}`,
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -396,11 +391,11 @@ export class CommandExecutorService {
|
|||||||
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
|
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to switch agent "${agentName}"`, err);
|
this.logger.error(`Failed to switch agent "${agentName}": ${err}`);
|
||||||
return {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to switch agent due to an internal error.',
|
message: `Failed to switch agent: ${String(err)}`,
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -613,12 +608,11 @@ export class CommandExecutorService {
|
|||||||
message: `MCP server "${serverName}" reconnected successfully.`,
|
message: `MCP server "${serverName}" reconnected successfully.`,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to reconnect MCP server "${serverName}"`, err);
|
|
||||||
return {
|
return {
|
||||||
command: 'mcp',
|
command: 'mcp',
|
||||||
conversationId,
|
conversationId,
|
||||||
success: false,
|
success: false,
|
||||||
message: `Failed to reconnect MCP server "${serverName}" due to an internal error.`,
|
message: `Failed to reconnect MCP server "${serverName}": ${err instanceof Error ? err.message : String(err)}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
||||||
import { resolveGatewayConfigPath } from '../env.js';
|
|
||||||
|
|
||||||
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
||||||
|
|
||||||
@@ -9,7 +8,7 @@ export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: MOSAIC_CONFIG,
|
provide: MOSAIC_CONFIG,
|
||||||
useFactory: (): MosaicConfig => loadConfig(resolveGatewayConfigPath()),
|
useFactory: (): MosaicConfig => loadConfig(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [MOSAIC_CONFIG],
|
exports: [MOSAIC_CONFIG],
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
import { config } from 'dotenv';
|
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
import { dirname, join, resolve } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { detectFromEnv, loadConfig } from '@mosaicstack/config';
|
|
||||||
|
|
||||||
type TierSource =
|
|
||||||
| 'process environment'
|
|
||||||
| 'daemon .env'
|
|
||||||
| 'monorepo-root .env'
|
|
||||||
| 'gateway-local .env'
|
|
||||||
| 'default';
|
|
||||||
|
|
||||||
type BootSource = TierSource | 'mosaic.config.json';
|
|
||||||
|
|
||||||
export interface GatewayDotenvPaths {
|
|
||||||
daemonEnv: string;
|
|
||||||
monorepoRootEnv: string;
|
|
||||||
gatewayLocalEnv: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
export function resolveGatewayDotenvPaths(
|
|
||||||
anchor: string = here,
|
|
||||||
homeBase: string = homedir(),
|
|
||||||
): GatewayDotenvPaths {
|
|
||||||
return {
|
|
||||||
daemonEnv: join(homeBase, '.config', 'mosaic', 'gateway', '.env'),
|
|
||||||
monorepoRootEnv: resolve(anchor, '../../..', '.env'),
|
|
||||||
gatewayLocalEnv: resolve(anchor, '..', '.env'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveGatewayConfigPath(anchor: string = here): string {
|
|
||||||
// GATEWAY_HOME is daemon-created 0700; its env override adds no authority because env can set MOSAIC_STORAGE_TIER.
|
|
||||||
const gatewayHome = resolve(
|
|
||||||
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
|
|
||||||
);
|
|
||||||
const daemonConfig = join(gatewayHome, 'mosaic.config.json');
|
|
||||||
const gatewayLocalConfig = resolve(anchor, '..', 'mosaic.config.json');
|
|
||||||
const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json');
|
|
||||||
|
|
||||||
if (existsSync(daemonConfig)) {
|
|
||||||
return daemonConfig;
|
|
||||||
}
|
|
||||||
if (existsSync(gatewayLocalConfig)) {
|
|
||||||
return gatewayLocalConfig;
|
|
||||||
}
|
|
||||||
if (existsSync(monorepoRootConfig)) {
|
|
||||||
return monorepoRootConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
return monorepoRootConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadGatewayEnv(anchor: string = here, homeBase: string = homedir()): void {
|
|
||||||
const { daemonEnv, monorepoRootEnv, gatewayLocalEnv } = resolveGatewayDotenvPaths(
|
|
||||||
anchor,
|
|
||||||
homeBase,
|
|
||||||
);
|
|
||||||
const inheritedTier = process.env['MOSAIC_STORAGE_TIER'];
|
|
||||||
let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment';
|
|
||||||
const inheritedDatabaseUrl = process.env['DATABASE_URL'];
|
|
||||||
let databaseUrlSource: TierSource =
|
|
||||||
inheritedDatabaseUrl === undefined ? 'default' : 'process environment';
|
|
||||||
|
|
||||||
function loadAnchoredDotenv(
|
|
||||||
path: string,
|
|
||||||
sourceLabel: Exclude<TierSource, 'process environment' | 'default'>,
|
|
||||||
): void {
|
|
||||||
if (!existsSync(path)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const beforeTier = process.env['MOSAIC_STORAGE_TIER'];
|
|
||||||
const beforeDatabaseUrl = process.env['DATABASE_URL'];
|
|
||||||
config({ path, quiet: true });
|
|
||||||
|
|
||||||
if (
|
|
||||||
beforeTier === undefined &&
|
|
||||||
process.env['MOSAIC_STORAGE_TIER'] !== undefined &&
|
|
||||||
tierSource === 'default'
|
|
||||||
) {
|
|
||||||
tierSource = sourceLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
beforeDatabaseUrl === undefined &&
|
|
||||||
process.env['DATABASE_URL'] !== undefined &&
|
|
||||||
databaseUrlSource === 'default'
|
|
||||||
) {
|
|
||||||
databaseUrlSource = sourceLabel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load .env from daemon config dir (global install / daemon mode) first.
|
|
||||||
// It takes precedence over file-based local-dev configuration.
|
|
||||||
loadAnchoredDotenv(daemonEnv, 'daemon .env');
|
|
||||||
|
|
||||||
// Load .env from the anchored monorepo root, then fill any remaining values
|
|
||||||
// from apps/gateway/.env when present.
|
|
||||||
loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env');
|
|
||||||
loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env');
|
|
||||||
|
|
||||||
const envOnlyTier = detectFromEnv().tier;
|
|
||||||
const configPath = resolveGatewayConfigPath(anchor);
|
|
||||||
const anchoredConfigExists = existsSync(configPath);
|
|
||||||
const resolvedTier = loadConfig(configPath).tier;
|
|
||||||
const configuredTier = process.env['MOSAIC_STORAGE_TIER'];
|
|
||||||
const databaseUrlDeterminesTier = envOnlyTier === 'standalone' && configuredTier !== 'standalone';
|
|
||||||
const recognizedTierDeterminesTier =
|
|
||||||
(configuredTier === 'federated' ||
|
|
||||||
configuredTier === 'standalone' ||
|
|
||||||
configuredTier === 'local') &&
|
|
||||||
configuredTier === envOnlyTier;
|
|
||||||
|
|
||||||
let source: BootSource;
|
|
||||||
if (anchoredConfigExists) {
|
|
||||||
source = 'mosaic.config.json';
|
|
||||||
} else if (databaseUrlDeterminesTier && databaseUrlSource !== 'default') {
|
|
||||||
source = databaseUrlSource;
|
|
||||||
} else if (recognizedTierDeterminesTier && tierSource !== 'default') {
|
|
||||||
source = tierSource;
|
|
||||||
} else {
|
|
||||||
source = 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadGatewayEnv();
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import * as nodeOs from 'node:os';
|
|
||||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
||||||
import * as nodeUrl from 'node:url';
|
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import type * as MosaicStorage from '@mosaicstack/storage';
|
|
||||||
import { describe, expect, it, vi, type MockInstance } from 'vitest';
|
|
||||||
|
|
||||||
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
|
||||||
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
|
|
||||||
|
|
||||||
function snapshotProcessEnv(): Record<string, string | undefined> {
|
|
||||||
return { ...process.env };
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
|
||||||
for (const key of Object.keys(process.env)) {
|
|
||||||
if (!(key in snapshot)) {
|
|
||||||
delete process.env[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(snapshot)) {
|
|
||||||
if (value === undefined) {
|
|
||||||
delete process.env[key];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
|
|
||||||
const relativePath = relative(tempRoot, path);
|
|
||||||
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
|
|
||||||
expectPathUnderTempRoot(path, tempRoot);
|
|
||||||
await mkdir(dirname(path), { recursive: true });
|
|
||||||
await writeFile(path, contents, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BootstrapPreflightResult {
|
|
||||||
capturedConfig: MosaicConfig | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBootstrapPreflight(
|
|
||||||
anchoredConfigContents: string,
|
|
||||||
ambientConfigContents: string,
|
|
||||||
): Promise<BootstrapPreflightResult> {
|
|
||||||
const originalEnv = snapshotProcessEnv();
|
|
||||||
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-main-preflight-'));
|
|
||||||
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
||||||
let exitSpy: MockInstance<typeof process.exit> | undefined;
|
|
||||||
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
||||||
let capturedConfig: MosaicConfig | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
|
|
||||||
const homePath = join(tempRoot, 'home');
|
|
||||||
const cwdPath = join(tempRoot, 'ambient', 'cwd');
|
|
||||||
const monorepoRootConfigPath = resolve(anchor, '../../..', 'mosaic.config.json');
|
|
||||||
|
|
||||||
await mkdir(anchor, { recursive: true });
|
|
||||||
await mkdir(cwdPath, { recursive: true });
|
|
||||||
|
|
||||||
await writeFixture(monorepoRootConfigPath, anchoredConfigContents, tempRoot);
|
|
||||||
await writeFixture(join(cwdPath, 'mosaic.config.json'), ambientConfigContents, tempRoot);
|
|
||||||
|
|
||||||
process.env['HOME'] = homePath;
|
|
||||||
process.env['BETTER_AUTH_SECRET'] = 'fixture-secret';
|
|
||||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
|
||||||
delete process.env['DATABASE_URL'];
|
|
||||||
delete process.env['VALKEY_URL'];
|
|
||||||
|
|
||||||
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
|
||||||
const exitMock = vi.fn<typeof process.exit>();
|
|
||||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation(exitMock);
|
|
||||||
|
|
||||||
vi.resetModules();
|
|
||||||
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
|
|
||||||
vi.doMock('node:url', () => ({
|
|
||||||
...nodeUrl,
|
|
||||||
fileURLToPath: (url: string | URL): string => {
|
|
||||||
const actualPath = nodeUrl.fileURLToPath(url);
|
|
||||||
if (
|
|
||||||
actualPath.endsWith('/apps/gateway/src/env.ts') ||
|
|
||||||
actualPath.endsWith('/apps/gateway/src/env.js')
|
|
||||||
) {
|
|
||||||
return join(anchor, 'env.ts');
|
|
||||||
}
|
|
||||||
return actualPath;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
|
|
||||||
vi.doMock('./tracing.js', () => ({}));
|
|
||||||
|
|
||||||
const preflightSentinel = new Error('preflight-capture-sentinel');
|
|
||||||
vi.doMock('@mosaicstack/storage', async () => {
|
|
||||||
const actual = await vi.importActual<typeof MosaicStorage>('@mosaicstack/storage');
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
detectAndAssertTier: vi.fn((config: MosaicConfig): Promise<void> => {
|
|
||||||
capturedConfig = config;
|
|
||||||
throw preflightSentinel;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
await import('./main.js');
|
|
||||||
await vi.waitFor((): void => {
|
|
||||||
expect(exitSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
return { capturedConfig };
|
|
||||||
} finally {
|
|
||||||
cwdSpy?.mockRestore();
|
|
||||||
exitSpy?.mockRestore();
|
|
||||||
consoleInfoSpy?.mockRestore();
|
|
||||||
vi.doUnmock('@mosaicstack/storage');
|
|
||||||
vi.doUnmock('./tracing.js');
|
|
||||||
vi.doUnmock('node:url');
|
|
||||||
vi.doUnmock('node:os');
|
|
||||||
vi.resetModules();
|
|
||||||
restoreProcessEnv(originalEnv);
|
|
||||||
await rm(tempRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('main bootstrap preflight config anchoring', (): void => {
|
|
||||||
it(
|
|
||||||
'passes the anchored monorepo-root config to detectAndAssertTier, not an ambient cwd config',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const anchoredConfig = JSON.stringify({
|
|
||||||
tier: 'local',
|
|
||||||
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
|
||||||
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
|
||||||
memory: { type: 'keyword' },
|
|
||||||
});
|
|
||||||
const ambientConfig = JSON.stringify({
|
|
||||||
tier: 'federated',
|
|
||||||
storage: {
|
|
||||||
type: 'postgres',
|
|
||||||
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
|
||||||
enableVector: true,
|
|
||||||
},
|
|
||||||
queue: { type: 'bullmq' },
|
|
||||||
memory: { type: 'pgvector' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const { capturedConfig } = await runBootstrapPreflight(anchoredConfig, ambientConfig);
|
|
||||||
|
|
||||||
expect(capturedConfig?.tier).toBe('local');
|
|
||||||
expect(capturedConfig?.storage).not.toEqual(
|
|
||||||
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import './env.js';
|
import { config } from 'dotenv';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { resolve, join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
// Load .env from daemon config dir (global install / daemon mode).
|
||||||
|
// Loaded first so monorepo .env can override for local dev.
|
||||||
|
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
|
||||||
|
if (existsSync(daemonEnv)) config({ path: daemonEnv });
|
||||||
|
|
||||||
|
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
|
||||||
|
config({ path: resolve(process.cwd(), '../../.env') });
|
||||||
|
config(); // Also load apps/gateway/.env if present (overrides)
|
||||||
|
|
||||||
import './tracing.js';
|
import './tracing.js';
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
@@ -13,7 +26,6 @@ import { mountAuthHandler } from './auth/auth.controller.js';
|
|||||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||||
import { McpService } from './mcp/mcp.service.js';
|
import { McpService } from './mcp/mcp.service.js';
|
||||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||||
import { resolveGatewayConfigPath } from './env.js';
|
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const logger = new Logger('Bootstrap');
|
const logger = new Logger('Bootstrap');
|
||||||
@@ -25,7 +37,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
// Pre-flight: assert all external services required by the configured tier
|
// Pre-flight: assert all external services required by the configured tier
|
||||||
// are reachable. Runs before NestFactory.create() so failures are visible
|
// are reachable. Runs before NestFactory.create() so failures are visible
|
||||||
// immediately with actionable remediation hints.
|
// immediately with actionable remediation hints.
|
||||||
const mosaicConfig = loadConfig(resolveGatewayConfigPath());
|
const mosaicConfig = loadConfig();
|
||||||
try {
|
try {
|
||||||
await detectAndAssertTier(mosaicConfig);
|
await detectAndAssertTier(mosaicConfig);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { McpClientService } from './mcp-client.service.js';
|
|
||||||
|
|
||||||
const MCP_LEAK_MARKER = 'MCP_LEAK_MARKER /srv/secret';
|
|
||||||
|
|
||||||
describe('McpClientService — failed connect error sanitization', () => {
|
|
||||||
const originalMcpServers = process.env['MCP_SERVERS'];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env['MCP_SERVERS'] = JSON.stringify([
|
|
||||||
{ name: 'leaky-server', url: 'http://localhost:9999/mcp' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
if (originalMcpServers === undefined) {
|
|
||||||
delete process.env['MCP_SERVERS'];
|
|
||||||
} else {
|
|
||||||
process.env['MCP_SERVERS'] = originalMcpServers;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores a generic serverEntry.error while logging the raw exception server-side', async () => {
|
|
||||||
vi.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error(MCP_LEAK_MARKER));
|
|
||||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
const service = new McpClientService();
|
|
||||||
await service.onModuleInit();
|
|
||||||
|
|
||||||
const statuses = service.getServerStatuses();
|
|
||||||
expect(statuses).toHaveLength(1);
|
|
||||||
expect(statuses[0]?.connected).toBe(false);
|
|
||||||
expect(statuses[0]?.error).toBe('Connection failed (see server logs).');
|
|
||||||
expect(statuses[0]?.error).not.toContain(MCP_LEAK_MARKER);
|
|
||||||
|
|
||||||
const loggedRawMarker = errorSpy.mock.calls.some((call) =>
|
|
||||||
call.some((arg) => typeof arg === 'string' && arg.includes(MCP_LEAK_MARKER)),
|
|
||||||
);
|
|
||||||
expect(loggedRawMarker).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -189,7 +189,7 @@ export class McpClientService implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
serverEntry.error = 'Connection failed (see server logs).';
|
serverEntry.error = message;
|
||||||
serverEntry.connected = false;
|
serverEntry.connected = false;
|
||||||
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import type { SlashCommandPayload, SystemReloadPayload } from '@mosaicstack/types';
|
|
||||||
import { ReloadService } from './reload.service.js';
|
import { ReloadService } from './reload.service.js';
|
||||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
|
||||||
|
|
||||||
function createMockCommandRegistry() {
|
function createMockCommandRegistry() {
|
||||||
return {
|
return {
|
||||||
@@ -107,79 +104,3 @@ describe('ReloadService', () => {
|
|||||||
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
|
||||||
it('generic per-plugin errors reach the chat surface while raw markers stay server-side only', async () => {
|
|
||||||
const registry = {
|
|
||||||
getManifest: vi.fn().mockReturnValue({
|
|
||||||
version: 1,
|
|
||||||
commands: [
|
|
||||||
{ name: 'reload', aliases: [], scope: 'core', execution: 'socket', available: true },
|
|
||||||
],
|
|
||||||
skills: [],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const reloadService = new ReloadService(registry as never);
|
|
||||||
|
|
||||||
const RELOAD_LOAD_LEAK_MARKER = 'RELOAD_LOAD_LEAK_MARKER /srv/load-secret';
|
|
||||||
const RELOAD_UNLOAD_LEAK_MARKER = 'RELOAD_UNLOAD_LEAK_MARKER /srv/unload-secret';
|
|
||||||
|
|
||||||
reloadService.registerPlugin('unload-fails', {
|
|
||||||
pluginName: 'unload-fails',
|
|
||||||
onLoad: vi.fn().mockResolvedValue(undefined),
|
|
||||||
onUnload: vi.fn().mockRejectedValue(new Error(RELOAD_UNLOAD_LEAK_MARKER)),
|
|
||||||
});
|
|
||||||
reloadService.registerPlugin('load-fails', {
|
|
||||||
pluginName: 'load-fails',
|
|
||||||
onLoad: vi.fn().mockRejectedValue(new Error(RELOAD_LOAD_LEAK_MARKER)),
|
|
||||||
onUnload: vi.fn().mockResolvedValue(undefined),
|
|
||||||
});
|
|
||||||
|
|
||||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
const broadcastReload = vi.fn();
|
|
||||||
const mockChatGateway = { broadcastReload };
|
|
||||||
const mockAgentService = { getSession: vi.fn(), applyAgentConfig: vi.fn() };
|
|
||||||
const mockSystemOverride = { set: vi.fn(), get: vi.fn(), clear: vi.fn() };
|
|
||||||
const mockSessionGC = { sweepOrphans: vi.fn() };
|
|
||||||
const mockBrain = { agents: { findByName: vi.fn(), findById: vi.fn(), create: vi.fn() } };
|
|
||||||
|
|
||||||
const executor = new CommandExecutorService(
|
|
||||||
registry as never,
|
|
||||||
mockAgentService as never,
|
|
||||||
mockSystemOverride as never,
|
|
||||||
mockSessionGC as never,
|
|
||||||
null,
|
|
||||||
mockBrain as never,
|
|
||||||
reloadService,
|
|
||||||
mockChatGateway as never,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
|
||||||
const result = await executor.execute(payload, { userId: 'user-1', tenantId: 'user-1' });
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.message).toContain('unload-fails: unload failed (internal error)');
|
|
||||||
expect(result.message).toContain('load-fails: load failed (internal error)');
|
|
||||||
expect(result.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
|
|
||||||
expect(result.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
|
|
||||||
|
|
||||||
expect(broadcastReload).toHaveBeenCalledOnce();
|
|
||||||
const broadcastPayload = broadcastReload.mock.calls[0]?.[0] as SystemReloadPayload;
|
|
||||||
expect(broadcastPayload.message).toContain('unload-fails: unload failed (internal error)');
|
|
||||||
expect(broadcastPayload.message).toContain('load-fails: load failed (internal error)');
|
|
||||||
expect(broadcastPayload.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
|
|
||||||
expect(broadcastPayload.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
|
|
||||||
|
|
||||||
const loggedUnloadMarker = errorSpy.mock.calls.some((call) =>
|
|
||||||
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_UNLOAD_LEAK_MARKER)),
|
|
||||||
);
|
|
||||||
const loggedLoadMarker = errorSpy.mock.calls.some((call) =>
|
|
||||||
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_LOAD_LEAK_MARKER)),
|
|
||||||
);
|
|
||||||
expect(loggedUnloadMarker).toBe(true);
|
|
||||||
expect(loggedLoadMarker).toBe(true);
|
|
||||||
|
|
||||||
errorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
|
|||||||
await plugin.onUnload();
|
await plugin.onUnload();
|
||||||
reloaded.push(name);
|
reloaded.push(name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Plugin "${name}" failed during onUnload: ${err}`);
|
errors.push(`${name}: unload failed — ${err}`);
|
||||||
errors.push(`${name}: unload failed (internal error)`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,8 +69,7 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
|
|||||||
try {
|
try {
|
||||||
await plugin.onLoad();
|
await plugin.onLoad();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Plugin "${name}" failed during onLoad: ${err}`);
|
errors.push(`${name}: load failed — ${err}`);
|
||||||
errors.push(`${name}: load failed (internal error)`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import {
|
|
||||||
type CanActivate,
|
|
||||||
type ExecutionContext,
|
|
||||||
type INestApplication,
|
|
||||||
ValidationPipe,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
|
||||||
import { WorkspaceController } from './workspace.controller.js';
|
|
||||||
|
|
||||||
const bootstrapMock = vi.fn(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
projectId: 'project-1',
|
|
||||||
workspacePath: '/opt/mosaic/.workspaces/users/user-1/project-1',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const authGuard: CanActivate = {
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
|
||||||
requestContext.user = { id: 'user-1' };
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('POST /api/workspaces repoUrl validation', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
controllers: [WorkspaceController],
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: ProjectBootstrapService,
|
|
||||||
useValue: { bootstrap: bootstrapMock },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.overrideGuard(AuthGuard)
|
|
||||||
.useValue(authGuard)
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
||||||
app.useGlobalPipes(
|
|
||||||
new ValidationPipe({
|
|
||||||
whitelist: true,
|
|
||||||
forbidNonWhitelisted: true,
|
|
||||||
transform: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await app.init();
|
|
||||||
await app.getHttpAdapter().getInstance().ready();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
bootstrapMock.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['a leading-dash value', '--upload-pack=sh -c id'],
|
|
||||||
['an ext remote helper', 'ext::sh -c id'],
|
|
||||||
['a file URL', 'file:///tmp/repository'],
|
|
||||||
['an unparseable value', 'not a url'],
|
|
||||||
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
|
||||||
['a scheme without //', 'https:example.com/acme/repository.git'],
|
|
||||||
['a hostless git URL', 'git:///tmp/repository'],
|
|
||||||
])('returns 400 for %s', async (_description, repoUrl) => {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.post('/api/workspaces')
|
|
||||||
.send({ name: 'Example', repoUrl })
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(bootstrapMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['a plain HTTPS repository URL', 'https://example.com/acme/repository.git'],
|
|
||||||
['a git protocol repository URL', 'git://example.com/acme/repository.git'],
|
|
||||||
])('accepts %s', async (_description, repoUrl) => {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.post('/api/workspaces')
|
|
||||||
.send({ name: 'Example', repoUrl })
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
|
||||||
expect(bootstrapMock).toHaveBeenCalledWith({
|
|
||||||
name: 'Example',
|
|
||||||
description: undefined,
|
|
||||||
userId: 'user-1',
|
|
||||||
teamId: undefined,
|
|
||||||
repoUrl,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import {
|
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||||
ProjectBootstrapService,
|
|
||||||
type BootstrapProjectResult,
|
|
||||||
} from './project-bootstrap.service.js';
|
|
||||||
import { CreateWorkspaceDto } from './workspace.dto.js';
|
|
||||||
|
|
||||||
@Controller('api/workspaces')
|
@Controller('api/workspaces')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -15,14 +11,20 @@ export class WorkspaceController {
|
|||||||
@Post()
|
@Post()
|
||||||
async create(
|
async create(
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
@Body() dto: CreateWorkspaceDto,
|
@Body()
|
||||||
): Promise<BootstrapProjectResult> {
|
body: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
teamId?: string;
|
||||||
|
repoUrl?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
return this.bootstrap.bootstrap({
|
return this.bootstrap.bootstrap({
|
||||||
name: dto.name,
|
name: body.name,
|
||||||
description: dto.description,
|
description: body.description,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: dto.teamId,
|
teamId: body.teamId,
|
||||||
repoUrl: dto.repoUrl,
|
repoUrl: body.repoUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { IsOptional, IsString, IsUrl, Matches, MaxLength } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateWorkspaceDto {
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(255)
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(10_000)
|
|
||||||
description?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
teamId?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@Matches(/^(?:https|git):\/\//i, {
|
|
||||||
message: 'repoUrl must be a valid https:// or git:// URL',
|
|
||||||
})
|
|
||||||
@IsUrl(
|
|
||||||
{
|
|
||||||
protocols: ['https', 'git'],
|
|
||||||
require_host: true,
|
|
||||||
require_protocol: true,
|
|
||||||
require_tld: false,
|
|
||||||
require_valid_protocol: true,
|
|
||||||
},
|
|
||||||
{ message: 'repoUrl must be a valid https:// or git:// URL' },
|
|
||||||
)
|
|
||||||
repoUrl?: string;
|
|
||||||
}
|
|
||||||
@@ -1,33 +1,11 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { WorkspaceService } from './workspace.service.js';
|
import { WorkspaceService } from './workspace.service.js';
|
||||||
|
import path from 'node:path';
|
||||||
type ExecFileMock = (
|
|
||||||
command: string,
|
|
||||||
args: readonly string[],
|
|
||||||
options: { cwd: string },
|
|
||||||
callback: (error: Error | null, stdout: string, stderr: string) => void,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
const { execFileMock } = vi.hoisted(() => ({
|
|
||||||
execFileMock: vi.fn<ExecFileMock>(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('node:child_process', () => ({
|
|
||||||
execFile: execFileMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('WorkspaceService', () => {
|
describe('WorkspaceService', () => {
|
||||||
let service: WorkspaceService;
|
let service: WorkspaceService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
execFileMock.mockReset();
|
|
||||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
|
||||||
callback(null, '', '');
|
|
||||||
});
|
|
||||||
service = new WorkspaceService();
|
service = new WorkspaceService();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,69 +76,4 @@ describe('WorkspaceService', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('create', () => {
|
|
||||||
const project = {
|
|
||||||
id: 'project-1',
|
|
||||||
ownerType: 'user',
|
|
||||||
userId: 'user-1',
|
|
||||||
teamId: null,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
let originalRoot: string | undefined;
|
|
||||||
let temporaryRoot: string;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
originalRoot = process.env['MOSAIC_ROOT'];
|
|
||||||
temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mosaic-workspace-'));
|
|
||||||
process.env['MOSAIC_ROOT'] = temporaryRoot;
|
|
||||||
service = new WorkspaceService();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
if (originalRoot === undefined) {
|
|
||||||
delete process.env['MOSAIC_ROOT'];
|
|
||||||
} else {
|
|
||||||
process.env['MOSAIC_ROOT'] = originalRoot;
|
|
||||||
}
|
|
||||||
await fs.rm(temporaryRoot, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['a leading-dash URL', '--upload-pack=sh -c id'],
|
|
||||||
['an ext remote helper', 'ext::sh -c id'],
|
|
||||||
['a file URL', 'file:///tmp/repository'],
|
|
||||||
['an unparseable value', 'not a url'],
|
|
||||||
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
|
||||||
['a scheme without //', 'https:example.com/acme/repository.git'],
|
|
||||||
['a hostless git URL', 'git:///tmp/repository'],
|
|
||||||
])('rejects %s before invoking git', async (_description, repoUrl) => {
|
|
||||||
await expect(service.create(project, repoUrl)).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
expect(execFileMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['an HTTPS URL', 'https://example.com/acme/repository.git'],
|
|
||||||
['a git protocol URL', 'git://example.com/acme/repository.git'],
|
|
||||||
])('accepts %s and invokes hardened git clone arguments', async (_description, repoUrl) => {
|
|
||||||
const workspacePath = await service.create(project, repoUrl);
|
|
||||||
|
|
||||||
expect(execFileMock).toHaveBeenCalledOnce();
|
|
||||||
expect(execFileMock).toHaveBeenCalledWith(
|
|
||||||
'git',
|
|
||||||
[
|
|
||||||
'-c',
|
|
||||||
'protocol.ext.allow=never',
|
|
||||||
'-c',
|
|
||||||
'protocol.file.allow=never',
|
|
||||||
'clone',
|
|
||||||
'--',
|
|
||||||
repoUrl,
|
|
||||||
'.',
|
|
||||||
],
|
|
||||||
{ cwd: workspacePath },
|
|
||||||
expect.any(Function),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,30 +1,10 @@
|
|||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const allowedRepositoryProtocols = new Set(['https:', 'git:']);
|
|
||||||
const repositoryUrlPrefixPattern = /^(?:https|git):\/\//i;
|
|
||||||
const repositoryUrlError = 'repoUrl must be a valid https:// or git:// URL';
|
|
||||||
|
|
||||||
function assertAllowedRepositoryUrl(repoUrl: string): void {
|
|
||||||
if (repoUrl.startsWith('-') || !repositoryUrlPrefixPattern.test(repoUrl)) {
|
|
||||||
throw new BadRequestException(repositoryUrlError);
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsedUrl: URL;
|
|
||||||
try {
|
|
||||||
parsedUrl = new URL(repoUrl);
|
|
||||||
} catch {
|
|
||||||
throw new BadRequestException(repositoryUrlError);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allowedRepositoryProtocols.has(parsedUrl.protocol) || parsedUrl.hostname.length === 0) {
|
|
||||||
throw new BadRequestException(repositoryUrlError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkspaceProject {
|
export interface WorkspaceProject {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -59,32 +39,14 @@ export class WorkspaceService {
|
|||||||
* If repoUrl is provided, clone instead of init.
|
* If repoUrl is provided, clone instead of init.
|
||||||
*/
|
*/
|
||||||
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
||||||
if (repoUrl !== undefined) {
|
|
||||||
assertAllowedRepositoryUrl(repoUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspacePath = this.resolvePath(project);
|
const workspacePath = this.resolvePath(project);
|
||||||
|
|
||||||
// Create directory
|
// Create directory
|
||||||
await fs.mkdir(workspacePath, { recursive: true });
|
await fs.mkdir(workspacePath, { recursive: true });
|
||||||
|
|
||||||
if (repoUrl !== undefined) {
|
if (repoUrl) {
|
||||||
// Clone existing repo. Defense in depth keeps dangerous local helpers
|
// Clone existing repo
|
||||||
// disabled and terminates option parsing before positional arguments.
|
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath });
|
||||||
await execFileAsync(
|
|
||||||
'git',
|
|
||||||
[
|
|
||||||
'-c',
|
|
||||||
'protocol.ext.allow=never',
|
|
||||||
'-c',
|
|
||||||
'protocol.file.allow=never',
|
|
||||||
'clone',
|
|
||||||
'--',
|
|
||||||
repoUrl,
|
|
||||||
'.',
|
|
||||||
],
|
|
||||||
{ cwd: workspacePath },
|
|
||||||
);
|
|
||||||
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
||||||
} else {
|
} else {
|
||||||
// Init new git repo
|
// Init new git repo
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Mosaic</title>
|
|
||||||
<meta name="description" content="Mosaic Stack Dashboard" />
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
|
|
||||||
/>
|
|
||||||
<script>
|
|
||||||
// set data-theme before first paint so the stored theme never flashes
|
|
||||||
(function () {
|
|
||||||
try {
|
|
||||||
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
|
|
||||||
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
|
|
||||||
} catch (error) {
|
|
||||||
document.documentElement.setAttribute('data-theme', 'dark');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -4,9 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node ../../scripts/build-web.mjs",
|
"build": "node ../../scripts/build-web.mjs",
|
||||||
"build:vite": "vite build",
|
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"dev:vite": "vite",
|
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
@@ -15,14 +13,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/design-tokens": "workspace:^",
|
"@mosaicstack/design-tokens": "workspace:^",
|
||||||
"@mosaicstack/types": "workspace:^",
|
|
||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.18.2",
|
|
||||||
"socket.io-client": "^4.8.0",
|
"socket.io-client": "^4.8.0",
|
||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
@@ -32,11 +28,9 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^6.0.5",
|
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vite": "^8.2.1",
|
"vitest": "^2.0.0"
|
||||||
"vitest": "^3.2.7"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,56 +3,41 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useSearchParams } from 'next/navigation';
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
|
||||||
import { signIn } from '@/lib/auth-client';
|
import { signIn } from '@/lib/auth-client';
|
||||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
import { getSsoProvider } from '@/lib/sso-providers';
|
||||||
|
|
||||||
export default function AuthProviderRedirectPage(): React.ReactElement {
|
export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||||
const params = useParams<{ provider: string }>();
|
const params = useParams<{ provider: string }>();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
||||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
const provider = getSsoProvider(providerId);
|
||||||
const [providerName, setProviderName] = useState<string | null>(null);
|
const callbackURL = searchParams.get('callbackURL') ?? '/chat';
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
const currentProvider = provider;
|
||||||
|
|
||||||
async function redirectToProvider(): Promise<void> {
|
if (!currentProvider) {
|
||||||
try {
|
|
||||||
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
|
||||||
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
|
||||||
if (!provider) {
|
|
||||||
setError('Unknown SSO provider.');
|
setError('Unknown SSO provider.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setProviderName(provider.name);
|
if (!currentProvider.enabled) {
|
||||||
if (!provider.configured) {
|
setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`);
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeProvider = currentProvider;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function redirectToProvider(): Promise<void> {
|
||||||
const result = await signIn.oauth2({
|
const result = await signIn.oauth2({
|
||||||
providerId: provider.id,
|
providerId: activeProvider.id,
|
||||||
callbackURL,
|
callbackURL,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!cancelled && result?.error) {
|
if (!cancelled && result?.error) {
|
||||||
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
|
||||||
}
|
|
||||||
} catch (caught: unknown) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,22 +46,19 @@ export default function AuthProviderRedirectPage(): React.ReactElement {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [providerId, requestedCallbackURL]);
|
}, [callbackURL, provider]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||||
<p className="mt-2 text-sm text-text-secondary">
|
<p className="mt-2 text-sm text-text-secondary">
|
||||||
{providerName
|
{provider
|
||||||
? `Redirecting you to ${providerName}...`
|
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...`
|
||||||
: 'Preparing your sign-in request...'}
|
: 'Preparing your sign-in request...'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div
|
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error">
|
||||||
role="alert"
|
|
||||||
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
|
||||||
>
|
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { api } from './api';
|
|
||||||
|
|
||||||
describe('api', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches the supplied relative path with credentials and a JSON body', async () => {
|
|
||||||
const fetchMock = vi.fn<typeof fetch>();
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ ok: true }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
api<{ ok: boolean }>('/api/projects', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { name: 'Mosaic' },
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({ ok: true });
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledOnce();
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
'/api/projects',
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ name: 'Mosaic' }),
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws the gateway JSON error with its statusCode', async () => {
|
|
||||||
const fetchMock = vi.fn<typeof fetch>();
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ statusCode: 403, message: 'Forbidden' }), {
|
|
||||||
status: 403,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
await expect(api('/api/admin/users')).rejects.toMatchObject({
|
|
||||||
name: 'Error',
|
|
||||||
message: 'Forbidden',
|
|
||||||
statusCode: 403,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
||||||
|
|
||||||
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
}
|
}
|
||||||
@@ -23,7 +25,7 @@ export async function api<T>(path: string, init?: ApiRequestInit): Promise<T> {
|
|||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(path, {
|
const res = await fetch(`${GATEWAY_URL}${path}`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
...rest,
|
...rest,
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
describe('auth client origin contract', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
vi.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the same-origin BetterAuth mount at /api/auth', async () => {
|
|
||||||
const fetchMock = vi.fn<typeof fetch>();
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ session: null, user: null }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
const { authClient } = await import('./auth-client');
|
|
||||||
await authClient.getSession();
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledOnce();
|
|
||||||
const firstCall = fetchMock.mock.calls.at(0);
|
|
||||||
expect(firstCall).toBeDefined();
|
|
||||||
const requestURL = new URL(String(firstCall?.[0]), window.location.origin);
|
|
||||||
expect(requestURL.origin).toBe(window.location.origin);
|
|
||||||
expect(requestURL.pathname).toBe('/api/auth/get-session');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { createAuthClient } from 'better-auth/react';
|
import { createAuthClient } from 'better-auth/react';
|
||||||
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
||||||
|
|
||||||
// The gateway and BetterAuth client both use /api/auth. Omitting baseURL keeps
|
|
||||||
// every browser request on the current origin in development and production.
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
|
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242',
|
||||||
plugins: [adminClient(), genericOAuthClient()],
|
plugins: [adminClient(), genericOAuthClient()],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { resolveAuthCallbackURL } from './auth-redirect';
|
|
||||||
|
|
||||||
const CURRENT_ORIGIN = 'https://mosaic.example';
|
|
||||||
|
|
||||||
describe('resolveAuthCallbackURL', () => {
|
|
||||||
it('preserves a canonical same-origin path with search and hash', () => {
|
|
||||||
expect(resolveAuthCallbackURL('/projects?view=active#current', CURRENT_ORIGIN)).toBe(
|
|
||||||
'/projects?view=active#current',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
null,
|
|
||||||
'chat',
|
|
||||||
'//evil.example',
|
|
||||||
'/..//evil.com',
|
|
||||||
'/..//evil.com/x',
|
|
||||||
'/./..//evil.com',
|
|
||||||
'/../..//evil.com',
|
|
||||||
'/foo/..//evil.com',
|
|
||||||
'/\\evil.example',
|
|
||||||
'/\n//evil.example',
|
|
||||||
'/\r//evil.example',
|
|
||||||
'/\t//evil.example',
|
|
||||||
'https://evil.example/phish',
|
|
||||||
])('falls back to chat for an unsafe callback target %#', (candidate) => {
|
|
||||||
expect(resolveAuthCallbackURL(candidate, CURRENT_ORIGIN)).toBe('/chat');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
const DEFAULT_AUTH_CALLBACK_URL = '/chat';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a canonical same-origin path for post-auth navigation.
|
|
||||||
*
|
|
||||||
* Parsing before comparing origins rejects protocol-relative URLs, backslash
|
|
||||||
* variants, and control characters that the WHATWG parser normalizes away.
|
|
||||||
*/
|
|
||||||
export function resolveAuthCallbackURL(candidate: string | null, currentOrigin: string): string {
|
|
||||||
if (!candidate?.startsWith('/')) return DEFAULT_AUTH_CALLBACK_URL;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const expectedOrigin = new URL(currentOrigin).origin;
|
|
||||||
const resolved = new URL(candidate, expectedOrigin);
|
|
||||||
if (resolved.origin !== expectedOrigin || resolved.pathname.startsWith('//')) {
|
|
||||||
return DEFAULT_AUTH_CALLBACK_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_AUTH_CALLBACK_URL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
// Centralizes the type-only import of the shared `/chat` Socket.IO contract from
|
|
||||||
// the public `@mosaicstack/types` package. `import type` is erased at compile
|
|
||||||
// time, so this introduces no runtime dependency — it only reuses the exact
|
|
||||||
// payload shapes instead of redeclaring them.
|
|
||||||
import type { Socket } from 'socket.io-client';
|
|
||||||
import type {
|
|
||||||
AbortPayload,
|
|
||||||
AgentEndPayload,
|
|
||||||
AgentStartPayload,
|
|
||||||
AgentTextPayload,
|
|
||||||
AgentThinkingPayload,
|
|
||||||
ChatMessagePayload,
|
|
||||||
ClientToServerEvents,
|
|
||||||
CommandDef,
|
|
||||||
CommandManifest,
|
|
||||||
CommandManifestPayload,
|
|
||||||
ErrorPayload,
|
|
||||||
MessageAckPayload,
|
|
||||||
RoutingDecisionInfo,
|
|
||||||
ServerToClientEvents,
|
|
||||||
SessionInfoPayload,
|
|
||||||
SessionUsagePayload,
|
|
||||||
SetThinkingPayload,
|
|
||||||
SkillCommandDef,
|
|
||||||
SlashCommandApprovalResultPayload,
|
|
||||||
SlashCommandPayload,
|
|
||||||
SlashCommandResultPayload,
|
|
||||||
SystemReloadPayload,
|
|
||||||
ToolEndPayload,
|
|
||||||
ToolStartPayload,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
AbortPayload,
|
|
||||||
AgentEndPayload,
|
|
||||||
AgentStartPayload,
|
|
||||||
AgentTextPayload,
|
|
||||||
AgentThinkingPayload,
|
|
||||||
ChatMessagePayload,
|
|
||||||
ClientToServerEvents,
|
|
||||||
CommandDef,
|
|
||||||
CommandManifest,
|
|
||||||
CommandManifestPayload,
|
|
||||||
ErrorPayload,
|
|
||||||
MessageAckPayload,
|
|
||||||
RoutingDecisionInfo,
|
|
||||||
ServerToClientEvents,
|
|
||||||
SessionInfoPayload,
|
|
||||||
SessionUsagePayload,
|
|
||||||
SetThinkingPayload,
|
|
||||||
SkillCommandDef,
|
|
||||||
SlashCommandApprovalResultPayload,
|
|
||||||
SlashCommandPayload,
|
|
||||||
SlashCommandResultPayload,
|
|
||||||
SystemReloadPayload,
|
|
||||||
ToolEndPayload,
|
|
||||||
ToolStartPayload,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The `/chat` namespace socket, narrowed to the exact typed event contract. */
|
|
||||||
export type ChatSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const { ioMock } = vi.hoisted(() => ({
|
|
||||||
ioMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('socket.io-client', () => ({
|
|
||||||
io: ioMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { destroySocket, getSocket } from './socket';
|
|
||||||
|
|
||||||
interface MockChatSocket {
|
|
||||||
on: ReturnType<typeof vi.fn>;
|
|
||||||
offAny: ReturnType<typeof vi.fn>;
|
|
||||||
disconnect: ReturnType<typeof vi.fn>;
|
|
||||||
/** Test-only helper: fires every handler registered for `event` via
|
|
||||||
* `.on`, mirroring how a real socket.io-client instance invokes its own
|
|
||||||
* listeners (e.g. calling the registered `disconnect` handler(s) on a
|
|
||||||
* real transient disconnect). */
|
|
||||||
trigger(event: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockSocket(): MockChatSocket {
|
|
||||||
const handlers = new Map<string, Set<() => void>>();
|
|
||||||
const mockSocket: MockChatSocket = {
|
|
||||||
on: vi.fn((event: string, handler: () => void) => {
|
|
||||||
if (!handlers.has(event)) handlers.set(event, new Set());
|
|
||||||
handlers.get(event)?.add(handler);
|
|
||||||
return mockSocket;
|
|
||||||
}),
|
|
||||||
offAny: vi.fn(() => mockSocket),
|
|
||||||
disconnect: vi.fn(() => mockSocket),
|
|
||||||
trigger(event: string): void {
|
|
||||||
for (const handler of handlers.get(event) ?? []) handler();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return mockSocket;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentMock!: MockChatSocket;
|
|
||||||
|
|
||||||
describe('chat socket', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
ioMock.mockReset();
|
|
||||||
// A fresh object per io() call so identity assertions (same singleton vs.
|
|
||||||
// a genuinely new instance) are meaningful.
|
|
||||||
ioMock.mockImplementation(() => {
|
|
||||||
currentMock = createMockSocket();
|
|
||||||
return currentMock;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
destroySocket();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates one same-origin /chat namespace socket', () => {
|
|
||||||
const first = getSocket();
|
|
||||||
const second = getSocket();
|
|
||||||
|
|
||||||
expect(first).toBe(second);
|
|
||||||
expect(ioMock).toHaveBeenCalledOnce();
|
|
||||||
expect(ioMock).toHaveBeenCalledWith('/chat', {
|
|
||||||
withCredentials: true,
|
|
||||||
autoConnect: false,
|
|
||||||
transports: ['websocket', 'polling'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the same singleton instance across a transient disconnect', () => {
|
|
||||||
const first = getSocket();
|
|
||||||
|
|
||||||
// socket.ts must not react to a real socket's `disconnect` event by
|
|
||||||
// nulling the singleton — it registers no such handler at all now.
|
|
||||||
// Actually fire every handler registered via `.on('disconnect', ...)`
|
|
||||||
// (mirroring a real socket.io-client reconnect) instead of merely
|
|
||||||
// calling getSocket() again: this is what makes the test fail if
|
|
||||||
// production reintroduces `socket.on('disconnect', () => { socket =
|
|
||||||
// null; })`, since that handler would run here and null the singleton
|
|
||||||
// before the next getSocket() call.
|
|
||||||
currentMock.trigger('disconnect');
|
|
||||||
const second = getSocket();
|
|
||||||
|
|
||||||
expect(second).toBe(first);
|
|
||||||
expect(ioMock).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only creates a new singleton after an explicit destroySocket()', () => {
|
|
||||||
const first = getSocket();
|
|
||||||
|
|
||||||
destroySocket();
|
|
||||||
const second = getSocket();
|
|
||||||
|
|
||||||
expect(second).not.toBe(first);
|
|
||||||
expect(ioMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+13
-17
@@ -1,27 +1,23 @@
|
|||||||
import { io } from 'socket.io-client';
|
import { io, type Socket } from 'socket.io-client';
|
||||||
import type { ChatSocket } from './chat-contract';
|
|
||||||
|
|
||||||
let socket: ChatSocket | null = null;
|
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
||||||
|
|
||||||
export function getSocket(): ChatSocket {
|
let socket: Socket | null = null;
|
||||||
|
|
||||||
|
export function getSocket(): Socket {
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
// socket.io-client 4.8.3's `io()` factory declaration always returns the
|
socket = io(`${GATEWAY_URL}/chat`, {
|
||||||
// default unparameterized Socket (it accepts no <ListenEvents, EmitEvents>
|
|
||||||
// generics), so this one cast is the unavoidable boundary between that and the
|
|
||||||
// typed `/chat` contract. Every other call site uses the resulting ChatSocket
|
|
||||||
// with no further assertions.
|
|
||||||
socket = io('/chat', {
|
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
}) as unknown as ChatSocket;
|
});
|
||||||
|
|
||||||
// A transient `disconnect` (network blip, server restart) must NOT null
|
// Reset singleton reference when socket is fully closed so the next
|
||||||
// the singleton: socket.io-client auto-reconnects this same instance,
|
// getSocket() call creates a fresh instance instead of returning a
|
||||||
// and its listeners stay registered across that reconnect. Nulling here
|
// closed/dead socket.
|
||||||
// previously orphaned those listeners on the next getSocket() call by
|
socket.on('disconnect', () => {
|
||||||
// handing back a brand-new, unconnected instance. Only destroySocket()
|
socket = null;
|
||||||
// (an explicit, intentional teardown) may reset the singleton.
|
});
|
||||||
}
|
}
|
||||||
return socket;
|
return socket;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getEnabledSsoProviders, getSsoProvider } from './sso-providers';
|
||||||
|
|
||||||
|
describe('sso-providers', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the enabled providers in login button order', () => {
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true');
|
||||||
|
|
||||||
|
expect(getEnabledSsoProviders()).toEqual([
|
||||||
|
{
|
||||||
|
id: 'workos',
|
||||||
|
buttonLabel: 'Continue with WorkOS',
|
||||||
|
description: 'Enterprise SSO via WorkOS',
|
||||||
|
enabled: true,
|
||||||
|
href: '/auth/provider/workos',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'keycloak',
|
||||||
|
buttonLabel: 'Continue with Keycloak',
|
||||||
|
description: 'Enterprise SSO via Keycloak',
|
||||||
|
enabled: true,
|
||||||
|
href: '/auth/provider/keycloak',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks disabled providers without exposing them in the enabled list', () => {
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false');
|
||||||
|
|
||||||
|
expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']);
|
||||||
|
expect(getSsoProvider('keycloak')).toEqual({
|
||||||
|
id: 'keycloak',
|
||||||
|
buttonLabel: 'Continue with Keycloak',
|
||||||
|
description: 'Enterprise SSO via Keycloak',
|
||||||
|
enabled: false,
|
||||||
|
href: '/auth/provider/keycloak',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown providers', () => {
|
||||||
|
expect(getSsoProvider('authentik')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export type SsoProviderId = 'workos' | 'keycloak';
|
||||||
|
|
||||||
|
export interface SsoProvider {
|
||||||
|
id: SsoProviderId;
|
||||||
|
buttonLabel: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDER_METADATA: Record<SsoProviderId, Omit<SsoProvider, 'enabled' | 'href'>> = {
|
||||||
|
workos: {
|
||||||
|
id: 'workos',
|
||||||
|
buttonLabel: 'Continue with WorkOS',
|
||||||
|
description: 'Enterprise SSO via WorkOS',
|
||||||
|
},
|
||||||
|
keycloak: {
|
||||||
|
id: 'keycloak',
|
||||||
|
buttonLabel: 'Continue with Keycloak',
|
||||||
|
description: 'Enterprise SSO via Keycloak',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getEnabledSsoProviders(): SsoProvider[] {
|
||||||
|
return (Object.keys(PROVIDER_METADATA) as SsoProviderId[])
|
||||||
|
.map((providerId) => getSsoProvider(providerId))
|
||||||
|
.filter((provider): provider is SsoProvider => provider?.enabled === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSsoProvider(providerId: string): SsoProvider | null {
|
||||||
|
if (!isSsoProviderId(providerId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...PROVIDER_METADATA[providerId],
|
||||||
|
enabled: isSsoProviderEnabled(providerId),
|
||||||
|
href: `/auth/provider/${providerId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSsoProviderId(value: string): value is SsoProviderId {
|
||||||
|
return value === 'workos' || value === 'keycloak';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSsoProviderEnabled(providerId: SsoProviderId): boolean {
|
||||||
|
switch (providerId) {
|
||||||
|
case 'workos':
|
||||||
|
return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
|
||||||
|
case 'keycloak':
|
||||||
|
return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { StrictMode } from 'react';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import { RouterProvider } from 'react-router-dom';
|
|
||||||
import { ThemeProvider } from '@/providers/theme-provider';
|
|
||||||
import { createAppRouter } from '@/routes';
|
|
||||||
import '@/app/globals.css';
|
|
||||||
|
|
||||||
const container = document.getElementById('root');
|
|
||||||
if (!container) {
|
|
||||||
throw new Error('missing #root element');
|
|
||||||
}
|
|
||||||
|
|
||||||
createRoot(container).render(
|
|
||||||
<StrictMode>
|
|
||||||
<ThemeProvider>
|
|
||||||
<RouterProvider router={createAppRouter()} />
|
|
||||||
</ThemeProvider>
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-router-dom';
|
|
||||||
import { LoginPage } from '@/spa/pages/login';
|
|
||||||
import { RegisterPage } from '@/spa/pages/register';
|
|
||||||
import { SsoCallbackPage } from '@/spa/pages/sso-callback';
|
|
||||||
import { ChatPage } from '@/spa/pages/chat';
|
|
||||||
import { ChatRouteErrorBoundary } from '@/spa/pages/chat-error-boundary';
|
|
||||||
import { 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: <Placeholder title="Projects" /> },
|
|
||||||
{ path: '/projects/:id', element: <Placeholder title="Project" /> },
|
|
||||||
{ path: '/tasks', element: <Placeholder title="Tasks" /> },
|
|
||||||
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
|
||||||
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function createAppRouter(): ReturnType<typeof createBrowserRouter> {
|
|
||||||
return createBrowserRouter(routes);
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { CommandsPanel } from './commands-panel';
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
let root: Root | null;
|
|
||||||
let container: HTMLElement | null;
|
|
||||||
|
|
||||||
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
|
||||||
container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(node);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
document.body.replaceChildren();
|
|
||||||
root = null;
|
|
||||||
container = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CommandsPanel', () => {
|
|
||||||
it('shows the frozen local pendingApproval args in the confirmation area, regardless of misleading server message text', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[]}
|
|
||||||
approval={{
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'deploy', // matches pendingApproval — this is a legitimately approved request
|
|
||||||
success: true,
|
|
||||||
approvalId: 'ap1',
|
|
||||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
// Free-text server message claims a different, less alarming target
|
|
||||||
// than what will actually be sent — the UI must not rely on this.
|
|
||||||
message: 'This will only affect the staging environment.',
|
|
||||||
}}
|
|
||||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
|
||||||
hasConversation
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The exact frozen combined action is visible...
|
|
||||||
expect(container?.textContent).toContain('/deploy');
|
|
||||||
expect(container?.textContent).toContain('prod');
|
|
||||||
// ...and the misleading server free-text is never shown next to it.
|
|
||||||
expect(container?.textContent).not.toContain('staging environment');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not throw when a manifest commands entry is null', async () => {
|
|
||||||
const manifest = {
|
|
||||||
commands: [
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
name: 'model',
|
|
||||||
aliases: [],
|
|
||||||
description: 'Change the active model',
|
|
||||||
scope: 'core',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
skills: [null],
|
|
||||||
version: 1,
|
|
||||||
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={manifest}
|
|
||||||
results={[]}
|
|
||||||
approval={null}
|
|
||||||
pendingApproval={null}
|
|
||||||
hasConversation={false}
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
),
|
|
||||||
).resolves.not.toThrow();
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain('model');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows an explicit no-args fallback when the frozen pendingApproval has no args', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[]}
|
|
||||||
approval={{
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'deploy',
|
|
||||||
success: true,
|
|
||||||
approvalId: 'ap1',
|
|
||||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
}}
|
|
||||||
pendingApproval={{ command: 'deploy' }}
|
|
||||||
hasConversation
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container?.textContent?.toLowerCase()).toContain('no args');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders skills from a skills-only manifest', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={{
|
|
||||||
commands: [],
|
|
||||||
skills: [{ name: 'brave-search', description: 'Search the web', available: true }],
|
|
||||||
version: 1,
|
|
||||||
}}
|
|
||||||
results={[]}
|
|
||||||
approval={null}
|
|
||||||
pendingApproval={null}
|
|
||||||
hasConversation={false}
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain('brave-search');
|
|
||||||
expect(container?.textContent).toContain('Search the web');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not show the Run affordance when approval.success/approvalId are objects, even though command matches pendingApproval', async () => {
|
|
||||||
const approval = {
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'deploy',
|
|
||||||
success: { truthy: 'object' },
|
|
||||||
approvalId: { also: 'object' },
|
|
||||||
} as unknown as Parameters<typeof CommandsPanel>[0]['approval'];
|
|
||||||
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[]}
|
|
||||||
approval={approval}
|
|
||||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
|
||||||
hasConversation
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
[...(container?.querySelectorAll('button') ?? [])].some((button) =>
|
|
||||||
button.textContent?.includes('Run approved command'),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the guarded server-provided denial reason for a denied approval', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[]}
|
|
||||||
approval={{
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'deploy',
|
|
||||||
success: false,
|
|
||||||
message: 'Not authorized',
|
|
||||||
}}
|
|
||||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
|
||||||
hasConversation
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain('Not authorized');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to a stable "Denied." copy when a denial has no usable message', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[]}
|
|
||||||
approval={{ conversationId: 'c1', command: 'deploy', success: false }}
|
|
||||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
|
||||||
hasConversation
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain('Denied.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the guarded contract-provided reason for a failed command result, falling back to a stable copy only when absent', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[
|
|
||||||
{ conversationId: 'c1', command: 'model', success: false, message: 'Unknown model' },
|
|
||||||
{ conversationId: 'c1', command: 'deploy', success: false },
|
|
||||||
]}
|
|
||||||
approval={null}
|
|
||||||
pendingApproval={null}
|
|
||||||
hasConversation={false}
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain('Unknown model');
|
|
||||||
expect(container?.textContent).toContain('Command failed.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bounds an oversized command result message at the render site as defense-in-depth', async () => {
|
|
||||||
const hostileMessage = 'y'.repeat(50_000);
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[
|
|
||||||
{ conversationId: 'c1', command: 'model', success: false, message: hostileMessage },
|
|
||||||
]}
|
|
||||||
approval={null}
|
|
||||||
pendingApproval={null}
|
|
||||||
hasConversation={false}
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const text = container?.textContent ?? '';
|
|
||||||
expect(text.length).toBeLessThan(hostileMessage.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not throw when the manifest fields are malformed (non-array commands/skills)', async () => {
|
|
||||||
const manifest = {
|
|
||||||
commands: 'not-an-array',
|
|
||||||
skills: null,
|
|
||||||
version: 1,
|
|
||||||
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={manifest}
|
|
||||||
results={[]}
|
|
||||||
approval={null}
|
|
||||||
pendingApproval={null}
|
|
||||||
hasConversation={false}
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
),
|
|
||||||
).resolves.not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import { useState, type ReactElement } from 'react';
|
|
||||||
import type { PendingApproval } from './use-chat-connection';
|
|
||||||
import { MAX_COMMAND_MESSAGE_CHARS } from './limits';
|
|
||||||
import { asNonEmptyString, asString } from './runtime-guards';
|
|
||||||
import type {
|
|
||||||
CommandManifest,
|
|
||||||
SlashCommandApprovalResultPayload,
|
|
||||||
SlashCommandResultPayload,
|
|
||||||
} from '@/lib/chat-contract';
|
|
||||||
|
|
||||||
/** Stable fallback copy shown for a failed command only when the server's
|
|
||||||
* own guarded, non-empty `message` (e.g. "Unknown model") is absent or
|
|
||||||
* malformed — the structured contract reason itself is otherwise shown
|
|
||||||
* directly, never a raw thrown exception, stack trace, or object value. */
|
|
||||||
const COMMAND_FAILURE_COPY = 'Command failed.';
|
|
||||||
|
|
||||||
/** Render-site defense-in-depth: `use-chat-connection.ts` already bounds a
|
|
||||||
* stored command:result message at ingestion, but this component must never
|
|
||||||
* assume every caller went through that path — bounding again here means a
|
|
||||||
* hostile/oversized message can never force an unbounded render. */
|
|
||||||
function boundMessage(value: string): string {
|
|
||||||
return value.length > MAX_COMMAND_MESSAGE_CHARS
|
|
||||||
? value.slice(0, MAX_COMMAND_MESSAGE_CHARS)
|
|
||||||
: value;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommandsPanelProps {
|
|
||||||
manifest: CommandManifest | null;
|
|
||||||
results: SlashCommandResultPayload[];
|
|
||||||
approval: SlashCommandApprovalResultPayload | null;
|
|
||||||
pendingApproval: PendingApproval | null;
|
|
||||||
hasConversation: boolean;
|
|
||||||
onExecute: (input: { command: string; args?: string }) => void;
|
|
||||||
onApprove: (input: { command: string; args?: string }) => void;
|
|
||||||
onRunApproved: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommandsPanel({
|
|
||||||
manifest,
|
|
||||||
results,
|
|
||||||
approval,
|
|
||||||
pendingApproval,
|
|
||||||
hasConversation,
|
|
||||||
onExecute,
|
|
||||||
onApprove,
|
|
||||||
onRunApproved,
|
|
||||||
}: CommandsPanelProps): ReactElement {
|
|
||||||
const [command, setCommand] = useState('');
|
|
||||||
const [args, setArgs] = useState('');
|
|
||||||
|
|
||||||
// Defense-in-depth: the reducer already normalizes success/approvalId
|
|
||||||
// before storing `approval`, but a matching command string alone must
|
|
||||||
// never be trusted here either — require the literal boolean `true` and a
|
|
||||||
// non-empty string approvalId, not merely truthy values.
|
|
||||||
const canRunApproved =
|
|
||||||
approval?.success === true &&
|
|
||||||
typeof approval.approvalId === 'string' &&
|
|
||||||
approval.approvalId.length > 0 &&
|
|
||||||
!!pendingApproval &&
|
|
||||||
pendingApproval.command === approval.command;
|
|
||||||
|
|
||||||
// A manifest arrives from the server as untyped JSON at runtime — guard
|
|
||||||
// both collections before mapping so a malformed manifest cannot throw.
|
|
||||||
const commands = Array.isArray(manifest?.commands) ? manifest.commands : [];
|
|
||||||
const skills = Array.isArray(manifest?.skills) ? manifest.skills : [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section aria-label="Commands" className="flex flex-col gap-2 border-b px-4 py-3 text-xs">
|
|
||||||
{commands.length > 0 ? (
|
|
||||||
<ul aria-label="Available commands" className="flex flex-col gap-1">
|
|
||||||
{commands.map((cmd, index) => (
|
|
||||||
<li key={asString(cmd?.name) || `cmd-${index}`}>
|
|
||||||
<strong>/{asString(cmd?.name)}</strong> — {asString(cmd?.description)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{skills.length > 0 ? (
|
|
||||||
<ul aria-label="Available skills" className="flex flex-col gap-1">
|
|
||||||
{skills.map((skill, index) => (
|
|
||||||
<li key={asString(skill?.name) || `skill-${index}`}>
|
|
||||||
<strong>/skill:{asString(skill?.name)}</strong> — {asString(skill?.description)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<input
|
|
||||||
aria-label="Command name"
|
|
||||||
value={command}
|
|
||||||
onChange={(event) => setCommand(event.target.value)}
|
|
||||||
placeholder="command"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
aria-label="Command arguments"
|
|
||||||
value={args}
|
|
||||||
onChange={(event) => setArgs(event.target.value)}
|
|
||||||
placeholder="args (optional)"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!hasConversation || !command.trim()}
|
|
||||||
onClick={() => onExecute({ command: command.trim(), args: args.trim() || undefined })}
|
|
||||||
>
|
|
||||||
Run command
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!hasConversation || !command.trim()}
|
|
||||||
onClick={() => onApprove({ command: command.trim(), args: args.trim() || undefined })}
|
|
||||||
>
|
|
||||||
Request approval
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{approval ? (
|
|
||||||
<div role={approval.success ? 'status' : 'alert'} className="flex items-center gap-2">
|
|
||||||
{/* A successful approval shows stable client copy only — never
|
|
||||||
the server-controlled approval.message or echoed
|
|
||||||
approval.command as the primary confirmation. The frozen local
|
|
||||||
pendingApproval below (not this line) is the sole authoritative
|
|
||||||
statement of what will run. A denial, by contrast, is not an
|
|
||||||
execution authority and safely surfaces the guarded structured
|
|
||||||
reason the server gave (e.g. "Not authorized"), falling back to
|
|
||||||
a stable copy only when absent/malformed. */}
|
|
||||||
<span>
|
|
||||||
{approval.success ? 'Approved.' : asNonEmptyString(approval.message, 'Denied.')}
|
|
||||||
</span>
|
|
||||||
{canRunApproved && pendingApproval ? (
|
|
||||||
<>
|
|
||||||
{/* Authoritative frozen local command+args — what the click below
|
|
||||||
will actually emit. The server's `approval` above is display-only
|
|
||||||
and must never be trusted to represent the executed payload. */}
|
|
||||||
<span>
|
|
||||||
Will run: /{pendingApproval.command}{' '}
|
|
||||||
{pendingApproval.args ? pendingApproval.args : '(no args)'}
|
|
||||||
</span>
|
|
||||||
<button type="button" onClick={onRunApproved}>
|
|
||||||
Run approved command
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{results.length > 0 ? (
|
|
||||||
<ul aria-label="Command results" className="flex flex-col gap-1">
|
|
||||||
{results.map((result, index) => (
|
|
||||||
<li key={`${result.command}-${index}`} role={result.success ? 'status' : 'alert'}>
|
|
||||||
/{asString(result.command)}: {result.success ? 'success' : 'failed'}
|
|
||||||
{result.success
|
|
||||||
? typeof result.message === 'string' && result.message
|
|
||||||
? ` — ${boundMessage(result.message)}`
|
|
||||||
: ''
|
|
||||||
: ` — ${boundMessage(asNonEmptyString(result.message, COMMAND_FAILURE_COPY))}`}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
|
||||||
|
|
||||||
interface ComposerProps {
|
|
||||||
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
|
|
||||||
onStop: () => void;
|
|
||||||
streaming: boolean;
|
|
||||||
/** True from local send time through server turn startup/ack and
|
|
||||||
* throughout streaming — a superset of `streaming` that also covers the
|
|
||||||
* pre-ack window where a second send could otherwise slip through. */
|
|
||||||
sending: boolean;
|
|
||||||
hasConversation: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Composer({
|
|
||||||
onSend,
|
|
||||||
onStop,
|
|
||||||
streaming,
|
|
||||||
sending,
|
|
||||||
hasConversation,
|
|
||||||
}: ComposerProps): ReactElement {
|
|
||||||
const [content, setContent] = useState('');
|
|
||||||
const [provider, setProvider] = useState('');
|
|
||||||
const [modelId, setModelId] = useState('');
|
|
||||||
const busy = streaming || sending;
|
|
||||||
|
|
||||||
function submit(): void {
|
|
||||||
if (busy) return;
|
|
||||||
const trimmed = content.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
onSend({
|
|
||||||
content: trimmed,
|
|
||||||
provider: provider.trim() || undefined,
|
|
||||||
modelId: modelId.trim() || undefined,
|
|
||||||
});
|
|
||||||
setContent('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
|
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
submit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
submit();
|
|
||||||
}}
|
|
||||||
className="flex flex-col gap-2 border-t p-4"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<input
|
|
||||||
aria-label="Provider"
|
|
||||||
value={provider}
|
|
||||||
onChange={(event) => setProvider(event.target.value)}
|
|
||||||
placeholder="Provider (optional)"
|
|
||||||
className="rounded border px-2 py-1 text-xs"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
aria-label="Model"
|
|
||||||
value={modelId}
|
|
||||||
onChange={(event) => setModelId(event.target.value)}
|
|
||||||
placeholder="Model (optional)"
|
|
||||||
className="rounded border px-2 py-1 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<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}
|
|
||||||
className="rounded px-3 py-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Stop"
|
|
||||||
disabled={!hasConversation || !streaming}
|
|
||||||
onClick={onStop}
|
|
||||||
className="rounded px-3 py-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Bounds on server-fed chat state. A hostile or malfunctioning gateway can
|
|
||||||
* flood any of these collections; caps keep memory/render cost flat instead
|
|
||||||
* of growing unboundedly for the lifetime of the connection.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Max characters retained for the in-flight streamed text/thinking buffers. */
|
|
||||||
export const MAX_STREAM_CHARS = 20_000;
|
|
||||||
/** Max transcript turns retained (oldest dropped first). */
|
|
||||||
export const MAX_MESSAGES = 500;
|
|
||||||
/** Max tool-call entries (including anomaly entries) retained per turn history. */
|
|
||||||
export const MAX_TOOLS = 200;
|
|
||||||
/** Max slash-command results retained. */
|
|
||||||
export const MAX_COMMAND_RESULTS = 200;
|
|
||||||
/** Max commands/skills accepted from a single manifest push. */
|
|
||||||
export const MAX_MANIFEST_ITEMS = 500;
|
|
||||||
/** Max executed approval IDs remembered for single-flight dedup. */
|
|
||||||
export const MAX_EXECUTED_APPROVAL_IDS = 200;
|
|
||||||
/** Max characters retained for a single command:result message — a hostile
|
|
||||||
* or malfunctioning gateway must not be able to push an unbounded curated
|
|
||||||
* success/failure reason into state (or, defensively, onto the page). */
|
|
||||||
export const MAX_COMMAND_MESSAGE_CHARS = 1_000;
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import type { ChatTranscriptMessage } from './use-chat-connection';
|
|
||||||
|
|
||||||
interface MessageTranscriptProps {
|
|
||||||
messages: ChatTranscriptMessage[];
|
|
||||||
streaming: boolean;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MessageTranscript({
|
|
||||||
messages,
|
|
||||||
streaming,
|
|
||||||
text,
|
|
||||||
}: MessageTranscriptProps): ReactElement {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="log"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-label="Conversation"
|
|
||||||
className="flex flex-1 flex-col gap-3 overflow-y-auto p-4"
|
|
||||||
>
|
|
||||||
{messages.map((message) => (
|
|
||||||
<div key={message.id} data-role={message.role} className="whitespace-pre-wrap text-sm">
|
|
||||||
<span className="font-medium">{message.role === 'user' ? 'You' : 'Assistant'}: </span>
|
|
||||||
<span>{message.text}</span>
|
|
||||||
{message.thinking ? (
|
|
||||||
<div className="pt-1 text-xs italic opacity-70">{message.thinking}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{streaming ? (
|
|
||||||
<div data-role="assistant-streaming" className="whitespace-pre-wrap text-sm">
|
|
||||||
<span className="font-medium">Assistant: </span>
|
|
||||||
<span>{text || 'Thinking…'}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
/**
|
|
||||||
* Socket.IO payloads are only statically typed at the call site — a
|
|
||||||
* misbehaving or compromised gateway can send anything at runtime. These
|
|
||||||
* guards protect the dereference sites that would otherwise throw (`.map` on
|
|
||||||
* a non-array, `.toFixed` on a non-number) or render an object as a React
|
|
||||||
* child.
|
|
||||||
*/
|
|
||||||
|
|
||||||
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 single point of truth for what counts as a valid conversation ID
|
|
||||||
* anywhere a scoped server event may adopt one into state — a non-empty
|
|
||||||
* string, nothing else. Every site that establishes or compares
|
|
||||||
* `state.conversationId` against a raw socket payload must route through
|
|
||||||
* this guard so a malformed first frame (null/object/number/empty string)
|
|
||||||
* can never be adopted verbatim. */
|
|
||||||
export function asConversationId(value: unknown): string | null {
|
|
||||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import type { SessionInfoPayload } from '@/lib/chat-contract';
|
|
||||||
import { MAX_MANIFEST_ITEMS } from './limits';
|
|
||||||
import { asString, asStringArray } from './runtime-guards';
|
|
||||||
|
|
||||||
interface SessionPanelProps {
|
|
||||||
sessionInfo: SessionInfoPayload | null;
|
|
||||||
onSetThinking: (level: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const THINKING_LEVEL_UNAVAILABLE = '';
|
|
||||||
|
|
||||||
export function SessionPanel({
|
|
||||||
sessionInfo,
|
|
||||||
onSetThinking,
|
|
||||||
}: SessionPanelProps): ReactElement | null {
|
|
||||||
if (!sessionInfo) return null;
|
|
||||||
|
|
||||||
// The reducer already caps this before storing it, but the render site
|
|
||||||
// defends independently — a hostile payload must never be able to force
|
|
||||||
// this <select> to lay out an unbounded number of options.
|
|
||||||
const availableThinkingLevels = asStringArray(sessionInfo.availableThinkingLevels).slice(
|
|
||||||
0,
|
|
||||||
MAX_MANIFEST_ITEMS,
|
|
||||||
);
|
|
||||||
const hasThinkingLevels = availableThinkingLevels.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
aria-label="Session info"
|
|
||||||
className="flex flex-wrap items-center gap-3 border-b px-4 py-2 text-xs"
|
|
||||||
>
|
|
||||||
<span>{asString(sessionInfo.provider, 'unknown')}</span>
|
|
||||||
<span>{asString(sessionInfo.modelId, 'unknown')}</span>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<span>Thinking level</span>
|
|
||||||
<select
|
|
||||||
aria-label="Thinking level"
|
|
||||||
value={
|
|
||||||
hasThinkingLevels ? asString(sessionInfo.thinkingLevel) : THINKING_LEVEL_UNAVAILABLE
|
|
||||||
}
|
|
||||||
onChange={(event) => {
|
|
||||||
// The placeholder option is not a real, settable level — a
|
|
||||||
// malformed availableThinkingLevels list must never let the
|
|
||||||
// client emit set:thinking for it.
|
|
||||||
if (!hasThinkingLevels) return;
|
|
||||||
onSetThinking(event.target.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hasThinkingLevels ? (
|
|
||||||
availableThinkingLevels.map((level) => (
|
|
||||||
<option key={level} value={level}>
|
|
||||||
{level}
|
|
||||||
</option>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<option value={THINKING_LEVEL_UNAVAILABLE}>Thinking level unavailable</option>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{sessionInfo.routingDecision ? (
|
|
||||||
<span title={asString(sessionInfo.routingDecision.ruleName)}>
|
|
||||||
{asString(sessionInfo.routingDecision.reason)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { vi } from 'vitest';
|
|
||||||
import type { ClientToServerEvents, ServerToClientEvents } from '@/lib/chat-contract';
|
|
||||||
|
|
||||||
type ServerEvent = keyof ServerToClientEvents;
|
|
||||||
type ClientEvent = keyof ClientToServerEvents;
|
|
||||||
type ServerHandler<K extends ServerEvent> = ServerToClientEvents[K];
|
|
||||||
type ClientPayload<K extends ClientEvent> = Parameters<ClientToServerEvents[K]>[0];
|
|
||||||
|
|
||||||
export interface EmittedEvent<K extends ClientEvent = ClientEvent> {
|
|
||||||
event: K;
|
|
||||||
payload: ClientPayload<K>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */
|
|
||||||
export interface FakeChatSocket {
|
|
||||||
connected: boolean;
|
|
||||||
connect(): FakeChatSocket;
|
|
||||||
on<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
|
||||||
off<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
|
||||||
emit<K extends ClientEvent>(event: K, payload: ClientPayload<K>): FakeChatSocket;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A typed in-memory stand-in for `getSocket()`. Unlike a bare
|
|
||||||
* `(event: string, payload: unknown) => void` mock, every public method here is
|
|
||||||
* checked against the real `/chat` contract — a typo'd event name or a payload
|
|
||||||
* missing a required field fails to compile instead of silently no-op'ing at
|
|
||||||
* runtime.
|
|
||||||
*/
|
|
||||||
/** Socket.IO's built-in connection-state events. Not part of the app-level
|
|
||||||
* ServerToClientEvents contract, but real sockets always support them and
|
|
||||||
* `useChatConnection` registers a `disconnect` handler on the real socket. */
|
|
||||||
type LifecycleEvent = 'connect' | 'disconnect';
|
|
||||||
|
|
||||||
export function createFakeChatSocket(): {
|
|
||||||
socket: FakeChatSocket;
|
|
||||||
listeners: Map<ServerEvent, Set<(payload: never) => void>>;
|
|
||||||
emitted: EmittedEvent[];
|
|
||||||
serverEmit<K extends ServerEvent>(
|
|
||||||
event: K,
|
|
||||||
payload: Parameters<ServerToClientEvents[K]>[0],
|
|
||||||
): void;
|
|
||||||
/** Escape hatch for malformed-payload tests: bypasses the compile-time
|
|
||||||
* payload contract to simulate a genuinely untrusted runtime value from the
|
|
||||||
* server, e.g. a `session:info` with a non-array `availableThinkingLevels`. */
|
|
||||||
serverEmitRaw(event: ServerEvent, payload: unknown): void;
|
|
||||||
/** Simulates a transient Socket.IO `disconnect` — fires any handler(s)
|
|
||||||
* registered via `socket.on('disconnect', ...)` without clearing any
|
|
||||||
* listeners, mirroring how a real reconnecting socket behaves. */
|
|
||||||
simulateDisconnect(): void;
|
|
||||||
/** Simulates socket.io-client's automatic reconnect of the *same*
|
|
||||||
* instance after a transient disconnect: marks the socket connected again
|
|
||||||
* and fires any handler(s) registered via `socket.on('connect', ...)`,
|
|
||||||
* without clearing or replacing any listeners. */
|
|
||||||
simulateReconnect(): void;
|
|
||||||
} {
|
|
||||||
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
|
|
||||||
const emitted: EmittedEvent[] = [];
|
|
||||||
|
|
||||||
// Internal storage is intentionally keyed loosely (the per-event handler shape
|
|
||||||
// varies by K, which a single Map can't express); the generic signatures on the
|
|
||||||
// exported `socket`/`serverEmit` above and below are what keep test call sites
|
|
||||||
// type-checked against ServerToClientEvents/ClientToServerEvents.
|
|
||||||
const socket = {
|
|
||||||
connected: false,
|
|
||||||
connect: vi.fn(function connect(this: void) {
|
|
||||||
socket.connected = true;
|
|
||||||
return socket;
|
|
||||||
}),
|
|
||||||
on: vi.fn(function on(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
|
||||||
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
||||||
listeners.get(event)?.add(handler);
|
|
||||||
return socket;
|
|
||||||
}),
|
|
||||||
off: vi.fn(function off(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
|
||||||
listeners.get(event)?.delete(handler);
|
|
||||||
return socket;
|
|
||||||
}),
|
|
||||||
emit: vi.fn(function emit(this: void, event: ClientEvent, payload: unknown) {
|
|
||||||
emitted.push({ event, payload } as EmittedEvent);
|
|
||||||
return socket;
|
|
||||||
}),
|
|
||||||
} as unknown as FakeChatSocket;
|
|
||||||
|
|
||||||
function serverEmit<K extends ServerEvent>(
|
|
||||||
event: K,
|
|
||||||
payload: Parameters<ServerToClientEvents[K]>[0],
|
|
||||||
): void {
|
|
||||||
for (const handler of listeners.get(event) ?? []) {
|
|
||||||
(handler as (payload: Parameters<ServerToClientEvents[K]>[0]) => void)(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function serverEmitRaw(event: ServerEvent, payload: unknown): void {
|
|
||||||
for (const handler of listeners.get(event) ?? []) {
|
|
||||||
(handler as (payload: unknown) => void)(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function simulateDisconnect(): void {
|
|
||||||
socket.connected = false;
|
|
||||||
const lifecycleKey = 'disconnect' satisfies LifecycleEvent as unknown as ServerEvent;
|
|
||||||
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
|
||||||
(handler as () => void)();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function simulateReconnect(): void {
|
|
||||||
socket.connected = true;
|
|
||||||
const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent;
|
|
||||||
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
|
||||||
(handler as () => void)();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
socket,
|
|
||||||
listeners,
|
|
||||||
emitted,
|
|
||||||
serverEmit,
|
|
||||||
serverEmitRaw,
|
|
||||||
simulateDisconnect,
|
|
||||||
simulateReconnect,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { ToolCallList } from './tool-call-list';
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
let root: Root | null;
|
|
||||||
let container: HTMLElement | null;
|
|
||||||
|
|
||||||
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
|
||||||
container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(node);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
document.body.replaceChildren();
|
|
||||||
root = null;
|
|
||||||
container = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ToolCallList', () => {
|
|
||||||
it('renders two entries independently, without a duplicate-key warning, when a valid toolCallId is shared', async () => {
|
|
||||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
|
|
||||||
await render(
|
|
||||||
<ToolCallList
|
|
||||||
tools={[
|
|
||||||
{ toolCallId: 'dup', toolName: 'search', status: 'success' },
|
|
||||||
{ toolCallId: 'dup', toolName: 'search', status: 'running' },
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const items = [...(container?.querySelectorAll('li') ?? [])];
|
|
||||||
expect(items).toHaveLength(2);
|
|
||||||
expect(items[0]?.textContent).toContain('success');
|
|
||||||
expect(items[1]?.textContent).toContain('running');
|
|
||||||
|
|
||||||
const duplicateKeyWarning = consoleError.mock.calls.some((args) =>
|
|
||||||
args.some((arg) => typeof arg === 'string' && arg.includes('same key')),
|
|
||||||
);
|
|
||||||
expect(duplicateKeyWarning).toBe(false);
|
|
||||||
|
|
||||||
consoleError.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import type { ToolCallState } from './use-chat-connection';
|
|
||||||
|
|
||||||
export function ToolCallList({ tools }: { tools: ToolCallState[] }): ReactElement | null {
|
|
||||||
if (tools.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul aria-label="Tool calls" className="flex flex-col gap-1 px-4 pb-2 text-xs">
|
|
||||||
{tools.map((tool, index) => (
|
|
||||||
<li
|
|
||||||
// A valid server-controlled toolCallId can legitimately repeat
|
|
||||||
// (e.g. two tool:start events sharing one id) — keying on it alone
|
|
||||||
// would give React two identical keys. Pairing it with its
|
|
||||||
// (stable, append-only) render index keeps every key unique.
|
|
||||||
key={`${tool.toolCallId}-${index}`}
|
|
||||||
role={tool.status === 'error' || tool.status === 'anomaly' ? 'alert' : 'status'}
|
|
||||||
>
|
|
||||||
{tool.toolName} —{' '}
|
|
||||||
{tool.status === 'anomaly' ? 'unexpected end (unknown tool call)' : tool.status}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,135 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const { useSessionMock } = vi.hoisted(() => ({
|
|
||||||
useSessionMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth-client', () => ({
|
|
||||||
useSession: useSessionMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { AuthGuard, GuestGuard } from './guards';
|
|
||||||
|
|
||||||
interface RenderedRouter {
|
|
||||||
container: HTMLDivElement;
|
|
||||||
router: ReturnType<typeof createMemoryRouter>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mountedRoots: Root[] = [];
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
async function renderRouter(
|
|
||||||
routeObjects: RouteObject[],
|
|
||||||
initialEntry: string,
|
|
||||||
): Promise<RenderedRouter> {
|
|
||||||
const container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
const router = createMemoryRouter(routeObjects, { initialEntries: [initialEntry] });
|
|
||||||
const root = createRoot(container);
|
|
||||||
mountedRoots.push(root);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(<RouterProvider router={router} />);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { container, router };
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
for (const root of mountedRoots.splice(0)) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
document.body.replaceChildren();
|
|
||||||
useSessionMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
const guestRoutes: RouteObject[] = [
|
|
||||||
{
|
|
||||||
path: '/login',
|
|
||||||
element: <GuestGuard />,
|
|
||||||
children: [{ index: true, element: <p>Guest page</p> }],
|
|
||||||
},
|
|
||||||
{ path: '/chat', element: <p>Chat page</p> },
|
|
||||||
];
|
|
||||||
|
|
||||||
const authenticatedRoutes: RouteObject[] = [
|
|
||||||
{
|
|
||||||
path: '/chat',
|
|
||||||
element: <AuthGuard />,
|
|
||||||
children: [{ index: true, element: <p>Private page</p> }],
|
|
||||||
},
|
|
||||||
{ path: '/login', element: <p>Login page</p> },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('GuestGuard', () => {
|
|
||||||
it('renders the guest outlet while session lookup is pending', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
|
||||||
|
|
||||||
const view = await renderRouter(guestRoutes, '/login');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Guest page');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/login');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the guest outlet when no session exists', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
|
||||||
|
|
||||||
const view = await renderRouter(guestRoutes, '/login');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Guest page');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/login');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redirects an authenticated session to chat', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
|
||||||
|
|
||||||
const view = await renderRouter(guestRoutes, '/login');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Chat page');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/chat');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AuthGuard', () => {
|
|
||||||
it('renders the existing loading treatment while session lookup is pending', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
|
||||||
|
|
||||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Loading...');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/chat');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redirects an unauthenticated visitor to login', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
|
||||||
|
|
||||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Login page');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/login');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the authenticated outlet when a session exists', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
|
||||||
|
|
||||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Private page');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/chat');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import { Navigate, Outlet } from 'react-router-dom';
|
|
||||||
import { useSession } from '@/lib/auth-client';
|
|
||||||
|
|
||||||
export function GuestGuard(): ReactElement {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
|
|
||||||
return session ? <Navigate to="/chat" replace /> : <Outlet />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthGuard(): ReactElement {
|
|
||||||
const { data: session, isPending } = useSession();
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="text-sm text-text-muted">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const { useSessionMock } = vi.hoisted(() => ({
|
|
||||||
useSessionMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth-client', () => ({
|
|
||||||
useSession: useSessionMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { routes } from '@/routes';
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
function Boom(): never {
|
|
||||||
throw new Error('render blew up');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recursively clones the real exported route table, replacing only the
|
|
||||||
* `/chat` route's `element` with `<Boom />` — every other route (including
|
|
||||||
* the real `AuthGuard` nesting and the real `/chat` `errorElement`) is left
|
|
||||||
* exactly as exported. This is what makes the test fail if a future change
|
|
||||||
* removes the real route's `errorElement`, unlike a hand-built independent
|
|
||||||
* route tree that could drift from production undetected. */
|
|
||||||
function replaceChatElementWithBoom(nodes: RouteObject[]): RouteObject[] {
|
|
||||||
return nodes.map((node) => {
|
|
||||||
const cloned: RouteObject = { ...node };
|
|
||||||
if (cloned.path === '/chat') {
|
|
||||||
cloned.element = <Boom />;
|
|
||||||
}
|
|
||||||
if (cloned.children) {
|
|
||||||
cloned.children = replaceChatElementWithBoom(cloned.children);
|
|
||||||
}
|
|
||||||
return cloned;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let root: Root | null;
|
|
||||||
let container: HTMLElement;
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
document.body.replaceChildren();
|
|
||||||
root = null;
|
|
||||||
useSessionMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ChatRouteErrorBoundary', () => {
|
|
||||||
it('renders a recoverable, non-blank fallback when the /chat route element throws during render', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
|
||||||
|
|
||||||
const routeObjects = replaceChatElementWithBoom(routes);
|
|
||||||
const router = createMemoryRouter(routeObjects, { initialEntries: ['/chat'] });
|
|
||||||
|
|
||||||
container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
try {
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(<RouterProvider router={router} />);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(container.textContent).not.toBe('');
|
|
||||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import { useRouteError } from 'react-router-dom';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `/chat` renders live, server-driven state (streamed text, tool calls,
|
|
||||||
* manifests) that can carry malformed payloads no compile-time contract can
|
|
||||||
* fully rule out at every dereference site. This is the last line of
|
|
||||||
* defense: if something still throws during render, show a recoverable
|
|
||||||
* alert instead of leaving the user on a blank/white screen.
|
|
||||||
*/
|
|
||||||
export function ChatRouteErrorBoundary(): ReactElement {
|
|
||||||
useRouteError();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div role="alert" className="flex min-h-screen flex-col items-center justify-center gap-3 p-8">
|
|
||||||
<p className="text-sm font-medium">Something went wrong loading chat.</p>
|
|
||||||
<a href="/chat" className="text-sm underline">
|
|
||||||
Reload chat
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,636 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { createFakeChatSocket } from '@/spa/chat/test-support/fake-chat-socket';
|
|
||||||
import { MAX_MANIFEST_ITEMS } from '@/spa/chat/limits';
|
|
||||||
|
|
||||||
const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({
|
|
||||||
getSocketMock: vi.fn(),
|
|
||||||
destroySocketMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/socket', () => ({
|
|
||||||
getSocket: getSocketMock,
|
|
||||||
destroySocket: destroySocketMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { ChatPage } from './chat';
|
|
||||||
|
|
||||||
function setValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): void {
|
|
||||||
const proto =
|
|
||||||
el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
||||||
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
||||||
setter?.call(el, value);
|
|
||||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectValue(el: HTMLSelectElement, value: string): void {
|
|
||||||
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
|
|
||||||
setter?.call(el, value);
|
|
||||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
|
|
||||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
|
||||||
candidate.textContent?.includes(text),
|
|
||||||
);
|
|
||||||
if (!button) throw new Error(`Button with text "${text}" not found`);
|
|
||||||
return button;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fake: ReturnType<typeof createFakeChatSocket>;
|
|
||||||
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(async () => {
|
|
||||||
fake = createFakeChatSocket();
|
|
||||||
getSocketMock.mockReset().mockReturnValue(fake.socket);
|
|
||||||
destroySocketMock.mockReset();
|
|
||||||
container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(<ChatPage />);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
document.body.replaceChildren();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ChatPage', () => {
|
|
||||||
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'pondering…' });
|
|
||||||
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hel' });
|
|
||||||
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'lo!' });
|
|
||||||
fake.serverEmit('agent:tool:start', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
toolCallId: 't1',
|
|
||||||
toolName: 'web_search',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('pondering…');
|
|
||||||
expect(container.textContent).toContain('Hello!');
|
|
||||||
expect(container.textContent).toContain('web_search');
|
|
||||||
expect(container.textContent).toMatch(/running/i);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('agent:tool:end', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
toolCallId: 't1',
|
|
||||||
toolName: 'web_search',
|
|
||||||
isError: false,
|
|
||||||
});
|
|
||||||
fake.serverEmit('agent:end', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
usage: {
|
|
||||||
provider: 'anthropic',
|
|
||||||
modelId: 'claude',
|
|
||||||
thinkingLevel: 'medium',
|
|
||||||
tokens: { input: 12, output: 34, cacheRead: 0, cacheWrite: 0, total: 46 },
|
|
||||||
cost: 0.02,
|
|
||||||
context: { percent: 3, window: 200000 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.textContent).toMatch(/success/i);
|
|
||||||
expect(container.textContent).toContain('Hello!');
|
|
||||||
expect(container.textContent).toMatch(/46/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the commands manifest and session info, and lets the user pick a thinking level', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmit('commands:manifest', {
|
|
||||||
manifest: {
|
|
||||||
commands: [
|
|
||||||
{
|
|
||||||
name: 'model',
|
|
||||||
aliases: ['m'],
|
|
||||||
description: 'Change the active model',
|
|
||||||
scope: 'core',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
skills: [],
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
fake.serverEmit('session:info', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
provider: 'anthropic',
|
|
||||||
modelId: 'claude',
|
|
||||||
thinkingLevel: 'medium',
|
|
||||||
availableThinkingLevels: ['low', 'medium', 'high'],
|
|
||||||
routingDecision: {
|
|
||||||
model: 'claude',
|
|
||||||
provider: 'anthropic',
|
|
||||||
ruleName: 'default',
|
|
||||||
reason: 'default routing',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('model');
|
|
||||||
expect(container.textContent).toContain('Change the active model');
|
|
||||||
expect(container.textContent).toContain('anthropic');
|
|
||||||
expect(container.textContent).toContain('default routing');
|
|
||||||
|
|
||||||
const select = container.querySelector(
|
|
||||||
'select[aria-label="Thinking level"]',
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
expect(select).toBeTruthy();
|
|
||||||
expect([...select.options].map((o) => o.value)).toEqual(['low', 'medium', 'high']);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
selectValue(select, 'high');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted).toContainEqual({
|
|
||||||
event: 'set:thinking',
|
|
||||||
payload: { conversationId: 'c1', level: 'high' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('executes and approves commands with exact payloads and surfaces the approval affordance', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
const commandInput = container.querySelector(
|
|
||||||
'input[aria-label="Command name"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const argsInput = container.querySelector(
|
|
||||||
'input[aria-label="Command arguments"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
setValue(commandInput, 'model');
|
|
||||||
setValue(argsInput, 'gpt-5');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
findButton(container, 'Run command').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted).toContainEqual({
|
|
||||||
event: 'command:execute',
|
|
||||||
payload: { conversationId: 'c1', command: 'model', args: 'gpt-5' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
setValue(commandInput, 'deploy');
|
|
||||||
setValue(argsInput, 'prod');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
findButton(container, 'Request approval').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted).toContainEqual({
|
|
||||||
event: 'command:approve',
|
|
||||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('command:approval', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'deploy',
|
|
||||||
success: true,
|
|
||||||
approvalId: 'ap1',
|
|
||||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.textContent).toMatch(/approved/i);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
findButton(container, 'Run approved command').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted).toContainEqual({
|
|
||||||
event: 'command:execute',
|
|
||||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows visible alert surfaces for a server error and the structured contract reason for a failed command result', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmit('error', { conversationId: 'c1', error: 'The model is unavailable' });
|
|
||||||
fake.serverEmit('command:result', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'model',
|
|
||||||
success: false,
|
|
||||||
message: 'Unknown model',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
|
||||||
const alertText = alerts.map((node) => node.textContent).join(' ');
|
|
||||||
expect(alertText).toContain('The model is unavailable');
|
|
||||||
// The structured, contract-provided denial reason is visibly rendered.
|
|
||||||
expect(alertText).toContain('Unknown model');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to a stable "Command failed." copy when a failed command result has no usable message', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmitRaw('command:result', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'model',
|
|
||||||
success: false,
|
|
||||||
message: { bad: 'object' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
|
||||||
const alertText = alerts.map((node) => node.textContent).join(' ');
|
|
||||||
expect(alertText).toContain('Command failed.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('caps availableThinkingLevels before storing and rendering a hostile session payload', async () => {
|
|
||||||
const hostileLevels = Array.from({ length: MAX_MANIFEST_ITEMS + 50 }, (_, i) => `level-${i}`);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmit('session:info', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
provider: 'anthropic',
|
|
||||||
modelId: 'claude',
|
|
||||||
thinkingLevel: 'level-0',
|
|
||||||
availableThinkingLevels: hostileLevels,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const select = container.querySelector(
|
|
||||||
'select[aria-label="Thinking level"]',
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
expect(select).toBeTruthy();
|
|
||||||
expect(select.options.length).toBeLessThanOrEqual(MAX_MANIFEST_ITEMS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a safe fallback when session:info arrives with a malformed (non-array) availableThinkingLevels, without throwing', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmitRaw('session:info', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
provider: 'anthropic',
|
|
||||||
modelId: 'claude',
|
|
||||||
thinkingLevel: 'medium',
|
|
||||||
availableThinkingLevels: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
|
||||||
const select = container.querySelector(
|
|
||||||
'select[aria-label="Thinking level"]',
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
expect(select).toBeTruthy();
|
|
||||||
// A malformed level list still shows a visible, safe placeholder option
|
|
||||||
// rather than a silently empty select.
|
|
||||||
expect([...select.options]).toHaveLength(1);
|
|
||||||
expect(select.options[0]?.textContent).toMatch(/unavailable/i);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
selectValue(select, '');
|
|
||||||
});
|
|
||||||
expect(fake.emitted.filter((e) => e.event === 'set:thinking')).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders honest unavailable labels — not fabricated zeros — when agent:end usage has malformed/missing numeric fields', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
||||||
fake.serverEmitRaw('agent:end', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
usage: {
|
|
||||||
provider: { nested: 'object' },
|
|
||||||
modelId: undefined,
|
|
||||||
thinkingLevel: 'medium',
|
|
||||||
tokens: { total: 'not-a-number' },
|
|
||||||
cost: undefined,
|
|
||||||
context: { percent: null, window: 200000 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const usage = container.querySelector('[aria-label="Usage"]');
|
|
||||||
expect(usage).toBeTruthy();
|
|
||||||
expect(usage?.textContent).toContain('tokens unavailable');
|
|
||||||
expect(usage?.textContent).toContain('cost unavailable');
|
|
||||||
expect(usage?.textContent).not.toContain('0 tokens');
|
|
||||||
expect(usage?.textContent).not.toContain('$0.0000');
|
|
||||||
expect(usage?.textContent).toContain('unknown/unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a safe fallback for message:ack when messageId is a malformed non-string value, without throwing', async () => {
|
|
||||||
await expect(
|
|
||||||
act(async () => {
|
|
||||||
fake.serverEmitRaw('message:ack', { conversationId: 'c1', messageId: { bad: 'object' } });
|
|
||||||
}),
|
|
||||||
).resolves.not.toThrow();
|
|
||||||
|
|
||||||
const status = [...container.querySelectorAll('[role="status"]')].find((node) =>
|
|
||||||
node.textContent?.includes('Message accepted'),
|
|
||||||
);
|
|
||||||
expect(status).toBeTruthy();
|
|
||||||
// A malformed messageId gets a stable, visible fallback — never blank,
|
|
||||||
// never the raw object.
|
|
||||||
expect(status?.textContent).toContain('unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders safely and does not throw when system:reload.message is a malformed non-string value', async () => {
|
|
||||||
await expect(
|
|
||||||
act(async () => {
|
|
||||||
fake.serverEmitRaw('system:reload', {
|
|
||||||
commands: [],
|
|
||||||
skills: [],
|
|
||||||
providers: [],
|
|
||||||
message: { bad: 'object' },
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
).resolves.not.toThrow();
|
|
||||||
|
|
||||||
const status = container.querySelector('[role="status"]');
|
|
||||||
expect(status).toBeTruthy();
|
|
||||||
// A malformed reload message renders a stable, visible fallback rather
|
|
||||||
// than a silently empty status line.
|
|
||||||
expect(status?.textContent).toContain('Commands reloaded.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders safely and does not throw when a scoped error carries a malformed non-string error value', async () => {
|
|
||||||
await expect(
|
|
||||||
act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmitRaw('error', { conversationId: 'c1', error: ['not', 'a', 'string'] });
|
|
||||||
}),
|
|
||||||
).resolves.not.toThrow();
|
|
||||||
|
|
||||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
|
|
||||||
const textarea = container.querySelector(
|
|
||||||
'textarea[aria-label="Message"]',
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
const providerInput = container.querySelector(
|
|
||||||
'input[aria-label="Provider"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const modelInput = container.querySelector('input[aria-label="Model"]') as HTMLInputElement;
|
|
||||||
|
|
||||||
const stopButtonBefore = container.querySelector(
|
|
||||||
'button[aria-label="Stop"]',
|
|
||||||
) as HTMLButtonElement;
|
|
||||||
expect(stopButtonBefore.disabled).toBe(true);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
setValue(textarea, 'hello there');
|
|
||||||
setValue(providerInput, 'anthropic');
|
|
||||||
setValue(modelInput, 'claude');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
textarea.dispatchEvent(
|
|
||||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted).toContainEqual({
|
|
||||||
event: 'message',
|
|
||||||
payload: {
|
|
||||||
conversationId: undefined,
|
|
||||||
content: 'hello there',
|
|
||||||
provider: 'anthropic',
|
|
||||||
modelId: 'claude',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(container.textContent).toContain('hello there');
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
const stopButtonDuring = container.querySelector(
|
|
||||||
'button[aria-label="Stop"]',
|
|
||||||
) as HTMLButtonElement;
|
|
||||||
expect(stopButtonDuring.disabled).toBe(false);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
stopButtonDuring.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => {
|
|
||||||
const textarea = container.querySelector(
|
|
||||||
'textarea[aria-label="Message"]',
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
setValue(textarea, 'hello');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
textarea.dispatchEvent(
|
|
||||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('session:info', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
provider: 'anthropic',
|
|
||||||
modelId: 'claude',
|
|
||||||
thinkingLevel: 'medium',
|
|
||||||
availableThinkingLevels: ['low', 'medium', 'high'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
|
||||||
expect(container.textContent).toContain('anthropic');
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
|
||||||
expect(container.textContent).toContain('anthropic');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('surfaces a pre-ack error as an alert without leaving the Stop control stuck active', async () => {
|
|
||||||
const textarea = container.querySelector(
|
|
||||||
'textarea[aria-label="Message"]',
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
setValue(textarea, 'hello');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
textarea.dispatchEvent(
|
|
||||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('error', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
error: 'Failed to start agent session. Please try again.',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
|
||||||
expect(alerts.some((node) => node.textContent?.includes('Failed to start agent session'))).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
const stopButton = container.querySelector('button[aria-label="Stop"]') as HTMLButtonElement;
|
|
||||||
expect(stopButton.disabled).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows an accessible status once the message is acknowledged', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
const statuses = [...container.querySelectorAll('[role="status"]')];
|
|
||||||
expect(statuses.some((node) => node.textContent?.includes('m1'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders finalized thinking text in the transcript after agent:end, not only while streaming', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
||||||
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'reasoning about it' });
|
|
||||||
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Done.' });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('reasoning about it');
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('agent:end', { conversationId: 'c1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('reasoning about it');
|
|
||||||
expect(container.textContent).toContain('Done.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores a concurrent approval request and only executes the approved command once', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
const commandInput = container.querySelector(
|
|
||||||
'input[aria-label="Command name"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const argsInput = container.querySelector(
|
|
||||||
'input[aria-label="Command arguments"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
setValue(commandInput, 'deploy');
|
|
||||||
setValue(argsInput, 'prod');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
findButton(container, 'Request approval').click();
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
setValue(argsInput, 'staging');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
findButton(container, 'Request approval').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(1);
|
|
||||||
expect(fake.emitted).toContainEqual({
|
|
||||||
event: 'command:approve',
|
|
||||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('command:approval', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'deploy',
|
|
||||||
success: true,
|
|
||||||
approvalId: 'ap1',
|
|
||||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
findButton(container, 'Run approved command').click();
|
|
||||||
findButton(container, 'Run approved command').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(1);
|
|
||||||
expect(fake.emitted).toContainEqual({
|
|
||||||
event: 'command:execute',
|
|
||||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables sending a second message while a turn is streaming', async () => {
|
|
||||||
const textarea = container.querySelector(
|
|
||||||
'textarea[aria-label="Message"]',
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
setValue(textarea, 'first');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
textarea.dispatchEvent(
|
|
||||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
const sendButton = findButton(container, 'Send');
|
|
||||||
expect(sendButton.disabled).toBe(true);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
setValue(textarea, 'second');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
textarea.dispatchEvent(
|
|
||||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => {
|
|
||||||
expect(fake.listeners.size).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
root = null;
|
|
||||||
|
|
||||||
for (const [, handlers] of fake.listeners) {
|
|
||||||
expect(handlers.size).toBe(0);
|
|
||||||
}
|
|
||||||
expect(destroySocketMock).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import { CommandsPanel } from '@/spa/chat/commands-panel';
|
|
||||||
import { Composer } from '@/spa/chat/composer';
|
|
||||||
import { MessageTranscript } from '@/spa/chat/message-transcript';
|
|
||||||
import { asFiniteNumberOrNull, asString } from '@/spa/chat/runtime-guards';
|
|
||||||
import { SessionPanel } from '@/spa/chat/session-panel';
|
|
||||||
import { ToolCallList } from '@/spa/chat/tool-call-list';
|
|
||||||
import { useChatConnection } from '@/spa/chat/use-chat-connection';
|
|
||||||
|
|
||||||
/** Renders a real value normally, but an honest "unavailable" label instead
|
|
||||||
* of a fabricated `0` for a missing/malformed count — a real `0 tokens` and
|
|
||||||
* an unknown token count must never look the same. */
|
|
||||||
function formatTokens(value: unknown): string {
|
|
||||||
const tokens = asFiniteNumberOrNull(value);
|
|
||||||
return tokens === null ? 'tokens unavailable' : `${tokens} tokens`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same honesty guarantee as `formatTokens`, for cost. */
|
|
||||||
function formatCost(value: unknown): string {
|
|
||||||
const cost = asFiniteNumberOrNull(value);
|
|
||||||
return cost === null ? 'cost unavailable' : `$${cost.toFixed(4)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatPage(): ReactElement {
|
|
||||||
const { state, actions } = useChatConnection();
|
|
||||||
const hasConversation = state.conversationId !== null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-[calc(100vh-3.5rem)] min-h-0 flex-col overflow-hidden md:h-screen">
|
|
||||||
<header className="border-b px-4 py-3">
|
|
||||||
<h1 className="text-lg font-semibold">Chat</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{state.systemReload ? (
|
|
||||||
<div role="status" className="border-b px-4 py-2 text-sm">
|
|
||||||
{asString(state.systemReload.message)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{state.error ? (
|
|
||||||
<div role="alert" className="border-b px-4 py-2 text-sm">
|
|
||||||
{asString(state.error)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{state.ack ? (
|
|
||||||
<div role="status" className="border-b px-4 py-1 text-xs opacity-70">
|
|
||||||
Message accepted · conversation {asString(state.ack.conversationId, 'unknown')} · id{' '}
|
|
||||||
{asString(state.ack.messageId, 'unknown')}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<SessionPanel sessionInfo={state.sessionInfo} onSetThinking={actions.setThinking} />
|
|
||||||
|
|
||||||
<MessageTranscript messages={state.messages} streaming={state.streaming} text={state.text} />
|
|
||||||
|
|
||||||
{state.thinking ? (
|
|
||||||
<section aria-label="Thinking" className="px-4 pb-2 text-xs italic opacity-80">
|
|
||||||
{state.thinking}
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ToolCallList tools={state.tools} />
|
|
||||||
|
|
||||||
{state.usage ? (
|
|
||||||
<div aria-label="Usage" className="px-4 pb-2 text-xs opacity-80">
|
|
||||||
{formatTokens(state.usage.tokens?.total)} · {formatCost(state.usage.cost)} ·{' '}
|
|
||||||
{asString(state.usage.provider, 'unknown')}/{asString(state.usage.modelId, 'unknown')}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={state.manifest}
|
|
||||||
results={state.commandResults}
|
|
||||||
approval={state.approval}
|
|
||||||
pendingApproval={state.pendingApproval}
|
|
||||||
hasConversation={hasConversation}
|
|
||||||
onExecute={actions.executeCommand}
|
|
||||||
onApprove={actions.approveCommand}
|
|
||||||
onRunApproved={actions.runApprovedCommand}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Composer
|
|
||||||
onSend={actions.sendMessage}
|
|
||||||
onStop={actions.abort}
|
|
||||||
streaming={state.streaming}
|
|
||||||
sending={state.sending}
|
|
||||||
hasConversation={hasConversation}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import { useEffect, useState, type FormEvent, type ReactElement } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { authClient, signIn } from '@/lib/auth-client';
|
|
||||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
|
||||||
|
|
||||||
export function LoginPage(): ReactElement {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
|
||||||
const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState<
|
|
||||||
SsoProviderDiscovery['id'] | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
void api<SsoProviderDiscovery[]>('/api/sso/providers').then(
|
|
||||||
(providers) => {
|
|
||||||
if (active) setSsoProviders(providers.filter((provider) => provider.configured));
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
if (active) setSsoProviders([]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
|
||||||
event.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const form = new FormData(event.currentTarget);
|
|
||||||
const email = String(form.get('email') ?? '');
|
|
||||||
const password = String(form.get('password') ?? '');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await signIn.email({ email, password });
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message ?? 'Sign in failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate('/chat', { replace: true });
|
|
||||||
} catch (caught: unknown) {
|
|
||||||
setError(caught instanceof Error ? caught.message : 'Sign in failed');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise<void> {
|
|
||||||
setError(null);
|
|
||||||
setSsoLoadingProviderId(providerId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await authClient.signIn.oauth2({
|
|
||||||
providerId,
|
|
||||||
callbackURL: '/chat',
|
|
||||||
newUserCallbackURL: '/chat',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message ?? `Sign in with ${providerId} failed`);
|
|
||||||
setSsoLoadingProviderId(null);
|
|
||||||
}
|
|
||||||
} catch (caught: unknown) {
|
|
||||||
setError(caught instanceof Error ? caught.message : `Sign in with ${providerId} failed`);
|
|
||||||
setSsoLoadingProviderId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">Sign in</h1>
|
|
||||||
<p className="mt-1 text-sm text-text-secondary">Sign in to your Mosaic account</p>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="[email protected]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? 'Signing in...' : 'Sign in'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<SsoProviderButtons
|
|
||||||
providers={ssoProviders}
|
|
||||||
loadingProviderId={ssoLoadingProviderId}
|
|
||||||
onOidcSignIn={(providerId) => {
|
|
||||||
void handleSsoSignIn(providerId);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="mt-4 text-center text-sm text-text-muted">
|
|
||||||
Don't have an account?{' '}
|
|
||||||
<Link to="/register" className="text-blue-400 hover:text-blue-300">
|
|
||||||
Sign up
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { useState, type FormEvent, type ReactElement } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import { signUp } from '@/lib/auth-client';
|
|
||||||
|
|
||||||
export function RegisterPage(): ReactElement {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
|
||||||
event.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const form = new FormData(event.currentTarget);
|
|
||||||
const name = String(form.get('name') ?? '');
|
|
||||||
const email = String(form.get('email') ?? '');
|
|
||||||
const password = String(form.get('password') ?? '');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await signUp.email({ name, email, password });
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message ?? 'Registration failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate('/chat', { replace: true });
|
|
||||||
} catch (caught: unknown) {
|
|
||||||
setError(caught instanceof Error ? caught.message : 'Registration failed');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">Create account</h1>
|
|
||||||
<p className="mt-1 text-sm text-text-secondary">Get started with Mosaic</p>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
autoComplete="name"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="Your name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="[email protected]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? 'Creating account...' : 'Create account'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="mt-4 text-center text-sm text-text-muted">
|
|
||||||
Already have an account?{' '}
|
|
||||||
<Link to="/login" className="text-blue-400 hover:text-blue-300">
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
|
||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const { apiMock, oauth2Mock } = vi.hoisted(() => ({
|
|
||||||
apiMock: vi.fn(),
|
|
||||||
oauth2Mock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/api', () => ({
|
|
||||||
api: apiMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth-client', () => ({
|
|
||||||
signIn: { oauth2: oauth2Mock },
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { SsoCallbackPage } from './sso-callback';
|
|
||||||
|
|
||||||
const mountedRoots: Root[] = [];
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
apiMock.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'authentik',
|
|
||||||
name: 'Authentik',
|
|
||||||
protocols: ['oidc'],
|
|
||||||
configured: true,
|
|
||||||
loginMode: 'oidc',
|
|
||||||
callbackPath: '/api/auth/oauth2/callback/authentik',
|
|
||||||
teamSync: { enabled: false, claim: null },
|
|
||||||
samlFallback: { configured: false, loginUrl: null },
|
|
||||||
warnings: [],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
oauth2Mock.mockResolvedValue({ data: null, error: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
for (const root of mountedRoots.splice(0)) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
document.body.replaceChildren();
|
|
||||||
apiMock.mockReset();
|
|
||||||
oauth2Mock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SsoCallbackPage', () => {
|
|
||||||
it('rejects a control-character callback that normalizes to an external origin', async () => {
|
|
||||||
const router = createMemoryRouter(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
path: '/auth/provider/:provider',
|
|
||||||
element: <SsoCallbackPage />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{
|
|
||||||
initialEntries: ['/auth/provider/authentik?callbackURL=%2F%0A%2F%2Fevil.example'],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
const root = createRoot(container);
|
|
||||||
mountedRoots.push(root);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(<RouterProvider router={router} />);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(oauth2Mock).toHaveBeenCalledWith({
|
|
||||||
providerId: 'authentik',
|
|
||||||
callbackURL: '/chat',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { useEffect, useState, type ReactElement } from 'react';
|
|
||||||
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
|
||||||
import { signIn } from '@/lib/auth-client';
|
|
||||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
|
||||||
|
|
||||||
export function SsoCallbackPage(): ReactElement {
|
|
||||||
const { provider: providerId = '' } = useParams<'provider'>();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
|
||||||
const [providerName, setProviderName] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function redirectToProvider(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
|
||||||
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
|
||||||
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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void redirectToProvider();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [providerId, requestedCallbackURL]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
<p className="mt-2 text-sm text-text-secondary">
|
|
||||||
{providerName
|
|
||||||
? `Redirecting you to ${providerName}...`
|
|
||||||
: 'Preparing your sign-in request...'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
<Link
|
|
||||||
to="/login"
|
|
||||||
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
|
|
||||||
>
|
|
||||||
Return to login
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
|
|
||||||
If the redirect does not start automatically, return to the login page and try again.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
|
|
||||||
export function Placeholder({ title }: { title: string }): ReactElement {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-screen items-center justify-center">
|
|
||||||
<h1 className="text-xl font-medium">{title}</h1>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { isValidElement } from 'react';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { RouteObject } from 'react-router-dom';
|
|
||||||
import { routes } from '@/routes';
|
|
||||||
import { Placeholder } from '@/spa/placeholder';
|
|
||||||
import { ChatPage } from '@/spa/pages/chat';
|
|
||||||
|
|
||||||
function collectPaths(routeObjects: RouteObject[]): string[] {
|
|
||||||
return routeObjects.flatMap((route) => [
|
|
||||||
...(route.path ? [route.path] : []),
|
|
||||||
...(route.children ? collectPaths(route.children) : []),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findRoute(routeObjects: RouteObject[], path: string): RouteObject | undefined {
|
|
||||||
for (const route of routeObjects) {
|
|
||||||
if (route.path === path) return route;
|
|
||||||
const nested = route.children ? findRoute(route.children, path) : undefined;
|
|
||||||
if (nested) return nested;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('SPA route table', () => {
|
|
||||||
it('covers every v1 parity route from the Phase P RFC', () => {
|
|
||||||
expect(collectPaths(routes).sort()).toEqual(
|
|
||||||
[
|
|
||||||
'/',
|
|
||||||
'/admin',
|
|
||||||
'/auth/provider/:provider',
|
|
||||||
'/chat',
|
|
||||||
'/login',
|
|
||||||
'/projects',
|
|
||||||
'/projects/:id',
|
|
||||||
'/register',
|
|
||||||
'/settings',
|
|
||||||
'/tasks',
|
|
||||||
].sort(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('separates guest and authenticated route groups', () => {
|
|
||||||
const guestPaths = collectPaths(routes.at(0)?.children ?? []);
|
|
||||||
const authPaths = collectPaths(routes.at(1)?.children ?? []);
|
|
||||||
expect(guestPaths).toContain('/login');
|
|
||||||
expect(guestPaths).not.toContain('/chat');
|
|
||||||
expect(authPaths).toContain('/chat');
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['/login', '/register', '/auth/provider/:provider'])(
|
|
||||||
'renders a real guest page instead of the P1 placeholder at %s',
|
|
||||||
(path) => {
|
|
||||||
const element = findRoute(routes, path)?.element;
|
|
||||||
expect(isValidElement(element)).toBe(true);
|
|
||||||
if (!isValidElement(element)) throw new Error(`Missing route element for ${path}`);
|
|
||||||
expect(element.type).not.toBe(Placeholder);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('renders the real chat page instead of the P1 placeholder at /chat, inside the authenticated group', () => {
|
|
||||||
const authPaths = collectPaths(routes.at(1)?.children ?? []);
|
|
||||||
expect(authPaths).toContain('/chat');
|
|
||||||
|
|
||||||
const element = findRoute(routes, '/chat')?.element;
|
|
||||||
expect(isValidElement(element)).toBe(true);
|
|
||||||
if (!isValidElement(element)) throw new Error('Missing route element for /chat');
|
|
||||||
expect(element.type).not.toBe(Placeholder);
|
|
||||||
expect(element.type).toBe(ChatPage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
describe('Vitest abort-controller realm', () => {
|
|
||||||
it('provides a global signal accepted by Node native Request', () => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const request = new Request('https://mosaic.invalid/navigation', {
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(request.signal).toBeInstanceOf(AbortSignal);
|
|
||||||
controller.abort();
|
|
||||||
expect(request.signal.aborted).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { transferableAbortController } from 'node:util';
|
|
||||||
|
|
||||||
// jsdom installs realm-local abort constructors while Node's undici Request
|
|
||||||
// remains native. React Router passes a global AbortSignal to Request, so both
|
|
||||||
// constructors must come from Node's native realm during tests.
|
|
||||||
const nativeController = transferableAbortController();
|
|
||||||
const nativeAbortController = nativeController.constructor;
|
|
||||||
const nativeAbortSignal = nativeController.signal.constructor;
|
|
||||||
|
|
||||||
for (const target of [globalThis, window]) {
|
|
||||||
Object.defineProperties(target, {
|
|
||||||
AbortController: {
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
value: nativeAbortController,
|
|
||||||
},
|
|
||||||
AbortSignal: {
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
value: nativeAbortSignal,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
|
|
||||||
// The proxy exists only in dev; in production the SPA is same-origin with the gateway
|
|
||||||
// (served by it under Candidate A, or behind one FQDN under Candidate B) and every
|
|
||||||
// request uses a relative path, so no origin may ever be configured here or in src/.
|
|
||||||
const gatewayTarget = 'http://localhost:14242';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
port: 3100,
|
|
||||||
proxy: {
|
|
||||||
'/api': gatewayTarget,
|
|
||||||
'/socket.io': { target: gatewayTarget, ws: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,21 +1,9 @@
|
|||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// tsconfig uses "jsx": "preserve" for Next; tests need esbuild to compile it
|
|
||||||
esbuild: {
|
|
||||||
jsx: 'automatic',
|
|
||||||
},
|
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./src/test/setup.ts'],
|
|
||||||
isolate: true,
|
|
||||||
exclude: ['e2e/**', 'node_modules/**'],
|
exclude: ['e2e/**', 'node_modules/**'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
|||||||
COPY apps/gateway/package.json ./apps/gateway/
|
COPY apps/gateway/package.json ./apps/gateway/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
COPY plugins/ ./plugins/
|
COPY plugins/ ./plugins/
|
||||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
|
||||||
COPY scripts/ ./scripts/
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
||||||
@@ -23,22 +21,11 @@ RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
|
|||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
# WorkspaceService shells out to git at runtime and roots workspaces at
|
|
||||||
# $MOSAIC_ROOT/.workspaces (apps/gateway/src/workspace/workspace.service.ts);
|
|
||||||
# mount a volume over /opt/mosaic to persist workspaces across container restarts.
|
|
||||||
# Intentionally unpinned: Alpine's signed repository is the trust anchor; pinning
|
|
||||||
# git was declined so routine base-image security updates remain maintainable.
|
|
||||||
RUN apk add --no-cache git \
|
|
||||||
&& mkdir -p /opt/mosaic/.workspaces \
|
|
||||||
&& chown -R node:node /opt/mosaic /app
|
|
||||||
ENV MOSAIC_ROOT=/opt/mosaic
|
|
||||||
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
||||||
COPY --chown=node:node --from=builder /deploy/node_modules ./node_modules
|
COPY --from=builder /deploy/node_modules ./node_modules
|
||||||
COPY --chown=node:node --from=builder /deploy/package.json ./package.json
|
COPY --from=builder /deploy/package.json ./package.json
|
||||||
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
||||||
# copy from builder explicitly as belt-and-suspenders
|
# copy from builder explicitly as belt-and-suspenders
|
||||||
COPY --chown=node:node --from=builder /app/apps/gateway/dist ./dist
|
COPY --from=builder /app/apps/gateway/dist ./dist
|
||||||
# gateway defaults to port 14242 (apps/gateway/src/main.ts)
|
EXPOSE 4000
|
||||||
EXPOSE 14242
|
|
||||||
USER node
|
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ WORKDIR /app
|
|||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
COPY apps/web/package.json ./apps/web/
|
COPY apps/web/package.json ./apps/web/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
|
||||||
COPY scripts/ ./scripts/
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm --filter @mosaicstack/web build
|
RUN pnpm --filter @mosaic/web build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,33 +0,0 @@
|
|||||||
# CI Queue Guard Purpose Semantics
|
|
||||||
|
|
||||||
- **Issue:** #1146
|
|
||||||
- **Target branch:** `next`
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
`ci-queue-wait.sh` treats any result other than terminal success as asserted non-readiness. That is correct for merge readiness, but incorrect for the pre-push queue guard: a terminal failure or an empty status set means no pipeline is queued or running, so the queue is clear.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
Make final-state handling purpose-sensitive while preserving the existing provider and payload safeguards:
|
|
||||||
|
|
||||||
- `--purpose push`
|
|
||||||
- wait while state is `pending`;
|
|
||||||
- return success for `terminal-success`, `terminal-failure`, and `no-status`;
|
|
||||||
- continue rejecting `malformed`, `unknown`, and unrecognized states.
|
|
||||||
- `--purpose merge`
|
|
||||||
- return success only for `terminal-success`;
|
|
||||||
- continue rejecting `terminal-failure`, `no-status`, malformed, unknown, and unrecognized states.
|
|
||||||
- `--require-status` remains authoritative: `no-status` fails for either purpose when it is supplied.
|
|
||||||
|
|
||||||
Diagnostics will explicitly distinguish a queue-clear push result from successful CI so callers cannot mistake an old failure for a green pipeline.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Extend the process-level tri-state regression harness with separate push and merge assertions:
|
|
||||||
|
|
||||||
1. Push passes for terminal success, terminal failure, and no status.
|
|
||||||
2. Push still fails for pending, malformed, and unknown states.
|
|
||||||
3. `--require-status` makes push/no-status fail.
|
|
||||||
4. Merge behavior remains fail-closed except for terminal success.
|
|
||||||
5. Existing provider-unavailable audit behavior remains unchanged.
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
# CI Queue Guard Purpose Semantics Implementation Plan
|
|
||||||
|
|
||||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
||||||
|
|
||||||
**Goal:** Make the pre-push CI queue guard pass when no pipeline is queued or running while preserving fail-closed merge readiness.
|
|
||||||
|
|
||||||
**Architecture:** Keep provider lookup and tri-state classification unchanged. Make only the final state dispatch purpose-sensitive: push treats valid non-pending states as queue-clear, while merge continues to require terminal success. Preserve `--require-status`, malformed-payload rejection, unknown-state rejection, and audited provider-unavailable behavior.
|
|
||||||
|
|
||||||
**Tech Stack:** Bash, process-level shell regression harnesses, Gitea/GitHub status APIs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Freeze Purpose-Specific State Semantics
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh`
|
|
||||||
- Test: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh`
|
|
||||||
|
|
||||||
**Step 1: Add failing push assertions**
|
|
||||||
|
|
||||||
Change push expectations so `terminal-failure` and `no-status` require exit 0 plus an explicit `queue-clear` diagnostic. Add a `--require-status` assertion that keeps push/no-status non-zero.
|
|
||||||
|
|
||||||
**Step 2: Add failing merge assertions**
|
|
||||||
|
|
||||||
Invoke the same harness with `MOSAIC_TEST_PURPOSE=merge` and assert terminal failure and no status remain non-zero while terminal success remains zero.
|
|
||||||
|
|
||||||
**Step 3: Add unknown-state coverage**
|
|
||||||
|
|
||||||
Add a stub payload with a syntactically valid but unsupported status value and assert both purposes reject it.
|
|
||||||
|
|
||||||
**Step 4: Run the focused test and verify RED**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: failures showing push terminal-failure and no-status returned exit 3 instead of exit 0 or lacked `queue-clear` diagnostics.
|
|
||||||
|
|
||||||
**Step 5: Commit the failing tests**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
|
|
||||||
git commit -m "test(ci): define purpose-aware queue readiness"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task 2: Implement Purpose-Sensitive Final-State Dispatch
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `packages/mosaic/framework/tools/git/ci-queue-wait.sh:458-481`
|
|
||||||
- Test: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh`
|
|
||||||
- Test: `packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh`
|
|
||||||
|
|
||||||
**Step 1: Implement push queue-clear behavior**
|
|
||||||
|
|
||||||
For `no-status`, retain the existing `--require-status` failure. Otherwise, return success for push with an explicit diagnostic such as:
|
|
||||||
|
|
||||||
```text
|
|
||||||
[ci-queue-wait] queue-clear state=no-status purpose=push branch=<branch>; no queued or running CI.
|
|
||||||
```
|
|
||||||
|
|
||||||
For `terminal-failure`, return success only for push with the same queue-clear wording. Merge must continue returning asserted non-readiness.
|
|
||||||
|
|
||||||
**Step 2: Preserve malformed and unknown rejection**
|
|
||||||
|
|
||||||
Keep `malformed`, `unknown`, and unrecognized states non-zero for both purposes.
|
|
||||||
|
|
||||||
**Step 3: Run focused tests and verify GREEN**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
|
|
||||||
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: both scripts exit 0 and report their regression suites passed.
|
|
||||||
|
|
||||||
**Step 4: Commit implementation**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add packages/mosaic/framework/tools/git/ci-queue-wait.sh
|
|
||||||
git commit -m "fix(ci): separate push queue clearance from merge readiness"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task 3: Verify, Review, and Document Evidence
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `docs/scratchpads/1146-ci-queue-purpose.md`
|
|
||||||
|
|
||||||
**Step 1: Run shell syntax and focused regressions**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash -n packages/mosaic/framework/tools/git/ci-queue-wait.sh
|
|
||||||
bash -n packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
|
|
||||||
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
|
|
||||||
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Run repository quality gates**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm preflight
|
|
||||||
pnpm typecheck
|
|
||||||
pnpm lint
|
|
||||||
pnpm test
|
|
||||||
pnpm format:check
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: every command exits 0.
|
|
||||||
|
|
||||||
**Step 3: Obtain independent review**
|
|
||||||
|
|
||||||
Request review of the exact branch head. Remediate all blocking findings and rerun focused and baseline gates.
|
|
||||||
|
|
||||||
**Step 4: Record evidence and commit**
|
|
||||||
|
|
||||||
Update the scratchpad with test output, review result, and residual risk, then commit it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add docs/scratchpads/1146-ci-queue-purpose.md
|
|
||||||
git commit -m "docs(ci): record queue guard verification"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task 4: Keep the Merge Wrapper Aligned with the `next` Lane
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `packages/mosaic/framework/tools/git/pr-merge.sh:97-101`
|
|
||||||
- Test: `packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh`
|
|
||||||
|
|
||||||
**Step 1: Write the failing regression**
|
|
||||||
|
|
||||||
Run the exact-head merge regression with its Gitea fixture targeting `next` and confirm the current wrapper rejects it because it only permits `main`.
|
|
||||||
|
|
||||||
**Step 2: Allow only documented integration targets**
|
|
||||||
|
|
||||||
Permit `main` and `next`; reject every other target. Do not alter exact-head pinning, queue-guard invocation, provider selection, or merge method enforcement.
|
|
||||||
|
|
||||||
**Step 3: Run focused merge regressions**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh
|
|
||||||
bash packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh
|
|
||||||
bash packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all pass, including a Gitea merge fixture targeting `next`.
|
|
||||||
|
|
||||||
**Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add packages/mosaic/framework/tools/git/pr-merge.sh packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh
|
|
||||||
git commit -m "fix(ci): allow reviewed merges into next"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task 5: Activate and Deliver Through `next`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Installed output: `~/.config/mosaic/tools/git/ci-queue-wait.sh`
|
|
||||||
|
|
||||||
**Step 1: Activate through the canonical installer**
|
|
||||||
|
|
||||||
From the reviewed worktree, run the framework installer in sync-only keep mode so operator files remain protected:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep MOSAIC_SKIP_SKILLS_SYNC=1 \
|
|
||||||
bash packages/mosaic/framework/install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Verify installed/source parity**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cmp -s \
|
|
||||||
packages/mosaic/framework/tools/git/ci-queue-wait.sh \
|
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: exit 0.
|
|
||||||
|
|
||||||
**Step 3: Run mandatory pre-push queue guard**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B fix/1146-ci-queue-purpose
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: branch-absent or queue-clear success.
|
|
||||||
|
|
||||||
**Step 4: Push and open a PR against `next`**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git push -u origin fix/1146-ci-queue-purpose
|
|
||||||
~/.config/mosaic/tools/git/pr-create.sh \
|
|
||||||
-t "fix(ci): make queue guard purpose-sensitive" \
|
|
||||||
-b "Closes #1146" \
|
|
||||||
-B next \
|
|
||||||
-H fix/1146-ci-queue-purpose \
|
|
||||||
-i 1146
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 5: Complete reviewed integration**
|
|
||||||
|
|
||||||
Wait for exact-head terminal-green CI, obtain the required review, merge via the Mosaic wrapper, verify merged CI, and close #1146. Do not bypass any gate.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# #1146 — CI Queue Guard Purpose Semantics
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Make the pre-push queue guard wait for queued/running CI without requiring the previous remote head to have successful CI. Preserve fail-closed merge readiness.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- `packages/mosaic/framework/tools/git/ci-queue-wait.sh`
|
|
||||||
- focused queue-guard regression tests
|
|
||||||
- design and scratchpad documentation
|
|
||||||
- local framework activation required before the fixed guard can authorize this branch's push
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Freeze purpose-specific behavior in failing process-level tests.
|
|
||||||
2. Implement the smallest state-dispatch change.
|
|
||||||
3. Run focused shell tests and repository quality gates.
|
|
||||||
4. Obtain independent review and remediate findings.
|
|
||||||
5. Install the reviewed framework source locally, run the mandatory pre-push queue guard, and push.
|
|
||||||
6. Open a PR against `next`, verify terminal-green CI, and close #1146 after merge.
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
- ASSUMPTION: no explicit token cap was provided.
|
|
||||||
- Working estimate: 12K tokens.
|
|
||||||
- Scope reduction: change only final-state dispatch and focused tests; do not redesign provider adapters.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- Confirmed source and installed guards are byte-identical.
|
|
||||||
- Reproduced `terminal-failure` blocking `--purpose push`.
|
|
||||||
- Root cause: final-state dispatch requires terminal success for both push and merge.
|
|
||||||
- Design approved: push is queue-clear on valid non-pending states; merge remains fail-closed.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- RED confirmed before implementation: the focused tri-state harness reported push `terminal-failure` and `no-status` as `ASSERTED_NOT_READY`.
|
|
||||||
- GREEN: `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` — all outcome classes passed.
|
|
||||||
- GREEN: `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh` — 6/6 purpose-aware cases passed.
|
|
||||||
- GREEN: `bash -n` passed for the changed guard and both focused harnesses.
|
|
||||||
- GREEN: `pnpm preflight`, `pnpm typecheck`, and `pnpm lint` passed.
|
|
||||||
- `pnpm test` ran 45/46 workspace test tasks successfully, but the pre-existing Gateway `cross-user-isolation.test.ts` failed during cleanup with PostgreSQL error `28P01` (local `mosaic` password authentication failure). The changed Mosaic framework test task passed within that run.
|
|
||||||
- GREEN: focused queue and merge shell regressions passed after the wrapper change.
|
|
||||||
- GREEN: isolated Mosaic Vitest run passed (81 files, 1,514 tests).
|
|
||||||
- The normal parallel Mosaic Vitest run has an environment-sensitive pre-existing failure in `install-ordering-guard.spec.ts`: the real activation probe changes between two calls while other suites run concurrently. Running the same spec alone and the complete Vitest suite with one fork passes.
|
|
||||||
- The framework shell suite's pre-existing `version_coupling_unittest.py` also fails locally because the newly installed `mosaic` is now on PATH despite the test injecting a nonexistent PATH; CI's clean image does not have this global CLI. All changed queue/merge harnesses pass.
|
|
||||||
- GREEN: `pnpm format:check` passed.
|
|
||||||
- Note: an additional ad hoc Prettier command was not applicable to shell files because Prettier has no shell parser; the repository-wide format check passed using its configured file globs.
|
|
||||||
|
|
||||||
## Review
|
|
||||||
|
|
||||||
- Independent Codex review of the six-file diff: approved, confidence 0.84, zero blockers/should-fix/suggestions.
|
|
||||||
- Review confirmed push queue-clear behavior, merge fail-closed behavior, and `--require-status` coverage.
|
|
||||||
|
|
||||||
## Risks and Blockers
|
|
||||||
|
|
||||||
- Canonical framework activation completed with `MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep MOSAIC_SKIP_SKILLS_SYNC=1 bash packages/mosaic/framework/install.sh`.
|
|
||||||
- Source and installed queue guards are byte-identical (`cmp` and SHA-256 parity passed).
|
|
||||||
- The installed pre-push guard now passes for the not-yet-remote feature branch with `queue clear`.
|
|
||||||
- The required merge wrapper then exposed a second bootstrap defect: `pr-merge.sh` hardcoded `main`, contradicting the documented PR-based `next` integration lane. Tracked as #1149 and fixed in the same delivery branch with a regression fixture targeting `next`.
|
|
||||||
- Activation emitted the existing manifest-safety warning that six `fleet/run/*.hb*` operator files were touched then restored; no data loss was observed, but this remains a pre-existing framework-manifest defect to report separately.
|
|
||||||
- The first activation attempt timed out after 600 seconds while copying the 113K-file operator snapshot; the bounded 1,800-second retry completed successfully. It left a partial durable snapshot from the interrupted attempt in the normal backup directory; the completed snapshot is the newer `pre-update-20260810T195317Z` entry.
|
|
||||||
- Full baseline test completion is blocked by the unrelated local PostgreSQL authentication/cleanup failure described above; CI has its own disposable PostgreSQL service.
|
|
||||||
- Existing `.mosaic/orchestrator/*` working-tree changes are unrelated and must remain unstaged.
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
# WebUI Fleet Bridge Planning Scratchpad
|
|
||||||
|
|
||||||
**Mode:** Task 0 docs-only decision PR authorized; Task 1 and all executable/runtime work remain blocked.
|
|
||||||
**Owner:** Velma
|
|
||||||
**Opened:** 2026-08-09
|
|
||||||
**Scope:** One enrolled Agent Host launching one native `mosaic claude` OAuth session and streaming it into `apps/web` through `apps/gateway`.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Turn the approved direction in `jarvis-brain/docs/scratchpads/MOSAIC-WEBUI-FLEET-BRIDGE.md` into a test-first implementation plan while preserving Fred's harness-home/launcher contract and Scooby's greenfield safety findings.
|
|
||||||
|
|
||||||
## Source reconciliation
|
|
||||||
|
|
||||||
- Current `origin/main`: `b0f7d26dd9c14d91eaaefc35d6c9fd6618a0bd92`.
|
|
||||||
- Current `origin/next`: `4df478cdd150fdf8d52ea109f02ade5d85017acd`.
|
|
||||||
- Branches currently diverge (`main` has 11 unique commits; `next` has 13). `next` contains local-tier Redis fix #689; `main` contains later fleet/shell fixes.
|
|
||||||
- Fred's three-root harness-home design and promotion stack are not yet fully present on either baseline.
|
|
||||||
- Therefore neither current SHA is an acceptable implementation pin. Code may begin only from a Fred-certified reconciled SHA containing the required launcher/home contract and safe Gateway startup prerequisites.
|
|
||||||
- The deployed `mosaic.woltje.com` v0.0.20 UI remains reference-only.
|
|
||||||
|
|
||||||
## Verified seams
|
|
||||||
|
|
||||||
- Current browser chat uses an in-process Pi SDK session.
|
|
||||||
- `AgentRuntimeProvider` supports list/tree/stream/send/attach/detach/terminate, but not create/start.
|
|
||||||
- `InteractionController` enrolls an already-existing runtime session; it cannot launch one.
|
|
||||||
- Hermes is the only runtime provider registered in Gateway.
|
|
||||||
- Tmux streaming is explicitly unsupported and remains out of scope.
|
|
||||||
- `mosaic claude` is the authoritative launcher and accepts Claude's machine-facing stream-json flags.
|
|
||||||
- Installed discovery version: Claude Code 2.1.226. Target Distrobox version must be independently pinned and certified.
|
|
||||||
|
|
||||||
## Non-negotiable dependencies
|
|
||||||
|
|
||||||
1. Fred approves the machine-facing launcher/seat-home contract before code.
|
|
||||||
2. No WebUI/Gateway direct read of lease broker state, daemon socket, or state files (F-V3).
|
|
||||||
3. No provider OAuth token leaves the Agent Host.
|
|
||||||
4. No local Gateway/Web startup around the KBN/database hold.
|
|
||||||
5. Greenfield work runs in a Debian Distrobox with an isolated home.
|
|
||||||
6. The initial plan PR targets `next`; Fred binds D1–D15 on its exact head before issue/PRD/tracker completion or implementation work.
|
|
||||||
|
|
||||||
## Reproducible evidence
|
|
||||||
|
|
||||||
Run from a clean Stack clone:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git fetch origin main next
|
|
||||||
git rev-parse origin/main origin/next
|
|
||||||
git rev-list --left-right --count origin/main...origin/next
|
|
||||||
rg -n "interface AgentRuntimeProvider|createSession|streamEvents|terminate" \
|
|
||||||
packages/types/src/agent packages/agent/src apps/gateway/src/agent
|
|
||||||
rg -n "AgentService\.prompt|interaction_sessions|createRuntimeTerminationApproval" \
|
|
||||||
apps/gateway/src packages/db/src/schema.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Primary inspected source seams:
|
|
||||||
|
|
||||||
- `packages/types/src/agent/agent-runtime-provider.ts`
|
|
||||||
- `packages/agent/src/{runtime-provider-registry,hermes-runtime-provider,matrix-native-runtime-provider,tmux-fleet-runtime-provider}.ts`
|
|
||||||
- `apps/gateway/src/agent/{runtime-provider-registry.service,interaction.controller,durable-session.repository,durable-session.service}.ts`
|
|
||||||
- `apps/gateway/src/chat/chat.gateway.ts`
|
|
||||||
- `packages/mosaic/src/commands/{launch,interaction}.ts`
|
|
||||||
- `packages/mosaic/src/fleet/generated-env-boundary.ts`
|
|
||||||
- `packages/db/src/schema.ts`
|
|
||||||
- `apps/web/src/app/(dashboard)/chat/page.tsx`
|
|
||||||
|
|
||||||
Planning-only investigation transcripts are local and intentionally uncommitted:
|
|
||||||
|
|
||||||
- `/tmp/velma-plan-stack-surface.txt`
|
|
||||||
- `/tmp/velma-plan-structure.txt`
|
|
||||||
- `/tmp/velma-plan-scooby.txt`
|
|
||||||
- `/tmp/velma-plan-runtime-contract.txt`
|
|
||||||
|
|
||||||
## Source findings that constrain the design
|
|
||||||
|
|
||||||
- `interaction_sessions.id` is the stable primary key; there are no create/policy/enrollment/state columns.
|
|
||||||
- `interaction_outbox` has a unique `(session_id, idempotency_key)` index and only `pending | processing | delivered`.
|
|
||||||
- Baseline `DurableSessionRepository.create()` can replace provider/runtime identity for the same owner; M1 must remove that implicit mutation.
|
|
||||||
- Baseline termination approval is Redis-backed and currently consumes separately from PostgreSQL; M1 therefore needs durable authorization acceptance before destructive token deletion/dispatch.
|
|
||||||
- Existing interaction HTTP base is `/api/interaction/:agentName`; the plan extends it rather than inventing a second route family.
|
|
||||||
- Baseline `launch.ts`/lease launcher still use ambient lookup/literal interpreters. Section 4.1 is non-binding consumer input to Fred's W-F design; W-F's final resolved-launch contract must exist in the certified base before Velma can certify it.
|
|
||||||
- Root `pnpm test` is not KBN-safe: it includes PGlite migration and framework-shell/lease-broker suites.
|
|
||||||
|
|
||||||
## Independent draft review
|
|
||||||
|
|
||||||
Seven adversarial review rounds found and drove explicit fixes for:
|
|
||||||
|
|
||||||
- a candidate resolved-seat consumer descriptor and threat model, now explicitly non-binding input to Fred's W-F-owned launcher design;
|
|
||||||
- one active launch per stable conversation, exact pending/failed encodings, durable pre-dispatch reservation, CAS activation, and crash lookup without a migration;
|
|
||||||
- generation-bound enrollment, command/event revalidation, `SIGHUP` config epochs, and stale-epoch rejection;
|
|
||||||
- Redis/PostgreSQL exact-stop crash safety via non-destructive verify, durable authorization acceptance, atomic claim/`GETDEL`, and same-operation status reconciliation;
|
|
||||||
- sequence gaps/reorder limits, deterministic UUIDv5 completion, and Gateway restart fail-closed behavior;
|
|
||||||
- shared streaming redaction before host ring/transport and again before Gateway persistence/browser;
|
|
||||||
- D2/D12-selected path-free provenance and a separate safe browser DTO—never raw or hashed path strings;
|
|
||||||
- exact F-V3 boundary: bridge has no broker API, while Fred's sealed launcher may enforce broker policy internally;
|
|
||||||
- migration-free focused tests in Tasks 1–8, with live OAuth, repository transaction, Gateway/Web, and Playwright restricted to the Fred/Scooby-certified Task 9 path;
|
|
||||||
- candidate private/public commitment and artifact-binding mechanisms that W-F may accept, simplify, replace, or defer;
|
|
||||||
- JCS event-digest recomputation and equal/different duplicate handling in both accepted and future-buffer states;
|
|
||||||
- a safe browser presentation DTO for host/workspace/seat/persona labels, readiness, and connection state;
|
|
||||||
- explicit `OnApplicationBootstrap` create/stop recovery enumeration with no auto-launch;
|
|
||||||
- all POSIX/Windows/UNC/file/tilde path classes in the streaming redactor and definitive failed-stop response semantics;
|
|
||||||
- the full 14-column canonical task schema, fake-only Task 5 repository tests, migration-free certified Task 9 DB test, server-owned operation correlations/routes, and exact merged-SHA smoke;
|
|
||||||
- per-commit independent review, queue guards, exact-head PR review, squash merge, exact merged-next SHA/CI wait, worktree-bound smoke, issue-state readback, and reviewed tracker-closure PR with its own merged-next CI;
|
|
||||||
- a private DB URL loader captured/exported per session without recording or echoing the credential;
|
|
||||||
- an attached Task 9 implementation branch, provider-filtered exact issue-state readback, and machine-verified smoke JSON binding source/worktree/deployed SHA;
|
|
||||||
- a capped, duplicate-key-rejecting, exact-key/type smoke schema so report extensions or JSON boolean/float coercion cannot smuggle data or fake child-count evidence.
|
|
||||||
|
|
||||||
The plan remains deliberately **decision-PR-ready, not implementation-ready**. Fred authorized only Task 0's initial two-document PR on `next`. He must still return every Section 2 value, replace all `[FRED-GATE]` entries, certify storage/startup, and provide the W-F-dependent `IMPLEMENTATION_BASE_SHA` before Task 0 closes or Task 1 starts.
|
|
||||||
|
|
||||||
## Current status
|
|
||||||
|
|
||||||
- Gitea principal verified as `velma`; helper and API wrapper resolution are fail-closed and correct.
|
|
||||||
- Fred authorized the initial Task 0 docs-only PR in `comms/20260809T094952Z__from-fred__ec0e85.md` and confirmed its `next` lane/W-F descriptor corrections in `comms/20260809T095437Z__from-fred__4ee79d.md`.
|
|
||||||
- Provisional decisions: D1/D8/D9/D10/D13/D14 approved; D4 tool labels exactly `{Read, Grep, Glob}`; D5/D12 provisional; D11 remains a single-operator seam; D2/D3/D6/D7 and `IMPLEMENTATION_BASE_SHA` are `BLOCKED-ON-W-F`.
|
|
||||||
- Awaiting exact plan-PR-head review and Fred's complete D1–D15 binding contract.
|
|
||||||
- No source code, database, deployment, or live runtime changes made.
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
# WebUI Phase P — P2 Data + Auth Scratchpad
|
|
||||||
|
|
||||||
**Task ID:** WEBUI-P2
|
|
||||||
**Tracking ref:** stacked on PR #1143 (`feat/webui-p1-vite-skeleton`); no separate issue specified in the author brief
|
|
||||||
**Branch:** `feat/webui-p2-data-auth`
|
|
||||||
**Started:** 2026-08-09
|
|
||||||
**Role:** P2 author worker (must not modify `docs/TASKS.md`)
|
|
||||||
|
|
||||||
## Original tasking
|
|
||||||
|
|
||||||
> Read `/home/jwoltje/briefs/P2-brief.md` and execute it fully in `/home/jwoltje/src/stack-p1` on branch `feat/webui-p2-data-auth`. Run every listed verification gate, commit locally only, and do not push.
|
|
||||||
|
|
||||||
Container path mapping:
|
|
||||||
|
|
||||||
- Brief: `/home/jwoltje/distrobox-homes/mosaic-dev/briefs/P2-brief.md`
|
|
||||||
- Repo: `/home/jwoltje/distrobox-homes/mosaic-dev/src/stack-p1`
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Make the P1 Vite SPA authenticate against the Mosaic Gateway by same-origin relative paths, replace guest-route placeholders with real login/register/SSO callback pages, and enforce guest/authenticated route guards without modifying the parallel Next app tree except for unavoidable shared-library import fixes.
|
|
||||||
|
|
||||||
## Scope and invariants
|
|
||||||
|
|
||||||
- All SPA HTTP and Socket.IO access remains origin-relative (`/api/...`, `/api/auth/...`, `/chat`).
|
|
||||||
- No `NEXT_PUBLIC_*`, `VITE_*` origin setting, or hard-coded `http://localhost:14242` under `apps/web/src/`.
|
|
||||||
- Verify the Gateway BetterAuth mount path from source before choosing auth-client configuration.
|
|
||||||
- Delete the legacy static SSO-provider discovery module and test; runtime `/api/sso/providers` is canonical.
|
|
||||||
- Preserve the Next build while adding React Router guest pages and session guards.
|
|
||||||
- Never push; stage named files only; do not touch the modified `.mosaic/orchestrator/session.lock`.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Inspect P1 SPA structure, shared libraries, legacy Next auth pages/components, Gateway auth mount, and current test setup.
|
|
||||||
2. Add/adjust tests first for relative API behavior, auth-client origin configuration, relative Socket.IO singleton behavior, and all guard session states; run focused tests and capture expected RED failures.
|
|
||||||
3. Implement shared-library relative networking and remove the legacy SSO-provider module/imports.
|
|
||||||
4. Port login, register, and provider callback pages to React Router and wire routes.
|
|
||||||
5. Implement session-aware guest/auth guards and satisfy focused tests.
|
|
||||||
6. Run focused tests, all brief verification gates, invariant searches, and an independent code/security review; remediate and re-run affected gates.
|
|
||||||
7. Update this scratchpad with evidence, stage named files only, and commit locally.
|
|
||||||
|
|
||||||
## Testing strategy
|
|
||||||
|
|
||||||
- TDD is required because this increment changes authentication/session behavior.
|
|
||||||
- Primary situational evidence: jsdom router guard state tests plus guest-page/auth-flow contract tests already present or added as needed.
|
|
||||||
- Baseline gates: web tests, Vite build, Next build, typecheck, lint, and root format check exactly as listed in the brief.
|
|
||||||
- Browser automation is not required by the brief for P2; if used, it will be headless only.
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
No explicit user token cap was provided. Working soft cap: **30K tokens**, derived from a multi-file auth/frontend increment with tests, dual builds, review, and remediation. Keep implementation within the brief; no unrelated refactors or dependencies.
|
|
||||||
|
|
||||||
## Progress / evidence
|
|
||||||
|
|
||||||
- [x] Loaded mission protocol, active MVP manifest/scratchpad, top-level tasks, PRD, relevant frontend/auth/testing/type/review guides, and matching skills.
|
|
||||||
- [x] Resolved host paths to the Distrobox-mapped repo and brief.
|
|
||||||
- [x] Confirmed branch `feat/webui-p2-data-auth`, stacked at P1 commit `068d0f9b`.
|
|
||||||
- [x] Source and Gateway mount inspection complete.
|
|
||||||
- [x] RED tests observed.
|
|
||||||
- [x] Implementation complete.
|
|
||||||
- [x] Independent review complete and findings remediated.
|
|
||||||
- [x] Verification gates complete.
|
|
||||||
- [x] Local implementation commit created: `46d68e1f`.
|
|
||||||
|
|
||||||
### Source decisions
|
|
||||||
|
|
||||||
- Gateway source mounts BetterAuth at `/api/auth/` in `apps/gateway/src/auth/auth.controller.ts`; `packages/auth/src/auth.ts` configures `basePath: '/api/auth'`.
|
|
||||||
- BetterAuth 1.5.5 defaults its browser client to `/api/auth` when `baseURL` is omitted. `src/lib/auth-client.ts` therefore omits `baseURL`, preserving same-origin behavior without encoding any gateway origin.
|
|
||||||
- The only `src/app/` edit is the transitional Next provider redirect page. Deleting `src/lib/sso-providers.ts` required that mechanical consumer migration; it now uses the same runtime `/api/sso/providers` discovery and callback sanitizer as the SPA.
|
|
||||||
- GuestGuard pending behavior intentionally follows the approved brief: if no session object exists (including pending), render `<Outlet />`; only a present session redirects to `/chat`. AuthGuard alone renders the specified pending treatment.
|
|
||||||
|
|
||||||
### TDD evidence
|
|
||||||
|
|
||||||
- Initial focused run: 9 expected failures, proving absolute API/auth/socket origins, P1 guest placeholders, and missing guard redirects/loading behavior.
|
|
||||||
- After implementation: focused contract suite 15/15 passed.
|
|
||||||
- Security remediation RED: `%2F%0A%2F%2Fevil.example` reached `signIn.oauth2` as an external-normalizing callback target before the fix.
|
|
||||||
- Security remediation GREEN: shared `resolveAuthCallbackURL` unit suite 9/9 plus SPA callback regression 1/1 passed; both SPA and Next consumers use the shared helper.
|
|
||||||
|
|
||||||
### Independent review
|
|
||||||
|
|
||||||
- Primary Codex wrappers could not run because `jq` is absent; direct Codex fallback then failed authentication with HTTP 401. No review result was claimed from those attempts.
|
|
||||||
- Independent Claude Sonnet code review found no implementation-scope blocker, one pending-state UX suggestion, and one pre-existing `api.ts` type-assertion suggestion. The pending-state suggestion was rejected because it contradicts the brief's explicit GuestGuard contract; the API cleanup is outside P2's preserve-contract scope.
|
|
||||||
- Independent Claude Sonnet security review found a high-severity control-character open redirect in the callback prefix check shared by the newly ported SPA logic and transitional Next consumer.
|
|
||||||
- Remediation centralized WHATWG URL parsing plus exact current-origin comparison in `src/lib/auth-redirect.ts`, returns only path/search/hash, and added the RED-first regression above.
|
|
||||||
- Fresh code re-review: `approve`, 0 blockers, 0 should-fix findings.
|
|
||||||
- Fresh security re-review: `low`, 0 critical/high/medium/low findings.
|
|
||||||
|
|
||||||
### Documentation checklist disposition
|
|
||||||
|
|
||||||
- `docs/PRD.md` exists and P2 aligns to FR-8 / AC-7 authentication requirements.
|
|
||||||
- No Gateway endpoint, DTO, permission, or API schema changed; existing `/api/auth/*` and `/api/sso/providers` contracts are consumed unchanged, so OpenAPI/API-index updates are not applicable.
|
|
||||||
- Route paths were already present in the P1 route table; P2 replaces placeholders without changing site-map navigation, so `docs/SITEMAP.md` is unchanged.
|
|
||||||
- User/admin auth behavior is parity with the still-live Next implementation, not a new workflow. Implementation decisions, failure behavior, testing, and migration compatibility are documented here; no publishing action is in scope.
|
|
||||||
- Documentation remains in-repo; no generated publishing output was created.
|
|
||||||
|
|
||||||
### Final verification evidence
|
|
||||||
|
|
||||||
Run fresh after remediation from the locations required by the brief:
|
|
||||||
|
|
||||||
- `apps/web: pnpm test` — PASS: `Test Files 9 passed (9)`; `Tests 27 passed (27)`.
|
|
||||||
- `apps/web: pnpm build:vite` — PASS: `✓ 113 modules transformed`; `✓ built in 565ms`.
|
|
||||||
- `apps/web: pnpm build` — PASS: `✓ Compiled successfully in 4.6s`; 10/10 static pages generated; dynamic provider/project routes retained.
|
|
||||||
- `apps/web: pnpm typecheck` — PASS: `tsc --noEmit` exited 0.
|
|
||||||
- `apps/web: pnpm lint` — PASS: `eslint src` exited 0.
|
|
||||||
- Root `pnpm format:check` — PASS: `All matched files use Prettier code style!`.
|
|
||||||
- Invariant scan — PASS: no `NEXT_PUBLIC_*`, `VITE_*`, `GATEWAY_URL`, `http://localhost:14242`, or `sso-providers` references under `apps/web/src`.
|
|
||||||
- `git diff --check` — PASS.
|
|
||||||
- All six required gates above were repeated successfully after implementation commit `46d68e1f`, proving the exact committed source tree.
|
|
||||||
|
|
||||||
## Risks / blockers
|
|
||||||
|
|
||||||
- `.mosaic/orchestrator/session.lock` is modified by the active harness and must remain unstaged.
|
|
||||||
- P2 changes shared `src/lib/` modules consumed by both Vite and Next, so the Next build is a required compatibility gate.
|
|
||||||
|
|
||||||
## Remediation 1 — independent Node 26 verification failure
|
|
||||||
|
|
||||||
**Correction received:** 2026-08-09
|
|
||||||
|
|
||||||
The orchestrator rejected the P2 verification claim after an independent run under Node 26.4.0 produced 2 failed redirect tests and 2 unhandled errors. React Router passed jsdom's realm-local `AbortSignal` to Node 26's native undici `Request`, which rejects non-native signals. Production guard behavior is correct and must not change.
|
|
||||||
|
|
||||||
### Remediation constraints
|
|
||||||
|
|
||||||
- Preserve both redirect assertions and all `guards.tsx` behavior; no skips, weakening, or test deletion.
|
|
||||||
- Add the smallest Vitest environment repair so global `AbortController` / `AbortSignal` are constructors accepted by native undici `Request`.
|
|
||||||
- Run all six required gates, commit named files locally without pushing, leave `.mosaic/orchestrator/session.lock` untouched, then run `apps/web: pnpm test` as the final worktree action.
|
|
||||||
- Completion requires zero failed tests, zero test errors, and zero unhandled rejections.
|
|
||||||
|
|
||||||
### Remediation plan
|
|
||||||
|
|
||||||
1. Reproduce under Node 26.4.0 if an ephemeral matching runtime is available.
|
|
||||||
2. Add a Vitest `setupFiles` module deriving Node-native abort constructors from `node:util` and register it in `apps/web/vitest.config.ts`.
|
|
||||||
3. Add a direct regression assertion that a global controller's signal is accepted by native `Request`, while retaining the existing redirect behavior tests unchanged.
|
|
||||||
4. Run focused Node 22 and Node 26 tests, independent review, all required gates, and local commit(s).
|
|
||||||
5. After every edit/commit/status check is complete, run `pnpm test` from `apps/web` as the last command.
|
|
||||||
|
|
||||||
### Remediation implementation and evidence
|
|
||||||
|
|
||||||
- Reproduced under ephemeral Node `v26.4.0`: `Test Files 1 failed (1)`, `Tests 2 failed | 4 passed (6)`, `Errors 2 errors`, with the exact undici `AbortSignal` realm rejection from the remediation brief.
|
|
||||||
- Added `apps/web/src/test/setup.ts`, registered through `vitest.config.ts#setupFiles`. It derives Node-native abort constructors from the built-in `node:util.transferableAbortController()` and aligns both `globalThis` and jsdom `window`; it does not replace `Request`, `Response`, or `fetch`.
|
|
||||||
- Explicitly pinned Vitest `isolate: true` so the test-only global constructors cannot leak between test-file environments.
|
|
||||||
- Added `src/test/setup.spec.ts`, which proves a global controller signal is accepted by Node's native `Request` and that abort propagation remains functional.
|
|
||||||
- Existing `guards.spec.tsx` and production `guards.tsx` remain unchanged.
|
|
||||||
- Focused Node 26.4.0 remediation run: `Test Files 2 passed (2)` and `Tests 7 passed (7)`, with zero errors/unhandled rejections.
|
|
||||||
- Preliminary full Node 26.4.0 run: `Test Files 9 passed (9)` and `Tests 27 passed (27)`, with zero errors/unhandled rejections before the direct setup regression was added.
|
|
||||||
- No dependency was added; `node:util` is a Node built-in.
|
|
||||||
|
|
||||||
### Remediation independent review
|
|
||||||
|
|
||||||
- First code review: approve, 0 blockers, 0 should-fix; suggested a direct Request regression and version-neutral comment.
|
|
||||||
- First security/integrity review: low risk; suggested pinning test isolation explicitly.
|
|
||||||
- All suggestions were applied.
|
|
||||||
- Fresh code re-review: approve, 0 blockers, 0 should-fix, no findings.
|
|
||||||
- Fresh security/integrity re-review: low risk, 0 findings.
|
|
||||||
|
|
||||||
### Remediation pre-commit gate evidence
|
|
||||||
|
|
||||||
- Node 26.4.0 `pnpm test`: `Test Files 10 passed (10)`; `Tests 28 passed (28)`; zero errors and zero unhandled rejections.
|
|
||||||
- `pnpm typecheck`: `tsc --noEmit` exited 0.
|
|
||||||
- `pnpm build:vite`: `✓ 113 modules transformed`; `✓ built in 960ms`.
|
|
||||||
- `pnpm build` (Next): `✓ Compiled successfully in 4.8s`; static pages generated 10/10.
|
|
||||||
- `pnpm lint`: `eslint src` exited 0.
|
|
||||||
- Root `pnpm format:check`: `All matched files use Prettier code style!`.
|
|
||||||
- Final post-commit non-test gates and final-action Node 26 `pnpm test` remain required before reporting completion.
|
|
||||||
|
|
||||||
### Remediation committed verification
|
|
||||||
|
|
||||||
- Implementation commit: `e16c08aa` (`test(web): align jsdom abort signals with Node`).
|
|
||||||
- Post-commit `pnpm typecheck`: `tsc --noEmit` exited 0.
|
|
||||||
- Post-commit `pnpm build:vite`: `✓ 113 modules transformed`; `✓ built in 323ms`.
|
|
||||||
- Post-commit `pnpm build` (Next): `✓ Compiled successfully in 4.6s`; static pages generated 10/10.
|
|
||||||
- Post-commit `pnpm lint`: `eslint src` exited 0.
|
|
||||||
- Post-commit root `pnpm format:check`: `All matched files use Prettier code style!`.
|
|
||||||
- Final Node 26 `pnpm test` will be the last worktree action and its verbatim output will be reported to the orchestrator.
|
|
||||||
@@ -85,10 +85,6 @@ function Get-QueueState {
|
|||||||
$state = "$($Payload.state)".ToLowerInvariant()
|
$state = "$($Payload.state)".ToLowerInvariant()
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($pending -contains $state) { return "pending" }
|
|
||||||
if ($failure -contains $state) { return "terminal-failure" }
|
|
||||||
if ($success -contains $state) { return "terminal-success" }
|
|
||||||
|
|
||||||
$values = @()
|
$values = @()
|
||||||
$statuses = @()
|
$statuses = @()
|
||||||
if ($null -ne $Payload.statuses) { $statuses = @($Payload.statuses) }
|
if ($null -ne $Payload.statuses) { $statuses = @($Payload.statuses) }
|
||||||
@@ -101,7 +97,15 @@ function Get-QueueState {
|
|||||||
if (-not [string]::IsNullOrEmpty($v)) { $values += $v }
|
if (-not [string]::IsNullOrEmpty($v)) { $values += $v }
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($values.Count -eq 0 -and [string]::IsNullOrEmpty($state)) { return "no-status" }
|
# Zero contexts is classified FIRST: Gitea reports a synthetic aggregate
|
||||||
|
# state of "pending" alongside statuses:null / total_count:0 (a commit
|
||||||
|
# with no CI at all), and honoring that aggregate would poll to the
|
||||||
|
# timeout. With zero contexts there is nothing to wait on.
|
||||||
|
if ($values.Count -eq 0) { return "no-status" }
|
||||||
|
|
||||||
|
if ($pending -contains $state) { return "pending" }
|
||||||
|
if ($failure -contains $state) { return "terminal-failure" }
|
||||||
|
if ($success -contains $state) { return "terminal-success" }
|
||||||
if (($values | Where-Object { $pending -contains $_ }).Count -gt 0) { return "pending" }
|
if (($values | Where-Object { $pending -contains $_ }).Count -gt 0) { return "pending" }
|
||||||
if (($values | Where-Object { $failure -contains $_ }).Count -gt 0) { return "terminal-failure" }
|
if (($values | Where-Object { $failure -contains $_ }).Count -gt 0) { return "terminal-failure" }
|
||||||
if ($values.Count -gt 0 -and ($values | Where-Object { -not ($success -contains $_) }).Count -eq 0) { return "terminal-success" }
|
if ($values.Count -gt 0 -and ($values | Where-Object { -not ($success -contains $_) }).Count -eq 0) { return "terminal-success" }
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ except Exception:
|
|||||||
print("malformed")
|
print("malformed")
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
# Gitea returns "statuses": null (not []) for a commit with zero status
|
||||||
|
# contexts -- e.g. any repo with no CI configured. Treat null as empty.
|
||||||
raw_statuses = payload.get("statuses", [])
|
raw_statuses = payload.get("statuses", [])
|
||||||
|
if raw_statuses is None:
|
||||||
|
raw_statuses = []
|
||||||
raw_state = payload.get("state", "")
|
raw_state = payload.get("state", "")
|
||||||
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
|
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
|
||||||
print("malformed")
|
print("malformed")
|
||||||
@@ -75,14 +79,18 @@ for item in statuses:
|
|||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
values.append(raw_value.lower())
|
values.append(raw_value.lower())
|
||||||
|
|
||||||
if any(value in pending_values for value in values) or state in pending_values:
|
# Zero contexts is classified FIRST: Gitea reports a synthetic aggregate
|
||||||
|
# state of "pending" alongside total_count:0, and an aggregate with no
|
||||||
|
# contexts behind it must not read as an in-flight pipeline (it would poll
|
||||||
|
# to the timeout). With zero contexts there is nothing to wait on.
|
||||||
|
if not values:
|
||||||
|
print("no-status")
|
||||||
|
elif any(value in pending_values for value in values) or state in pending_values:
|
||||||
print("pending")
|
print("pending")
|
||||||
elif any(value in failure_values for value in values) or state in failure_values:
|
elif any(value in failure_values for value in values) or state in failure_values:
|
||||||
print("terminal-failure")
|
print("terminal-failure")
|
||||||
elif values and all(value in success_values for value in values) and state in {"", "success"}:
|
elif all(value in success_values for value in values) and state in {"", "success"}:
|
||||||
print("terminal-success")
|
print("terminal-success")
|
||||||
elif not values:
|
|
||||||
print("no-status")
|
|
||||||
else:
|
else:
|
||||||
print("unknown")
|
print("unknown")
|
||||||
'
|
'
|
||||||
@@ -467,22 +475,19 @@ while true; do
|
|||||||
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
||||||
exit 3
|
exit 3
|
||||||
fi
|
fi
|
||||||
|
# A head with zero status contexts has no CI queue to wait on.
|
||||||
|
# For push, that is queue-clear (a repo with no CI must remain
|
||||||
|
# pushable) -- mirroring record_cannot_assert's dispositions
|
||||||
|
# (push=degraded-pass, merge=hold). Merge stays fail-closed:
|
||||||
|
# no-status there may just mean CI has not reported yet.
|
||||||
if [[ "$PURPOSE" == "push" ]]; then
|
if [[ "$PURPOSE" == "push" ]]; then
|
||||||
echo "[ci-queue-wait] queue-clear state=no-status purpose=push branch=${BRANCH}; no queued or running CI."
|
echo "[ci-queue-wait] no status contexts on ${BRANCH} head (state=no-status) — no CI queue to wait on; queue clear."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
exit 3
|
exit 3
|
||||||
;;
|
;;
|
||||||
terminal-failure)
|
terminal-failure|malformed|unknown)
|
||||||
if [[ "$PURPOSE" == "push" ]]; then
|
|
||||||
echo "[ci-queue-wait] queue-clear state=terminal-failure purpose=push branch=${BRANCH}; no queued or running CI."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Error: ASSERTED_NOT_READY state=terminal-failure purpose=${PURPOSE} branch=${BRANCH}." >&2
|
|
||||||
exit 3
|
|
||||||
;;
|
|
||||||
malformed|unknown)
|
|
||||||
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
exit 3
|
exit 3
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print(
|
|||||||
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
|
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
|
||||||
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
|
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
|
||||||
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||||
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
|
if [[ "$BASE_BRANCH" != "main" ]]; then
|
||||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
|
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -43,22 +43,22 @@ SH
|
|||||||
chmod +x "$STUB_DIR/gh"
|
chmod +x "$STUB_DIR/gh"
|
||||||
|
|
||||||
run_guard() {
|
run_guard() {
|
||||||
local mode="$1" purpose="${2:-push}"
|
local mode="$1"
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR" || exit
|
cd "$REPO_DIR" || exit
|
||||||
export PATH="$STUB_DIR:$PATH"
|
export PATH="$STUB_DIR:$PATH"
|
||||||
export MOSAIC_GH_CHECK_MODE="$mode"
|
export MOSAIC_GH_CHECK_MODE="$mode"
|
||||||
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
|
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
|
||||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
||||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "$purpose" -t 0 -i 0
|
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose push -t 0 -i 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
failures=0
|
failures=0
|
||||||
assert_case() {
|
assert_case() {
|
||||||
local mode="$1" expected_rc="$2" expected_state="$3" purpose="${4:-push}" output rc
|
local mode="$1" expected_rc="$2" expected_state="$3" output rc
|
||||||
set +e
|
set +e
|
||||||
output=$(run_guard "$mode" "$purpose" 2>&1)
|
output=$(run_guard "$mode" 2>&1)
|
||||||
rc=$?
|
rc=$?
|
||||||
set -e
|
set -e
|
||||||
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
|
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
|
||||||
@@ -79,12 +79,10 @@ set -e
|
|||||||
: > "$WORK_DIR/gh-calls.log"
|
: > "$WORK_DIR/gh-calls.log"
|
||||||
assert_case success zero terminal-success
|
assert_case success zero terminal-success
|
||||||
assert_case pending nonzero pending
|
assert_case pending nonzero pending
|
||||||
assert_case failure zero terminal-failure
|
assert_case failure nonzero terminal-failure
|
||||||
assert_case late-failure zero terminal-failure
|
assert_case late-failure nonzero terminal-failure
|
||||||
assert_case failure nonzero terminal-failure merge
|
|
||||||
assert_case late-failure nonzero terminal-failure merge
|
|
||||||
|
|
||||||
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 6 ]]; then
|
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 4 ]]; then
|
||||||
echo "FAIL: expected every case to query all Checks API pages" >&2
|
echo "FAIL: expected every case to query all Checks API pages" >&2
|
||||||
failures=$((failures + 1))
|
failures=$((failures + 1))
|
||||||
fi
|
fi
|
||||||
@@ -94,4 +92,4 @@ if [[ "$failures" -ne 0 ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "GitHub check-runs regression passed (6/6 purpose-aware cases, including later-page failure)"
|
echo "GitHub check-runs regression passed (4/4 cases, including later-page failure)"
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for ci-queue-wait.sh's zero-status-context handling.
|
||||||
|
#
|
||||||
|
# Gitea's combined-status endpoint returns, for a commit with NO status
|
||||||
|
# contexts (e.g. a repo with no CI configured at all):
|
||||||
|
# {"state":"pending","sha":"...","total_count":0,"statuses":null,...}
|
||||||
|
# -- statuses is JSON null (not []), and state is a synthetic "pending"
|
||||||
|
# even though nothing is running. Captured live from a Gitea 1.22 host,
|
||||||
|
# 2026-08-09 (jason.woltje/jarvis-brain, a repo with no pipeline).
|
||||||
|
#
|
||||||
|
# Before the fix, `payload.get("statuses", [])` received null, failed the
|
||||||
|
# isinstance(list) check, and the guard reported ASSERTED_NOT_READY
|
||||||
|
# state=malformed (exit 3) -- blocking every push to a CI-less repo. Had
|
||||||
|
# null been tolerated, the synthetic aggregate "pending" would instead
|
||||||
|
# have polled to the timeout (exit 124). The fix must:
|
||||||
|
# 1. treat statuses:null as an empty list, and
|
||||||
|
# 2. classify zero status VALUES as "no-status" regardless of the
|
||||||
|
# synthetic aggregate state, and
|
||||||
|
# 3. treat no-status for --purpose push (without --require-status) as
|
||||||
|
# queue-clear (exit 0) -- a repo with no CI has no queue to wait on;
|
||||||
|
# this mirrors record_cannot_assert's disposition table
|
||||||
|
# (push=degraded-pass, merge=hold).
|
||||||
|
#
|
||||||
|
# Covers:
|
||||||
|
# (a) statuses:null + synthetic state:pending, purpose=push
|
||||||
|
# -> exit 0, queue-clear/no-status message (THE live fault).
|
||||||
|
# (b) same payload, purpose=merge -> still fail-closed (exit 3).
|
||||||
|
# (c) same payload, push + --require-status -> still fail-closed (exit 3).
|
||||||
|
# (d) statuses:[] + state:"" -> same as (a) (exit 0).
|
||||||
|
# (e) a real pending context -> still polls (times out, 124),
|
||||||
|
# proving the relaxation didn't swallow genuine pending states.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-no-status}"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
STUB_DIR="$WORK_DIR/stubs"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||||
|
|
||||||
|
# Minimal curl stub (same conventions as test-ci-queue-wait-branch-absent.sh):
|
||||||
|
# branch lookup answers 200 with a fixed SHA; the status endpoint's payload
|
||||||
|
# is selected by MOSAIC_STUB_STATUS_MODE.
|
||||||
|
cat > "$STUB_DIR/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
has_w=0
|
||||||
|
url=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-w) has_w=1 ;;
|
||||||
|
http://*|https://*) url="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$url" in
|
||||||
|
*/branches/*)
|
||||||
|
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
|
||||||
|
if [[ "$has_w" == 1 ]]; then
|
||||||
|
printf '%s\n200' "$body"
|
||||||
|
else
|
||||||
|
printf '%s' "$body"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*/status)
|
||||||
|
mode="${MOSAIC_STUB_STATUS_MODE:?MOSAIC_STUB_STATUS_MODE not set}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "curl stub: unrecognized URL: $url" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
# Verbatim shape of the live Gitea response (repository object elided).
|
||||||
|
gitea-null-statuses)
|
||||||
|
body='{"state":"pending","sha":"deadbeefcafef00d0123456789abcdef01234567","total_count":0,"statuses":null,"url":"","commit_url":""}'
|
||||||
|
;;
|
||||||
|
empty-statuses)
|
||||||
|
body='{"state":"","statuses":[]}'
|
||||||
|
;;
|
||||||
|
real-pending)
|
||||||
|
body='{"state":"pending","statuses":[{"context":"ci/woodpecker","status":"running","target_url":""}]}'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "curl stub: unknown mode=$mode" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf '%s' "$body"
|
||||||
|
SH
|
||||||
|
chmod +x "$STUB_DIR/curl"
|
||||||
|
|
||||||
|
run_ci_queue_wait() {
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
export PATH="$STUB_DIR:$PATH"
|
||||||
|
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||||
|
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
||||||
|
export GITEA_TOKEN="stub-token"
|
||||||
|
export GITEA_URL="https://git.example.test"
|
||||||
|
"$SCRIPT_DIR/ci-queue-wait.sh" -B main -t 3 -i 1 "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
# (a) THE live fault: statuses:null + synthetic pending, purpose=push -> exit 0.
|
||||||
|
set +e
|
||||||
|
out_a=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose push 2>&1)
|
||||||
|
status_a=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$status_a" -ne 0 ]]; then
|
||||||
|
echo "FAIL(a): expected exit 0 for null-statuses/no-CI repo on push, got $status_a" >&2
|
||||||
|
echo "$out_a" >&2
|
||||||
|
fail=1
|
||||||
|
elif [[ "$out_a" == *"malformed"* ]]; then
|
||||||
|
echo "FAIL(a): null statuses must not be classified as malformed" >&2
|
||||||
|
echo "$out_a" >&2
|
||||||
|
fail=1
|
||||||
|
elif [[ "$out_a" != *"no-status"* ]]; then
|
||||||
|
echo "FAIL(a): expected a no-status/queue-clear message, got:" >&2
|
||||||
|
echo "$out_a" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# (b) Same payload, purpose=merge -> hold, fail-closed exit 3.
|
||||||
|
set +e
|
||||||
|
out_b=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose merge 2>&1)
|
||||||
|
status_b=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$status_b" -ne 3 ]]; then
|
||||||
|
echo "FAIL(b): expected exit 3 for no-status on merge, got $status_b" >&2
|
||||||
|
echo "$out_b" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# (c) Same payload, push + --require-status -> strictness opt-in still fails.
|
||||||
|
set +e
|
||||||
|
out_c=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose push --require-status 2>&1)
|
||||||
|
status_c=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$status_c" -ne 3 ]]; then
|
||||||
|
echo "FAIL(c): expected exit 3 for --require-status with no contexts, got $status_c" >&2
|
||||||
|
echo "$out_c" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# (d) statuses:[] + state:"" (the shape the code always tolerated) -> exit 0 on push.
|
||||||
|
set +e
|
||||||
|
out_d=$(MOSAIC_STUB_STATUS_MODE=empty-statuses run_ci_queue_wait --purpose push 2>&1)
|
||||||
|
status_d=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$status_d" -ne 0 ]]; then
|
||||||
|
echo "FAIL(d): expected exit 0 for empty-statuses on push, got $status_d" >&2
|
||||||
|
echo "$out_d" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# (e) A REAL pending context must still block: polls to timeout, exit 124.
|
||||||
|
set +e
|
||||||
|
out_e=$(MOSAIC_STUB_STATUS_MODE=real-pending run_ci_queue_wait --purpose push 2>&1)
|
||||||
|
status_e=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$status_e" -ne 124 ]]; then
|
||||||
|
echo "FAIL(e): expected exit 124 (timeout) for a genuinely pending context, got $status_e" >&2
|
||||||
|
echo "$out_e" >&2
|
||||||
|
fail=1
|
||||||
|
elif [[ "$out_e" != *"ci/woodpecker=running"* ]]; then
|
||||||
|
echo "FAIL(e): expected the pending context to be reported, got:" >&2
|
||||||
|
echo "$out_e" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
echo "ci-queue-wait no-status regression passed (5/5 cases)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$fail"
|
||||||
@@ -53,7 +53,6 @@ case "$url" in
|
|||||||
malformed) printf '%s' 'not-json' ;;
|
malformed) printf '%s' 'not-json' ;;
|
||||||
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
|
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
|
||||||
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
|
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
|
||||||
unknown) printf '%s' '{"state":"success","statuses":[{"status":"cancelled"}]}' ;;
|
|
||||||
large-success)
|
large-success)
|
||||||
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
|
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
|
||||||
;;
|
;;
|
||||||
@@ -129,27 +128,18 @@ run_assertion() {
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
: > "$WORK_DIR/urls.log"
|
: > "$WORK_DIR/urls.log"
|
||||||
# Push readiness is queue clearance, not proof that prior CI succeeded.
|
run_assertion success zero success 'state=terminal-success'
|
||||||
run_assertion push-success zero success 'state=terminal-success'
|
run_assertion pending nonzero pending 'ASSERTED_NOT_READY'
|
||||||
run_assertion push-pending nonzero pending 'ASSERTED_NOT_READY'
|
run_assertion failure nonzero failure 'ASSERTED_NOT_READY'
|
||||||
run_assertion push-failure zero failure 'queue-clear state=terminal-failure purpose=push'
|
run_assertion no-status nonzero no-status 'ASSERTED_NOT_READY'
|
||||||
run_assertion push-no-status zero no-status 'queue-clear state=no-status purpose=push'
|
run_assertion aggregate-success-no-status nonzero aggregate-success-no-status 'ASSERTED_NOT_READY'
|
||||||
run_assertion push-aggregate-success-no-status zero aggregate-success-no-status 'queue-clear state=no-status purpose=push'
|
run_assertion malformed nonzero malformed 'ASSERTED_NOT_READY'
|
||||||
run_assertion push-require-status nonzero no-status 'ASSERTED_NOT_READY state=no-status' --require-status
|
run_assertion malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
|
||||||
run_assertion push-malformed nonzero malformed 'ASSERTED_NOT_READY'
|
run_assertion malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
|
||||||
run_assertion push-malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
|
run_assertion large-payload not126 large-success 'state=terminal-success'
|
||||||
run_assertion push-malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
|
|
||||||
run_assertion push-unknown nonzero unknown 'ASSERTED_NOT_READY'
|
|
||||||
run_assertion push-large-payload not126 large-success 'state=terminal-success'
|
|
||||||
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
|
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
|
||||||
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
|
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
|
||||||
|
|
||||||
# Merge readiness remains fail-closed and requires exact-head terminal success.
|
|
||||||
MOSAIC_TEST_PURPOSE=merge run_assertion merge-success zero success 'state=terminal-success'
|
|
||||||
MOSAIC_TEST_PURPOSE=merge run_assertion merge-failure nonzero failure 'ASSERTED_NOT_READY state=terminal-failure'
|
|
||||||
MOSAIC_TEST_PURPOSE=merge run_assertion merge-no-status nonzero no-status 'ASSERTED_NOT_READY state=no-status'
|
|
||||||
MOSAIC_TEST_PURPOSE=merge run_assertion merge-unknown nonzero unknown 'ASSERTED_NOT_READY state=unknown'
|
|
||||||
|
|
||||||
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
|
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
|
||||||
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
|
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
|
||||||
failures=$((failures + 1))
|
failures=$((failures + 1))
|
||||||
|
|||||||
@@ -17,10 +17,9 @@ make_fixture() {
|
|||||||
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
|
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
|
||||||
git -C "$root/repo" init -q
|
git -C "$root/repo" init -q
|
||||||
git -C "$root/repo" remote add origin "$remote"
|
git -C "$root/repo" remote add origin "$remote"
|
||||||
local base_branch="${3:-main}"
|
|
||||||
cat > "$tools/pr-metadata.sh" <<SH
|
cat > "$tools/pr-metadata.sh" <<SH
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
printf '%s\n' '{"baseRefName":"$base_branch","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
|
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
|
||||||
SH
|
SH
|
||||||
cat > "$tools/ci-queue-wait.sh" <<'SH'
|
cat > "$tools/ci-queue-wait.sh" <<'SH'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
@@ -30,8 +29,8 @@ SH
|
|||||||
}
|
}
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
make_fixture gitea https://git.example.test/acme/widgets.git next
|
make_fixture gitea https://git.example.test/acme/widgets.git
|
||||||
make_fixture github https://github.com/acme/widgets.git main
|
make_fixture github https://github.com/acme/widgets.git
|
||||||
|
|
||||||
cat > "$WORK_DIR/gitea/curl" <<'SH'
|
cat > "$WORK_DIR/gitea/curl" <<'SH'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|||||||
Generated
+68
-774
File diff suppressed because it is too large
Load Diff
@@ -75,16 +75,6 @@ export async function installHooks({
|
|||||||
} = {}) {
|
} = {}) {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
|
|
||||||
try {
|
|
||||||
await execFileAsync('git', ['--version']);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code === 'ENOENT') {
|
|
||||||
console.warn('git not found; skipping hook installation');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const huskyDir = path.join(root, '.husky');
|
const huskyDir = path.join(root, '.husky');
|
||||||
const active = path.join(huskyDir, '_');
|
const active = path.join(huskyDir, '_');
|
||||||
const nonce = `${Date.now()}-${process.pid}`;
|
const nonce = `${Date.now()}-${process.pid}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user