diff --git a/.env.example b/.env.example index 4bb1f818..440d185e 100644 --- a/.env.example +++ b/.env.example @@ -149,6 +149,5 @@ OTEL_SERVICE_NAME=mosaic-gateway # KEYCLOAK_CLIENT_ID=mosaic # KEYCLOAK_CLIENT_SECRET= -# Feature flags — set to true alongside provider credentials to show SSO buttons in the UI -# NEXT_PUBLIC_WORKOS_ENABLED=true -# NEXT_PUBLIC_KEYCLOAK_ENABLED=true +# The web login page discovers configured providers dynamically from +# GET /api/sso/providers. No NEXT_PUBLIC_* provider feature flag is required. diff --git a/.gitignore b/.gitignore index e86bc179..611a9907 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ coverage *.tsbuildinfo .pnpm-store __pycache__/ -docs/reports/ +docs/.obsidian # Step-CA dev password — real file is gitignored; commit only the .example infra/step-ca/dev-password diff --git a/.woodpecker/ci-image.yml b/.woodpecker/ci-image.yml index c89e24ac..9d40b903 100644 --- a/.woodpecker/ci-image.yml +++ b/.woodpecker/ci-image.yml @@ -22,9 +22,9 @@ steps: image: gcr.io/kaniko-project/executor:debug environment: REGISTRY_USER: - from_secret: gitea_username + from_secret: REGISTRY_USERNAME REGISTRY_PASS: - from_secret: gitea_password + from_secret: REGISTRY_PASSWORD CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH} CI_COMMIT_TAG: ${CI_COMMIT_TAG} CI_COMMIT_SHA: ${CI_COMMIT_SHA} diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index c02f3f1c..35acff9e 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -30,6 +30,19 @@ steps: # the baked pnpm store. - pnpm install --frozen-lockfile --prefer-offline + # --------------------------------------------------------------------------- + # The steps below (sanitization, upgrade-guard, typecheck, lint, format, + # test) are the COMPLETE mandatory verification set. SDLC-D-034 mirrors them + # one-for-one in the canonical terminal verification command — root + # `pnpm verify:release` (scripts/verify-release.mjs) — which the publish + # pipeline (.woodpecker/publish.yml `verify` step) runs before ANY publish + # effect. These lines stay direct (not routed through the runner) because the + # #1017 test-enumeration guard audits framework tool paths through THIS + # surface; scripts/verify-release.test.mjs enforces that the runner's stage + # table keeps matching these commands exactly, so the two cannot drift. + # --------------------------------------------------------------------------- + + # Canonical verify:release stage `sanitization`. # Blocking gate: public framework package must contain no operator-specific # personal data or private $HOME defaults. Runs early (no node_modules needed). sanitization: @@ -47,6 +60,7 @@ steps: # with everything it guards; this direct line keeps one instrument running. - bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh + # Canonical verify:release stage `upgrade-guard`. # Blocking gate (#791): a framework upgrade must never write or delete an # operator-owned path. The HARD GATE proves an unanticipated operator sentinel # survives a keep-mode reseed byte-identical (with rsync present AND absent — @@ -68,6 +82,8 @@ steps: - bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh - bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh + # Canonical verify:release stage `typecheck` — the same `pnpm typecheck` + # invocation (which runs the checkout preflight first, then turbo). typecheck: image: *node_image commands: @@ -78,7 +94,8 @@ steps: - sanitization - upgrade-guard - # lint, format, and test are independent — run in parallel after typecheck + # lint, format, and test are independent — run in parallel after typecheck. + # Each runs exactly its canonical verify:release stage command. lint: image: *node_image commands: @@ -95,6 +112,12 @@ steps: depends_on: - typecheck + # Canonical verify:release stage `test` — the `pnpm test` line below is the + # shared command; everything else in this step is PIPELINE-LEVEL + # prerequisite the canonical command expects its caller to provide (SDLC-D-034): + # the ci-postgres service + pg_isready wait + db:migrate (postgres path), + # `apk add openssl`, and the pinned pi install. None of those can move into + # the runner (it must also work locally on the PGlite path with no database). test: image: *node_image environment: @@ -109,6 +132,16 @@ steps: # `apk add` guarantees openssl is present on PR pipelines too (and is a # fast no-op once the rebuilt image already ships it). - apk add --no-cache openssl + # Pi runtime (Invariant R): invariant_r_unittest.py hard-requires an + # installed `pi` binary at exactly this measured version — the test + # boots Pi's real tool registry to prove the read-only carve-out + # resolves to real, unshadowed builtins, and fails loud (by design) + # when the runtime is absent or drifts. The canonical Pi is + # @earendil-works/pi-coding-agent@0.84.1 exactly (@mariozechner/* is + # embedded-legacy). Step-level install because ci-base image publishes + # are currently blocked on registry auth; fold into Dockerfile.ci once + # that is fixed, keeping this as a fast no-op guard. + - npm install -g @earendil-works/pi-coding-agent@0.84.1 # postgresql-client (pg_isready) is baked into ci-base. # Wait up to 60s for CI postgres to be ready; fail fast if it never comes up. - | diff --git a/.woodpecker/publish.yml b/.woodpecker/publish.yml index cce35805..2249b1c1 100644 --- a/.woodpecker/publish.yml +++ b/.woodpecker/publish.yml @@ -1,5 +1,19 @@ # Build, publish npm packages, and push Docker images # Runs on main for stable publishes and on next for integration-line prereleases/images +# +# SDLC-D-034 publish gate: every publish effect (publish-npm, publish-next-npm, +# and every image build/push step) depends DIRECTLY on the `verify` step below. +# `verify` (a) asserts the provider's commit identity matches the actual +# checkout (CI_COMMIT_SHA == git rev-parse HEAD, fail closed on mismatch or +# emptiness) and (b) runs the canonical terminal verification command +# (`pnpm verify:release`), which mirrors the PR CI pipeline's complete +# mandatory set (sanitization, upgrade-guard, preflight+typecheck, lint, +# format:check, test, build) — see scripts/verify-release.mjs. A missing, +# failed, skipped, cancelled, or inconclusive verification therefore skips the +# dependent publish effects (fail closed). Path-filtered short-circuits may +# skip publish EFFECTS (e.g. docs-only merges) but never bypass `verify` for a +# publish that does run: `verify` itself carries no path filter. +# scripts/verify-release.test.mjs enforces this DAG invariant at checkout time. variables: # Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine + @@ -48,6 +62,45 @@ steps: # Resolve from the baked pnpm store instead of a cold network fetch. - pnpm install --frozen-lockfile --prefer-offline + # SDLC-D-034 exact-commit publish gate. No `when`/path filter on purpose: it + # runs for every event this pipeline serves so no publish effect can ever + # start without it. Fails closed on commit-identity mismatch (or either SHA + # being empty) and on any incomplete verification. + verify: + image: *node_image + commands: + - *enable_pnpm + # (a) Commit identity: the provider's claimed SHA must equal the actual + # checkout HEAD — verification of anything else must never authorize a + # publish of this commit. + - | + if [ -z "$CI_COMMIT_SHA" ]; then + echo "[verify] FATAL: CI_COMMIT_SHA is empty — cannot certify commit identity" >&2 + exit 1 + fi + CHECKOUT_SHA="$(git rev-parse HEAD 2>/dev/null || true)" + if [ -z "$CHECKOUT_SHA" ]; then + echo "[verify] FATAL: git rev-parse HEAD returned nothing — cannot certify commit identity" >&2 + exit 1 + fi + if [ "$CI_COMMIT_SHA" != "$CHECKOUT_SHA" ]; then + echo "[verify] FATAL: provider commit ($CI_COMMIT_SHA) != checkout HEAD ($CHECKOUT_SHA)" >&2 + exit 1 + fi + echo "[verify] commit identity confirmed: $CHECKOUT_SHA" + # (b) Canonical terminal verification. Caller-provided prerequisites the + # runner expects (see .woodpecker/ci.yml comments): bash/rsync for the + # guard stages, openssl + the pinned pi binary for the test stage. git is + # baked into ci-base but re-asserted here so the identity check above can + # never silently depend on a stale baked image. DATABASE_URL is + # deliberately NOT set: the canonical command must hold on the PGlite + # path too and never sets or requires a database itself. + - apk add --no-cache bash rsync openssl git + - npm install -g @earendil-works/pi-coding-agent@0.84.1 + - pnpm verify:release + depends_on: + - install + build: image: *node_image commands: @@ -55,6 +108,7 @@ steps: - pnpm build depends_on: - install + - verify publish-npm: image: *node_image @@ -114,6 +168,7 @@ steps: exit 1 depends_on: - build + - verify publish-next-npm: image: *node_image @@ -192,6 +247,7 @@ steps: echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION" depends_on: - build + - verify # TODO: Uncomment when ready to publish to npmjs.org # publish-npmjs: @@ -205,6 +261,7 @@ steps: # - bash scripts/publish-npmjs.sh # depends_on: # - build + # - verify # when: # - event: [tag] @@ -213,9 +270,9 @@ steps: when: *image_build_when environment: REGISTRY_USER: - from_secret: gitea_username + from_secret: REGISTRY_USERNAME REGISTRY_PASS: - from_secret: gitea_password + from_secret: REGISTRY_PASSWORD CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH} CI_COMMIT_TAG: ${CI_COMMIT_TAG} CI_COMMIT_SHA: ${CI_COMMIT_SHA} @@ -242,15 +299,16 @@ steps: /kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS depends_on: - build + - verify build-appservice: image: gcr.io/kaniko-project/executor:debug when: *main_image_build_when environment: REGISTRY_USER: - from_secret: gitea_username + from_secret: REGISTRY_USERNAME REGISTRY_PASS: - from_secret: gitea_password + from_secret: REGISTRY_PASSWORD CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH} CI_COMMIT_TAG: ${CI_COMMIT_TAG} CI_COMMIT_SHA: ${CI_COMMIT_SHA} @@ -268,15 +326,16 @@ steps: /kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS depends_on: - build + - verify build-web: image: gcr.io/kaniko-project/executor:debug when: *main_image_build_when environment: REGISTRY_USER: - from_secret: gitea_username + from_secret: REGISTRY_USERNAME REGISTRY_PASS: - from_secret: gitea_password + from_secret: REGISTRY_PASSWORD CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH} CI_COMMIT_TAG: ${CI_COMMIT_TAG} CI_COMMIT_SHA: ${CI_COMMIT_SHA} @@ -294,3 +353,4 @@ steps: /kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS depends_on: - build + - verify diff --git a/AGENTS.md b/AGENTS.md index 9986c8ca..a15279dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,48 +11,87 @@ ## Project Context -Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo with NestJS gateway, Next.js web dashboard, Pi SDK agent runtime, and plugin architecture for Discord/Telegram. +Mosaic Stack is a self-hosted, multi-user AI agent platform. It is a TypeScript monorepo with a NestJS gateway, Next.js dashboard, Pi SDK agent runtime, and Discord/Telegram plugin architecture. -## Package Map +### Stack -| Package | Purpose | Key Dependencies | -| ------------------ | ------------------------------- | -------------------------------- | -| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL | -| `apps/web` | Next.js dashboard | React 19, Tailwind | -| `packages/types` | Shared TypeScript contracts | class-validator | -| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres | -| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db | -| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db | -| `packages/queue` | Valkey task queue + MCP | ioredis | -| `packages/coord` | Mission coordination | @mosaicstack/queue | -| `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander | -| `plugins/discord` | Discord channel plugin | discord.js | -| `plugins/telegram` | Telegram channel plugin | Telegraf | +- **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 -## Architecture Rules +### Package Map -1. Gateway is the single API surface — all clients connect through it -2. Pi SDK is ESM-only — gateway and CLI must use ESM -3. Socket.IO typed events defined in `@mosaicstack/types` enforce compile-time contracts -4. OTEL auto-instrumentation loads before NestJS bootstrap -5. BetterAuth manages auth tables; schema defined in `@mosaicstack/db` -6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686) -7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata) +| Package | Purpose | Key Dependencies | +| ------------------ | ----------------------------- | -------------------------------- | +| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL | +| `apps/web` | Next.js dashboard | React 19, Tailwind | +| `packages/types` | Shared TypeScript contracts | class-validator | +| `packages/db` | Drizzle schema and migrations | drizzle-orm, postgres | +| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db | +| `packages/brain` | Structured data layer | @mosaicstack/db | +| `packages/queue` | Valkey task queue and MCP | ioredis | +| `packages/coord` | Mission coordination | @mosaicstack/queue | +| `packages/mosaic` | Unified `mosaic` CLI and TUI | Ink, Pi SDK, commander | +| `plugins/discord` | Discord channel plugin | discord.js | +| `plugins/telegram` | Telegram channel plugin | Telegraf | + +## Architecture and Code Conventions + +1. Gateway is the single API surface; all clients connect through it. +2. Pi SDK is ESM-only; gateway and CLI code must remain ESM. +3. Use `"type": "module"`, NodeNext module resolution, and `.js` extensions in imports. +4. Keep typed Socket.IO events in `@mosaicstack/types` to enforce client/server contracts. +5. Import OTEL tracing before NestJS bootstrap (`import './tracing.js'`). +6. Use explicit `@Inject()` decorators in NestJS because tsx/esbuild does not emit decorator metadata. +7. Keep DTOs in `*.dto.ts` files at module boundaries. +8. BetterAuth owns authentication tables; their schema is defined in `@mosaicstack/db`. +9. Create a task-specific scratchpad for non-trivial work. ## Development Workflow +Requirements: Node.js 20+, pnpm 10.6.2, and Docker Compose when optional local services are needed. + ```bash -docker compose up -d # Infrastructure -pnpm install # Dependencies -pnpm typecheck && pnpm lint && pnpm format:check # Quality gates +pnpm install --frozen-lockfile +pnpm preflight + +# Optional local queue service only; do not start the full Compose stack. +docker compose up -d valkey ``` -## Repo-Specific Notes +The pre-push hook requires: -- DTOs in `*.dto.ts` files at module boundaries -- ESM everywhere (`"type": "module"`, `.js` extensions in imports) -- NodeNext module resolution in all tsconfigs -- Scratchpads are mandatory for non-trivial tasks +```bash +pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check +``` + +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) diff --git a/CLAUDE.md b/CLAUDE.md index 8876a145..ee90e7ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,46 +1,5 @@ -# CLAUDE.md — Mosaic Stack +# Claude Compatibility Pointer -## Project +@AGENTS.md -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 +Do not add project guidance here. Keep `AGENTS.md` authoritative so every agent runtime receives the same instructions. diff --git a/README.md b/README.md index f4d3c290..b45c13ee 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,13 @@ mosaic wizard # Full guided setup (gateway install → verify) ### Requirements -- Node.js ≥ 20 +- Node.js ≥ 22 - npm (for global @mosaicstack/mosaic install) -- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent) +- One or more runtimes: + - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) + - [Codex](https://github.com/openai/codex) + - [OpenCode](https://opencode.ai) + - [Pi](https://pi.dev) ## Usage @@ -134,9 +138,9 @@ mosaic brain tasks mosaic brain conversations # Agent forge pipeline -mosaic forge run +mosaic forge run [--simulate] # fails closed (FORGE_NO_EXECUTOR) with no executor wired; --simulate for typed simulated runs mosaic forge status -mosaic forge resume +mosaic forge resume [--simulate] # same fail-closed rule as forge run mosaic forge personas # Structured logging @@ -200,7 +204,7 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo ### Prerequisites -- Node.js ≥ 20 +- Node.js ≥ 22 - pnpm 10.6+ - Docker & Docker Compose @@ -327,7 +331,7 @@ The framework is the bash-based standards layer installed to every developer mac ├── bin/mosaic ← Unified launcher (claude, codex, opencode, pi, yolo) ├── guides/ ← E2E delivery, orchestrator protocol, PRD, etc. ├── runtime/ ← Per-runtime configs (claude/, codex/, opencode/, pi/) -├── skills/ ← Universal skills (synced from agent-skills repo) +├── skills/ ← Universal skills (shipped with the framework package) ├── tools/ ← Tool suites (orchestrator, git, quality, prdy, etc.) └── memory/ ← Persistent agent memory (preserved across upgrades) ``` diff --git a/REPORT-A1207.md b/REPORT-A1207.md new file mode 100644 index 00000000..752e7c90 --- /dev/null +++ b/REPORT-A1207.md @@ -0,0 +1,71 @@ +# REPORT A1207 + +Date: 2026-08-13 +Branch: `fix/869-lease-probe-timeout` +Starting head: `2373a5ad345fb316ad2460f6390baab1f45ba08f` +Base: `216cd72226cd9ee17eea461cfe7cd0e010a22f02` + +## What changed + +- Added Python behavior tests using isolated temporary directories and marker-writing fake `mosaic` executables. They prove that the supplied `PATH` wins over ambient `os.environ["PATH"]`, and that absent or empty supplied `PATH` values do not search ambient paths, platform defaults, or the current directory. +- Bound Python override behavior with executable fakes: a valid `MOSAIC_LEASE_VERSION_PROBE_COMMAND` wins over supplied and ambient `PATH`; an invalid override returns `None` without PATH fallback. +- Added a Python runner binding test that captures kwargs and requires `timeout=10.0`. Existing timeout, transport-error, and nonzero-exit checks remain fail-closed with `None`. +- Added the optional TypeScript dependency-injection seam `CapabilityProbeExecFile`, defaulting to the existing real `execFileSync` implementation. Production callers have no behavior change. +- Added TypeScript tests that capture child-process options and require exactly `timeout: 10_000`. Injected timeout, spawn-error, nonzero-exit, unparseable JSON, and malformed-object cases all return `null`. +- Removed the ambient no-dependency TypeScript smoke case that could execute a built checkout's real CLI. Default resolver and supervisor behavior retain their isolated tests, while capability transport tests now use an isolated artifact or the injected transport. + +No Python production code changed relative to `2373a5ad`. The only production delta is the optional TypeScript child-process injection seam. + +## Hermeticity incident and correction + +An initial ambient-lookup mutation run exposed that the pre-existing Python "not resolvable" test left ambient process PATH uncontrolled. On this host, that mutation resolved and executed the host `mosaic` capability probe. A post-build intermediate TypeScript run also let the pre-existing no-dependency smoke case execute the checkout's built `dist/cli.js` capability probe. No `claude` process was run. I then isolated the Python test's ambient PATH, removed the TypeScript ambient smoke case, repeated the PATH mutation using only marker-writing temporary fakes, and repeated the final suites without either real probe path. + +## Mutation evidence + +Each mutation was applied independently, its focused suite was run, and the production source was restored before the final run. + +| Mutation | Result | Reddened test name(s) | +| ------------------------------------------------------------------------------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `shutil.which("mosaic", path=environ.get("PATH", ""))` to ambient `shutil.which("mosaic")` | RED, three failures | `ProbeActivationCapabilityTest.test_supplied_path_wins_over_ambient_process_path`; `ProbeActivationCapabilityTest.test_absent_or_empty_supplied_path_never_falls_back_or_executes` for both absent and empty PATH subtests | +| Python `PROBE_TIMEOUT_SECONDS: 10.0` to `2.0` | RED, one failure | `ProbeActivationCapabilityTest.test_probe_passes_ten_second_timeout_to_runner` | +| TypeScript `LEASE_CAPABILITY_PROBE_TIMEOUT_MS: 10_000` to `2_000` | RED, one failure | `defaultCapabilityProbe > passes the exact ten-second timeout to the injected child-process transport` | + +## Final test run + +Dependencies were installed first with `pnpm install --frozen-lockfile`. Workspace dependencies were then built with `pnpm --filter '@mosaicstack/mosaic...' run build` so package type declarations were available. + +```text +$ cd packages/mosaic && python3 src/mutator-gate/version_coupling_unittest.py +................... +---------------------------------------------------------------------- +Ran 19 tests in 0.007s + +OK + +$ pnpm exec vitest run src/commands/lease-activation-probe.spec.ts +✓ src/commands/lease-activation-probe.spec.ts (20 tests) 80ms +Test Files 1 passed (1) +Tests 20 passed (20) +``` + +```text +$ pnpm exec prettier --check packages/mosaic/src/commands/lease-activation-probe.ts packages/mosaic/src/commands/lease-activation-probe.spec.ts +Checking formatting... +All matched files use Prettier code style! + +$ pnpm --filter @mosaicstack/mosaic lint +> eslint src + +$ pnpm --filter @mosaicstack/mosaic typecheck +> tsc --noEmit + +$ python3 -m py_compile packages/mosaic/src/mutator-gate/version_coupling_unittest.py packages/mosaic/framework/tools/lease-broker/activation_version_gate.py + +$ git diff --check +``` + +All commands above exited zero. + +## Ambiguities skipped + +None. diff --git a/apps/gateway/src/__tests__/conversation-persistence.test.ts b/apps/gateway/src/__tests__/conversation-persistence.test.ts index 781d2957..0907b155 100644 --- a/apps/gateway/src/__tests__/conversation-persistence.test.ts +++ b/apps/gateway/src/__tests__/conversation-persistence.test.ts @@ -417,7 +417,7 @@ describe('ConversationsController — search endpoint', () => { }, ]; brain = createMockBrain({ searchResults }); - controller = new ConversationsController(brain as never); + controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' }); }); it('returns matching messages for a valid search query', async () => { @@ -479,7 +479,7 @@ describe('ConversationsController — search endpoint', () => { describe('ConversationsController — message CRUD', () => { it('listMessages returns 404 when conversation is not owned by user', async () => { const brain = createMockBrain({ conversation: undefined }); - const controller = new ConversationsController(brain as never); + const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' }); await expect(controller.listMessages(CONV_ID, { id: USER_ID })).rejects.toBeInstanceOf( NotFoundException, @@ -489,7 +489,7 @@ describe('ConversationsController — message CRUD', () => { it('listMessages returns the messages for an owned conversation', async () => { const msgs = [makeMessage('user', 'Test message'), makeMessage('assistant', 'Test reply')]; const brain = createMockBrain({ conversation: makeConversation(), messages: msgs }); - const controller = new ConversationsController(brain as never); + const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' }); const result = await controller.listMessages(CONV_ID, { id: USER_ID }); @@ -500,7 +500,7 @@ describe('ConversationsController — message CRUD', () => { it('addMessage returns the persisted message', async () => { const brain = createMockBrain({ conversation: makeConversation() }); - const controller = new ConversationsController(brain as never); + const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' }); const result = await controller.addMessage( CONV_ID, diff --git a/apps/gateway/src/__tests__/cross-user-isolation.test.ts b/apps/gateway/src/__tests__/cross-user-isolation.test.ts index 439b750e..781aba79 100644 --- a/apps/gateway/src/__tests__/cross-user-isolation.test.ts +++ b/apps/gateway/src/__tests__/cross-user-isolation.test.ts @@ -190,7 +190,13 @@ beforeEach((ctx) => { }); afterAll(async () => { - if (!handle) return; + // Cleanup only when the fixture actually installed rows. `handle` is set + // before the first query (createDb connects lazily), so on an unreachable + // database `handle` is truthy while nothing was inserted — cleanup must + // honor `dbAvailable` or the skip path fails the file with ECONNREFUSED in + // afterAll (caught live by the publish pipeline's no-DATABASE_URL verify + // step, pipeline 2486). + if (!handle || !dbAvailable) return; const db = handle.db; // Delete in dependency order (FK constraints) diff --git a/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts b/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts index 22abe32c..0d7916f3 100644 --- a/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts +++ b/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts @@ -35,6 +35,25 @@ function payload(content: string, messageId: string, correlationId: string): Dis }; } +/** + * The chat runtime router must never be exercised on the Discord approval/stop control paths — + * those paths run entirely through the command-authorization, runtime-provider and durable-session + * dependencies. Placed in the gateway's chat-runtime-router slot (the former direct `AgentService` + * slot) so any accidental chat-runtime dispatch throws loudly instead of silently passing. Because + * approval/stop never resolve a chat runtime, this fixture is never triggered and the integration + * stays a GREEN cross-surface control. + */ +function failIfUsedChatRuntimeRouter() { + return { + onModuleInit: () => { + throw new Error('chat runtime router must not initialise on the Discord control path'); + }, + get active(): never { + throw new Error('chat runtime must not be resolved on the Discord approval/stop path'); + }, + }; +} + function authorization(): CommandAuthorizationService { const entries = new Map(); return new CommandAuthorizationService( @@ -113,7 +132,7 @@ describe('interaction Discord/CLI durable-session integration', () => { }, ); const gateway = new ChatGateway( - {} as never, + failIfUsedChatRuntimeRouter() as never, {} as never, {} as never, {} as never, diff --git a/apps/gateway/src/__tests__/resource-ownership.test.ts b/apps/gateway/src/__tests__/resource-ownership.test.ts index 821a7a62..cd2bbacf 100644 --- a/apps/gateway/src/__tests__/resource-ownership.test.ts +++ b/apps/gateway/src/__tests__/resource-ownership.test.ts @@ -60,7 +60,7 @@ describe('Resource ownership checks', () => { // The repo enforces ownership via the WHERE clause; it returns undefined when the // conversation does not belong to the requesting user. brain.conversations.findById.mockResolvedValue(undefined); - const controller = new ConversationsController(brain as never); + const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' }); await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf( NotFoundException, diff --git a/apps/gateway/src/agent/__tests__/session-ownership.test.ts b/apps/gateway/src/agent/__tests__/session-ownership.test.ts index 4fc0a284..ce2e6ddb 100644 --- a/apps/gateway/src/agent/__tests__/session-ownership.test.ts +++ b/apps/gateway/src/agent/__tests__/session-ownership.test.ts @@ -1,6 +1,8 @@ +import 'reflect-metadata'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; import { describe, expect, it, vi } from 'vitest'; vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} })); @@ -12,10 +14,25 @@ vi.mock('../routing/routing-engine.service.js', () => ({ })); import { SessionsController } from '../sessions.controller.js'; +import { AgentService } from '../agent.service.js'; import { ChatController } from '../../chat/chat.controller.js'; import { ChatGateway } from '../../chat/chat.gateway.js'; import type { AgentSession } from '../agent.service.js'; import type { SessionInfoDto } from '../session.dto.js'; +import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types'; +import { AuthGuard } from '../../auth/auth.guard.js'; +import { AUTH } from '../../auth/auth.tokens.js'; +import { BRAIN } from '../../brain/brain.tokens.js'; +import { CommandRegistryService } from '../../commands/command-registry.service.js'; +import { CommandExecutorService } from '../../commands/command-executor.service.js'; +import { RoutingEngineService } from '../routing/routing-engine.service.js'; +import { ChatRuntimeRouter } from '../../chat/chat-runtime-router.js'; +import { EmbeddedChatRuntime } from '../../chat/embedded-chat.runtime.js'; +import { ownConversation } from '../../chat/chat-runtime.js'; +import type { LegacyRuntimeStream } from '../../chat/chat-runtime.js'; +import { HarnessChatRuntime } from '../../chat/harness-chat.runtime.js'; +import { HarnessRegistry } from '../../harness/harness.registry.js'; +import { HARNESS_CONVERSATION_SERVICE_UNAVAILABLE } from '../../harness/harness.tokens.js'; const USER_A = { id: 'user-a', tenantId: 'tenant-a' }; const USER_B = { id: 'user-b', tenantId: 'tenant-b' }; @@ -74,6 +91,12 @@ function makeAgentSession(owner = USER_A): AgentSession { }; } +/** + * A shape-complete, non-throwing AgentService fake scoped so that USER_B (a foreign owner guessing + * USER_A's conversation id) is never granted the session. Because every method exists and no method + * throws for a wrong shape, production runs to its real ownership decision — the RED never comes from + * a `getSession is not a function` TypeError, only from a router-boundary/scope assertion mismatch. + */ function makeScopedAgentService() { const foreign = makeAgentSession(USER_A); return { @@ -87,7 +110,7 @@ function makeScopedAgentService() { getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) => scope?.userId === USER_B.id ? undefined : foreign, ), - createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')), + createSession: vi.fn().mockRejectedValue(new NotFoundException('Session scope mismatch')), onEvent: vi.fn(() => vi.fn()), addChannel: vi.fn(), removeChannel: vi.fn(), @@ -96,6 +119,201 @@ function makeScopedAgentService() { }; } +type ScopedAgentService = ReturnType; + +/** + * A structurally-complete harness conversation service that throws if any method is invoked. + * Fronted behind the legacy runtime's harness slot: the legacy path must never reach it. + */ +const failIfUsedConversationService = { + attach: () => { + throw new Error('harness conversation service must not be reached on the legacy path'); + }, + detach: () => { + throw new Error('harness conversation service must not be reached on the legacy path'); + }, + send: () => { + throw new Error('harness conversation service must not be reached on the legacy path'); + }, + + subscribeFrom: async function* () { + throw new Error('harness conversation service must not be reached on the legacy path'); + }, +} as unknown as HarnessConversationService; + +/** A structurally-complete, non-sentinel conversation service used to satisfy the pi-rpc readiness gate. */ +const boundConversationService = { + attach: () => Promise.reject(new Error('unused')), + detach: () => Promise.reject(new Error('unused')), + send: () => Promise.reject(new Error('unused')), + + subscribeFrom: async function* () { + throw new Error('unused'); + }, +} as unknown as HarnessConversationService; + +function registryWith(adapterIds: readonly string[]): HarnessRegistry { + const registry = new HarnessRegistry(); + for (const id of adapterIds) { + registry.register({ + id, + describe: () => Promise.reject(new Error('unused')), + catalog: () => Promise.reject(new Error('unused')), + create: () => Promise.reject(new Error('unused')), + resume: () => Promise.reject(new Error('unused')), + } as HarnessAdapter); + } + return registry; +} + +/** + * Build the real legacy-mode {@link ChatRuntimeRouter} fronting a real {@link EmbeddedChatRuntime} + * that holds the scoped AgentService fake. This is the ONLY path server-derived scope may travel to + * reach an AgentService: controller/gateway → ChatRuntimeRouter → EmbeddedChatRuntime → AgentService. + * The `embeddedAgentService` handed here is a SEPARATE instance from the directly-injected fake, so a + * call landing on it proves the router-delegation redesign is live rather than the old direct path. + */ +function legacyRouterFronting(agentService: unknown): ChatRuntimeRouter { + const embedded = new EmbeddedChatRuntime(agentService as never); + const harness = new HarnessChatRuntime(failIfUsedConversationService); + const router = new ChatRuntimeRouter( + new HarnessRegistry(), + HARNESS_CONVERSATION_SERVICE_UNAVAILABLE, + embedded, + harness, + 'legacy', + ); + router.onModuleInit(); + return router; +} + +/** + * The AgentService method names the controller/gateway must NEVER drive on the runtime at the + * delegation boundary. An AgentService-shaped router shim (a method-for-method mirror) would record + * one of these instead of the frozen legacy op, so asserting their ABSENCE from the observed runtime + * call set defeats the shim on INVOCATION evidence — never satisfiable by dead source text. + */ +const FORBIDDEN_AGENT_OPS = [ + 'getSession', + 'createSession', + 'onEvent', + 'addChannel', + 'prompt', + 'setThinking', + 'abort', +] as const; + +/** + * Wrap a real {@link ChatRuntimeRouter} in a call-recording Proxy. Every property access that yields + * an OWN/inherited callable is returned as a thin wrapper that appends the method name to `calls` at + * INVOCATION time and forwards to the real method (bound to the real target, so the router's internal + * delegation to the embedded runtime runs untouched below this boundary). Non-function and MISSING + * properties are returned verbatim via Reflect.get — the observer NEVER fabricates a value, returns a + * canned outcome, or delegates a not-yet-implemented named op, so it cannot itself become a shim. + * + * The result is a RUNTIME call set of exactly the methods the controller/gateway invoke ON the router + * at the delegation seam. Only an actual call can enter it; a dead method, comment, or string in the + * production source cannot. This replaces the earlier `source.toContain('')` proof — which + * a dead declaration could satisfy while production still executed a shim — with invocation evidence. + */ +function makeRecordingRouter(target: ChatRuntimeRouter, calls: string[]): ChatRuntimeRouter { + return new Proxy(target, { + get(t, prop) { + const value = Reflect.get(t, prop); + if (typeof value === 'function' && typeof prop === 'string') { + return (...args: unknown[]) => { + calls.push(prop); + return (value as (...a: unknown[]) => unknown).apply(t, args); + }; + } + return value; + }, + }) as ChatRuntimeRouter; +} + +/** + * Real Nest DI dual-provider fixture (mirrors the blessed group-3 pattern in chat-security.test.ts). + * + * BOTH an `AgentService` provider (the FORBIDDEN direct dependency) and a `ChatRuntimeRouter` provider + * (fronting a real EmbeddedChatRuntime over a SEPARATE scoped AgentService) are registered. Production + * resolves whichever its constructor declares: + * - RED today: the controller/gateway `@Inject(AgentService)` → the direct fake is consulted, the + * router (and its embedded fake) is never reached. + * - GREEN later: the controller/gateway inject `ChatRuntimeRouter` → the direct fake is never + * touched (stays at zero) and scope is observed inside the embedded fake behind the router. + * The SAME test body reds today and greens later; a method-for-method AgentService shim on the router + * records a FORBIDDEN op (and never the frozen legacy op) in the observed runtime call set, and + * restoring the direct injection cannot satisfy the "direct fake at zero" / "embedded fake observed + * scope" / "frozen op invoked on the router" anchors. The router is wrapped by {@link + * makeRecordingRouter} so those anchors are runtime invocation evidence, not source substrings. + */ +function buildRestModule( + directAgentService: ScopedAgentService, + embeddedAgentService: ScopedAgentService, + routerCalls: string[], +): Promise { + return ( + Test.createTestingModule({ + controllers: [ChatController], + providers: [ + { provide: AgentService, useValue: directAgentService }, + { + provide: ChatRuntimeRouter, + useFactory: () => + makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls), + }, + ], + }) + // ChatController's @UseGuards(AuthGuard) is resolved during instance loading; AuthGuard injects + // AUTH, an HTTP-only concern never exercised by a direct handler call. Stub it so the graph + // resolves and the test reds on BEHAVIOUR, not on a DI collection error. + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .compile() + ); +} + +function buildGatewayModule( + directAgentService: ScopedAgentService, + embeddedAgentService: ScopedAgentService, + routerCalls: string[], +): Promise { + const brain = { + conversations: { + // The sender OWNS this durable conversation, so the browser-send admission gate lets the turn + // reach the router seam. Foreignness is asserted downstream at the in-memory agent session + // (getSession({USER_B}) -> undefined), not at durable admission — the admission-rejection + // property has its own dedicated coverage. + findById: vi.fn().mockResolvedValue({ id: CONVERSATION_ID, userId: USER_B.id }), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + findMessages: vi.fn().mockResolvedValue([]), + addMessage: vi.fn().mockResolvedValue({ id: 'persisted-turn' }), + }, + }; + return Test.createTestingModule({ + providers: [ + ChatGateway, + { provide: AgentService, useValue: directAgentService }, + { provide: AUTH, useValue: { api: { getSession: vi.fn().mockResolvedValue(null) } } }, + { provide: BRAIN, useValue: brain }, + { provide: CommandRegistryService, useValue: { getManifest: vi.fn().mockReturnValue([]) } }, + { provide: CommandExecutorService, useValue: { execute: vi.fn() } }, + { + provide: RoutingEngineService, + useValue: { + resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }), + }, + }, + { + provide: ChatRuntimeRouter, + useFactory: () => + makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls), + }, + ], + }).compile(); +} + describe('TESS-M1-SEC-002 AgentService ownership boundary', () => { it('requires explicit owner+tenant scope on protected session operations', () => { const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8'); @@ -152,50 +370,66 @@ describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => { }); }); -describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => { - it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => { - const agentService = makeScopedAgentService(); - const controller = new ChatController(agentService as never); +describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding (router-delegated legacy runtime)', () => { + // TESS test A — REST /api/chat send. The genuine RED is the router-delegation redesign, not a slot + // swap: the forbidden directly-injected AgentService must go UNtouched while the server-derived + // scope is observed inside the real ChatRuntimeRouter → EmbeddedChatRuntime → AgentService path. + it('routes a REST send through completeLegacyRestTurn and never the directly-injected AgentService', async () => { + const directAgentService = makeScopedAgentService(); // FORBIDDEN direct dependency + const embeddedAgentService = makeScopedAgentService(); // reached ONLY via router → embedded delegation + const routerCalls: string[] = []; // runtime call set observed AT the controller → router seam + const moduleRef = await buildRestModule(directAgentService, embeddedAgentService, routerCalls); + try { + const controller = moduleRef.get(ChatController, { strict: false }); - await expect( - controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B), - ).rejects.toMatchObject({ status: 404 }); + // Foreign ownership is denied (never resolves) — a control that holds today AND at GREEN. + await expect( + controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B), + ).rejects.toBeDefined(); - expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { - userId: USER_B.id, - tenantId: USER_B.tenantId, - }); - expect(agentService.prompt).not.toHaveBeenCalled(); + // Soft anchors so EVERY anchor is evaluated under each mutation, not just the first to fail. + + // RUNTIME anchor A1 — delegation: the controller must INVOKE the frozen legacy op on the router. + // Only an actual call enters routerCalls; a dead method/comment/string cannot. RED today (the + // controller @Inject(AgentService) and never calls the router). GREEN once it drives the op. + expect + .soft(routerCalls, 'controller must invoke completeLegacyRestTurn on the router') + .toContain('completeLegacyRestTurn'); + // RUNTIME anchor A2 — nondelegation: the controller must not drive any AgentService-shaped op on + // the router. An AgentService-shaped router shim records one of these → RED, defeating the shim + // on invocation evidence (not source text). A dead named method added alongside the shim does not + // help: it is never invoked, so it never enters routerCalls while a forbidden op still does. + for (const op of FORBIDDEN_AGENT_OPS) { + expect + .soft(routerCalls, `router seam must not invoke AgentService.${op}`) + .not.toContain(op); + } + // RUNTIME anchor A3 — the forbidden directly-injected AgentService stays at zero (fails today; + // restoring the direct injection keeps it failing). + expect.soft(directAgentService.getSession).not.toHaveBeenCalled(); + // RUNTIME anchor A4 — server-derived scope observed INSIDE the separate embedded fake behind the + // router (fails today; the router path is never taken). + expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + + // Zero foreign mutation on either path (holds today and at GREEN). + expect.soft(directAgentService.prompt).not.toHaveBeenCalled(); + expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled(); + + // Defense-in-depth (NOT load-bearing; the runtime anchors above carry the anti-mask): the + // controller no longer declares the direct embedded AgentService dependency. A negative source + // check cannot be satisfied by dead text — it only fails when the injection is present. + const controllerSource = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8'); + expect.soft(controllerSource).not.toContain('@Inject(AgentService)'); + } finally { + await moduleRef.close(); + } }); }); -describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => { - function makeGateway(agentService = makeScopedAgentService()) { - const brain = { - conversations: { - findById: vi.fn().mockResolvedValue(undefined), - create: vi.fn().mockResolvedValue(undefined), - update: vi.fn().mockResolvedValue(undefined), - findMessages: vi.fn().mockResolvedValue([]), - addMessage: vi.fn().mockResolvedValue(undefined), - }, - }; - const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) }; - const commandExecutor = { execute: vi.fn() }; - const routingEngine = { - resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }), - }; - const gateway = new ChatGateway( - agentService as never, - {} as never, - brain as never, - commandRegistry as never, - commandExecutor as never, - routingEngine as never, - ); - return { gateway, agentService }; - } - +describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding (router-delegated legacy runtime)', () => { function makeSocket() { return { id: 'socket-b', @@ -206,57 +440,519 @@ describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => }; } - it('does not attach or send to another owner/tenant session by guessed conversationId', async () => { - const { gateway, agentService } = makeGateway(); - const socket = makeSocket(); + // TESS test B — WebSocket send/attach. + it('routes a WebSocket send through prepareLegacySocketTurn and never the directly-injected AgentService', async () => { + const directAgentService = makeScopedAgentService(); + const embeddedAgentService = makeScopedAgentService(); + const routerCalls: string[] = []; + const moduleRef = await buildGatewayModule( + directAgentService, + embeddedAgentService, + routerCalls, + ); + try { + const gateway = moduleRef.get(ChatGateway, { strict: false }); + const socket = makeSocket(); - await gateway.handleMessage(socket as never, { - conversationId: CONVERSATION_ID, - content: 'attach to foreign session', - }); + await Promise.resolve( + gateway.handleMessage(socket as never, { + conversationId: CONVERSATION_ID, + content: 'attach to foreign session', + }), + ).catch(() => undefined); - expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { - userId: USER_B.id, - tenantId: USER_B.tenantId, - }); - expect(agentService.onEvent).not.toHaveBeenCalled(); - expect(agentService.addChannel).not.toHaveBeenCalled(); + // RUNTIME anchor B1 — delegation: the gateway must invoke the frozen socket op on the router. + expect + .soft(routerCalls, 'gateway must invoke prepareLegacySocketTurn on the router') + .toContain('prepareLegacySocketTurn'); + // RUNTIME anchor B2 — nondelegation: no AgentService-shaped op on the router (defeats the shim). + for (const op of FORBIDDEN_AGENT_OPS) { + expect + .soft(routerCalls, `router seam must not invoke AgentService.${op}`) + .not.toContain(op); + } + // RED anchor B3 — forbidden direct AgentService untouched (fails today, gateway injects it). + expect.soft(directAgentService.getSession).not.toHaveBeenCalled(); + // RED anchor B4 — scope observed inside router → embedded delegation (fails today, never reached). + expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + // Foreign session gets zero lease/listener/channel/prompt on EITHER path (holds today and GREEN). + expect.soft(directAgentService.onEvent).not.toHaveBeenCalled(); + expect.soft(directAgentService.addChannel).not.toHaveBeenCalled(); + expect.soft(directAgentService.prompt).not.toHaveBeenCalled(); + expect.soft(embeddedAgentService.onEvent).not.toHaveBeenCalled(); + expect.soft(embeddedAgentService.addChannel).not.toHaveBeenCalled(); + expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled(); + expect + .soft(socket.emit) + .toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: CONVERSATION_ID }), + ); + + // Defense-in-depth (NOT load-bearing): gateway no longer declares the direct dependency. + const gatewaySource = readFileSync(resolve('src/chat/chat.gateway.ts'), 'utf8'); + expect.soft(gatewaySource).not.toContain('@Inject(AgentService)'); + } finally { + await moduleRef.close(); + } + }); + + // TESS test C — WebSocket set:thinking. + it('routes set:thinking through setLegacyThinking and never the directly-injected AgentService', async () => { + const directAgentService = makeScopedAgentService(); + const embeddedAgentService = makeScopedAgentService(); + const routerCalls: string[] = []; + const moduleRef = await buildGatewayModule( + directAgentService, + embeddedAgentService, + routerCalls, + ); + try { + const gateway = moduleRef.get(ChatGateway, { strict: false }); + const socket = makeSocket(); + + await Promise.resolve( + gateway.handleSetThinking(socket as never, { + conversationId: CONVERSATION_ID, + level: 'high', + }), + ).catch(() => undefined); + + // RUNTIME anchor C1 — delegation: the gateway must invoke the frozen thinking op on the router. + expect + .soft(routerCalls, 'gateway must invoke setLegacyThinking on the router') + .toContain('setLegacyThinking'); + // RUNTIME anchor C2 — nondelegation: no AgentService-shaped op on the router (defeats the shim). + for (const op of FORBIDDEN_AGENT_OPS) { + expect + .soft(routerCalls, `router seam must not invoke AgentService.${op}`) + .not.toContain(op); + } + expect.soft(directAgentService.getSession).not.toHaveBeenCalled(); + expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + expect + .soft(socket.emit) + .toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: CONVERSATION_ID }), + ); + } finally { + await moduleRef.close(); + } + }); + + // TESS test D — WebSocket abort. + it('routes abort through abortLegacyTurn and never the directly-injected AgentService', async () => { + const directAgentService = makeScopedAgentService(); + const embeddedAgentService = makeScopedAgentService(); + const routerCalls: string[] = []; + const moduleRef = await buildGatewayModule( + directAgentService, + embeddedAgentService, + routerCalls, + ); + try { + const gateway = moduleRef.get(ChatGateway, { strict: false }); + const socket = makeSocket(); + + await Promise.resolve( + gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID }), + ).catch(() => undefined); + + // RUNTIME anchor D1 — delegation: the gateway must invoke the frozen abort op on the router. + expect + .soft(routerCalls, 'gateway must invoke abortLegacyTurn on the router') + .toContain('abortLegacyTurn'); + // RUNTIME anchor D2 — nondelegation: no AgentService-shaped op on the router (defeats the shim). + for (const op of FORBIDDEN_AGENT_OPS) { + expect + .soft(routerCalls, `router seam must not invoke AgentService.${op}`) + .not.toContain(op); + } + expect.soft(directAgentService.getSession).not.toHaveBeenCalled(); + expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + expect + .soft(socket.emit) + .toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: CONVERSATION_ID }), + ); + } finally { + await moduleRef.close(); + } + }); + + // TESS test E (genuine, unchanged) — pi-rpc browser-legacy refusal. + it('rejects a browser legacy raw message in pi-rpc mode with a fixed typed unsupported and executes nothing', async () => { + // pi-rpc: the harness runtime is live. The browser legacy `message` path is unsupported and + // must be refused with a fixed typed code, touching neither the embedded AgentService nor the + // harness conversation service. + const agentService = makeScopedAgentService(); + const embedded = new EmbeddedChatRuntime(agentService as never); + const harnessConversation = { + attach: vi.fn(), + detach: vi.fn(), + send: vi.fn(), + subscribeFrom: vi.fn(), + }; + const harness = new HarnessChatRuntime(harnessConversation as never); + const router = new ChatRuntimeRouter( + registryWith(['pi']), + boundConversationService, + embedded, + harness, + 'pi-rpc', + ); + router.onModuleInit(); + + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue(undefined), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + findMessages: vi.fn().mockResolvedValue([]), + addMessage: vi.fn().mockResolvedValue(undefined), + }, + }; + const gateway = new ChatGateway( + router as never, + {} as never, + brain as never, + { getManifest: vi.fn().mockReturnValue([]) } as never, + { execute: vi.fn() } as never, + { resolve: vi.fn() } as never, + ); + const socket = { + id: 'socket-b', + connected: true, + data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } }, + emit: vi.fn(), + disconnect: vi.fn(), + }; + + await Promise.resolve( + gateway.handleMessage(socket as never, { + conversationId: CONVERSATION_ID, + content: 'route me', + }), + ).catch(() => undefined); + + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ code: 'runtime_unsupported' }), + ); + expect(agentService.getSession).not.toHaveBeenCalled(); expect(agentService.prompt).not.toHaveBeenCalled(); - expect(socket.emit).toHaveBeenCalledWith( - 'error', - expect.objectContaining({ conversationId: CONVERSATION_ID }), + expect(harnessConversation.attach).not.toHaveBeenCalled(); + expect(harnessConversation.send).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Task-5 AMEND — embedded runtime lease lifecycle (G1) + ownership collapse (G5). +// These drive the real EmbeddedChatRuntime directly over a shape-complete AgentService +// fake (every touched method exists, so a RED can only come from behavior, never a +// `getSession is not a function` TypeError). Ownership context is minted through the +// real `ownConversation` factory — the only sanctioned way to reach a port op. +// --------------------------------------------------------------------------- + +const EMBEDDED_SCOPE = { userId: USER_A.id, tenantId: USER_A.tenantId }; +const CONVERSATION_UNAVAILABLE_RESULT = { + ok: false, + code: 'conversation_unavailable', + retryable: false, +} as const; + +/** A stream sink; `channelId` is server-derived, `onEvent` records nothing here. */ +function makeStream(): LegacyRuntimeStream { + return { channelId: 'websocket:test-1', onEvent: vi.fn() }; +} + +/** + * getSession → undefined (session missing), createSession → rejects with `err`. Exercises the + * `resolveOrCreate` collapse branch. `prompt` exists so its ABSENCE from the call record proves + * the turn short-circuited before any dispatch. + */ +function makeCollapsingAgentService(err: Error) { + return { + getSession: vi.fn(() => undefined), + createSession: vi.fn().mockRejectedValue(err), + onEvent: vi.fn(() => vi.fn()), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + recordTokenUsage: vi.fn(), + }; +} + +/** getSession → a live owned session, so `resolveOrCreate` succeeds and a lease is built. */ +function makeLeaseAgentService() { + const session = makeAgentSession(USER_A); + const unsubscribe = vi.fn(); + const svc = { + getSession: vi.fn(() => session), + createSession: vi.fn(), + onEvent: vi.fn(() => unsubscribe), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + recordTokenUsage: vi.fn(), + }; + return { svc, unsubscribe, session }; +} + +/** + * getSession → a live owned session (REST resolveOrCreate succeeds), onEvent returns a `detach` + * spy, and `prompt` REJECTS with a non-timeout error. Drives the REST-turn catch path so the single + * idempotent teardown must clear the 120s timeout and detach the listener exactly once. + */ +function makeRejectingPromptAgentService() { + const session = makeAgentSession(USER_A); + const detach = vi.fn(); + const svc = { + getSession: vi.fn(() => session), + createSession: vi.fn(), + onEvent: vi.fn(() => detach), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockRejectedValue(new Error('agent backend exploded')), + recordTokenUsage: vi.fn(), + }; + return { svc, detach }; +} + +describe('TESS Task-5 embedded ownership collapse (missing and foreign are indistinguishable, never throw)', () => { + const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE); + + it('collapses a foreign (Forbidden) create to conversation_unavailable and never throws', async () => { + const svc = makeCollapsingAgentService(new ForbiddenException('foreign owner')); + const runtime = new EmbeddedChatRuntime(svc as never); + + const result = await runtime.completeLegacyRestTurn(ctx, { content: 'take over' }); + + expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT); + expect(svc.prompt).not.toHaveBeenCalled(); + }); + + it('collapses a missing (NotFound) create to conversation_unavailable and never throws', async () => { + const svc = makeCollapsingAgentService(new NotFoundException('no such conversation')); + const runtime = new EmbeddedChatRuntime(svc as never); + + const result = await runtime.completeLegacyRestTurn(ctx, { content: 'hello' }); + + expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT); + expect(svc.prompt).not.toHaveBeenCalled(); + }); + + it('returns the IDENTICAL collapse for foreign and missing so neither can be distinguished', async () => { + const foreign = new EmbeddedChatRuntime( + makeCollapsingAgentService(new ForbiddenException('foreign owner')) as never, ); + const missing = new EmbeddedChatRuntime( + makeCollapsingAgentService(new NotFoundException('no such conversation')) as never, + ); + + const foreignResult = await foreign.completeLegacyRestTurn(ctx, { content: 'x' }); + const missingResult = await missing.completeLegacyRestTurn(ctx, { content: 'x' }); + + expect(foreignResult).toEqual(missingResult); + expect(foreignResult).toEqual(CONVERSATION_UNAVAILABLE_RESULT); }); +}); + +describe('TESS Task-5 embedded socket lease lifecycle (one-shot dispatch, idempotent dispose, partial-setup rollback)', () => { + const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE); + + it('dispatches the turn exactly once; a second dispatch is a no-op turn_already_dispatched', async () => { + const { svc } = makeLeaseAgentService(); + const runtime = new EmbeddedChatRuntime(svc as never); - it('does not mutate thinking level on another owner/tenant session', () => { - const { gateway, agentService } = makeGateway(); - const socket = makeSocket(); + const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'first' }, makeStream()); + expect(prepared.ok).toBe(true); + if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed'); + const lease = prepared.value; - gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' }); + const first = await lease.dispatch(); + expect(first).toEqual({ ok: true, value: undefined }); + expect(svc.prompt).toHaveBeenCalledTimes(1); - expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { - userId: USER_B.id, - tenantId: USER_B.tenantId, + const second = await lease.dispatch(); + expect(second).toEqual({ ok: false, code: 'turn_already_dispatched', retryable: false }); + // Zero additional effect — the second dispatch must not prompt again. + expect(svc.prompt).toHaveBeenCalledTimes(1); + }); + + it('disposes once; a second dispose is a silent no-op that never re-detaches or destroys the session', async () => { + const { svc, unsubscribe, session } = makeLeaseAgentService(); + const runtime = new EmbeddedChatRuntime(svc as never); + + const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream()); + expect(prepared.ok).toBe(true); + if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed'); + const lease = prepared.value; + + await lease.dispose(); + await lease.dispose(); + + // Listener + channel torn down exactly once across two dispose calls. + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(svc.removeChannel).toHaveBeenCalledTimes(1); + // Disposal never terminates the underlying session or process. + expect(session.piSession.abort).not.toHaveBeenCalled(); + expect(session.piSession.dispose).not.toHaveBeenCalled(); + }); + + it('rolls back the acquired listener and returns a total safe failure when channel attach fails mid-setup', async () => { + const { svc, unsubscribe } = makeLeaseAgentService(); + svc.addChannel = vi.fn(() => { + throw new Error('channel attach failed'); }); - expect(socket.emit).toHaveBeenCalledWith( - 'error', - expect.objectContaining({ conversationId: CONVERSATION_ID }), - ); + const runtime = new EmbeddedChatRuntime(svc as never); + + // Must NOT throw out of the port — a partial setup collapses to a total safe failure. + const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream()); + expect(prepared.ok).toBe(false); + // Exactly what was acquired (the event listener) is rolled back. + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); +}); + +describe('TESS Task-5 embedded REST turn teardown (a prompt rejection frees the timer + listener exactly once)', () => { + const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE); + + it('clears the 120s timeout and detaches the listener exactly once when prompt() rejects, leaving no timer to reject the abandoned done-promise later (Task 5 finding 6)', async () => { + const { svc, detach } = makeRejectingPromptAgentService(); + const runtime = new EmbeddedChatRuntime(svc as never); + + // A rejected `done` promise firing after completeLegacyRestTurn has already returned would + // surface as an unhandledRejection — the leak this test fences. Capture any that escape. + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + vi.useFakeTimers(); + try { + const result = await runtime.completeLegacyRestTurn(ctx, { + content: 'trigger a backend failure', + }); + + // The rejection collapses to a total safe failure (not a timeout) — never throws out of the port. + expect(result).toEqual({ ok: false, code: 'operation_failed', retryable: false }); + // The single idempotent dispose ran in the catch: listener detached exactly once. + expect(detach).toHaveBeenCalledTimes(1); + + // dispose() cleared the REST timeout, so advancing far past it (120s) fires nothing: no second + // detach, and — the actual leak — no live timer left to reject the now-abandoned `done` promise. + vi.advanceTimersByTime(600_000); + expect(detach).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + // Let any scheduled rejection surface on a real macrotask, then confirm none did. + await new Promise((resolve) => setTimeout(resolve, 0)); + process.off('unhandledRejection', onUnhandled); + expect(unhandled).toHaveLength(0); }); + + it('bounds a hung prompt: when prompt() never settles and no agent_end arrives, the 120s timeout ends the turn with a timeout result and exactly one teardown, no unhandledRejection (Task 5 finding 6 — pending-prompt timeout)', async () => { + const session = makeAgentSession(USER_A); + const detach = vi.fn(); + const svc = { + getSession: vi.fn(() => session), + createSession: vi.fn(), + onEvent: vi.fn(() => detach), + addChannel: vi.fn(), + removeChannel: vi.fn(), + // The prompt never resolves or rejects — a hung agent backend. Under the pre-fix sequential + // `await prompt()` the timer could never even be observed, so the turn hung forever. + prompt: vi.fn(() => new Promise(() => undefined)), + recordTokenUsage: vi.fn(), + }; + const runtime = new EmbeddedChatRuntime(svc as never); - it('does not terminate another owner/tenant session over WebSocket abort', async () => { - const { gateway, agentService } = makeGateway(); - const socket = makeSocket(); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + vi.useFakeTimers(); + try { + const resultPromise = runtime.completeLegacyRestTurn(ctx, { + content: 'a prompt that never returns', + }); + // No agent_end, prompt still pending: only the 120s timeout can end the turn. Promise.all + // installed a handler on `done` synchronously, so the timer bounds the turn while prompt hangs. + await vi.advanceTimersByTimeAsync(200_000); + const result = await resultPromise; - await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID }); + expect(result).toEqual({ ok: false, code: 'timeout', retryable: true }); + // The single idempotent dispose ran on the timeout path: listener detached exactly once. + expect(detach).toHaveBeenCalledTimes(1); + // Advancing far past the deadline fires nothing more: dispose cleared the timer. + vi.advanceTimersByTime(600_000); + expect(detach).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + process.off('unhandledRejection', onUnhandled); + expect(unhandled).toHaveLength(0); + }); - expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { - userId: USER_B.id, - tenantId: USER_B.tenantId, + it('when the 120s timeout fires while prompt() is still pending, returns timeout with one teardown, and a later prompt rejection surfaces no unhandledRejection (Task 5 finding 6 — timeout/prompt race)', async () => { + const session = makeAgentSession(USER_A); + const detach = vi.fn(); + let rejectPrompt: (reason: unknown) => void = () => undefined; + const prompting = new Promise((_resolve, reject) => { + rejectPrompt = reject; }); - expect(socket.emit).toHaveBeenCalledWith( - 'error', - expect.objectContaining({ conversationId: CONVERSATION_ID }), - ); + const svc = { + getSession: vi.fn(() => session), + createSession: vi.fn(), + onEvent: vi.fn(() => detach), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn(() => prompting), + recordTokenUsage: vi.fn(), + }; + const runtime = new EmbeddedChatRuntime(svc as never); + + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + vi.useFakeTimers(); + try { + const resultPromise = runtime.completeLegacyRestTurn(ctx, { + content: 'prompt settles after the deadline', + }); + // The timeout wins the race while prompt is still pending. + await vi.advanceTimersByTimeAsync(200_000); + const result = await resultPromise; + + expect(result).toEqual({ ok: false, code: 'timeout', retryable: true }); + expect(detach).toHaveBeenCalledTimes(1); + + // The prompt now rejects LATE — after the turn already returned its timeout result. Because + // Promise.all installed a rejection handler on `prompting` synchronously (the fix), this late + // rejection is already observed and must not escape as an unhandledRejection. + rejectPrompt(new Error('late backend failure')); + } finally { + vi.useRealTimers(); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + process.off('unhandledRejection', onUnhandled); + expect(unhandled).toHaveLength(0); }); }); diff --git a/apps/gateway/src/agent/routing/routing-e2e.test.ts b/apps/gateway/src/agent/routing/routing-e2e.test.ts index bd927c20..e6f2ff9e 100644 --- a/apps/gateway/src/agent/routing/routing-e2e.test.ts +++ b/apps/gateway/src/agent/routing/routing-e2e.test.ts @@ -8,6 +8,7 @@ * to avoid real I/O — they verify the complete classify → match → decide path. */ import { describe, it, expect, vi } from 'vitest'; +import type { ProviderHealthStatus } from '@mosaicstack/types'; import { RoutingEngineService } from './routing-engine.service.js'; import { DEFAULT_ROUTING_RULES } from '../routing/default-rules.js'; import type { RoutingRule } from './routing.types.js'; @@ -17,7 +18,7 @@ import type { RoutingRule } from './routing.types.js'; /** Build a RoutingEngineService backed by the given rule set and health map. */ function makeService( rules: RoutingRule[], - healthMap: Record, + healthMap: Record, ): RoutingEngineService { const mockDb = { select: vi.fn().mockReturnValue({ @@ -67,11 +68,11 @@ function defaultRules(): RoutingRule[] { } /** A health map where anthropic, openai, and zai are all healthy. */ -const allHealthy: Record = { - anthropic: { status: 'up' }, - openai: { status: 'up' }, - zai: { status: 'up' }, - ollama: { status: 'up' }, +const allHealthy: Record = { + anthropic: { status: 'healthy' }, + openai: { status: 'healthy' }, + zai: { status: 'healthy' }, + ollama: { status: 'healthy' }, }; // ─── M4-013 E2E tests ───────────────────────────────────────────────────────── @@ -212,10 +213,10 @@ describe('M4-013: routing end-to-end pipeline', () => { // Let's use a simple coding message to target Simple coding → Codex (openai) const message = 'implement a sort function'; - const unhealthyHealth = { + const unhealthyHealth: Record = { anthropic: { status: 'down' }, - openai: { status: 'up' }, - zai: { status: 'up' }, + openai: { status: 'healthy' }, + zai: { status: 'healthy' }, ollama: { status: 'down' }, }; diff --git a/apps/gateway/src/agent/routing/routing-engine.service.ts b/apps/gateway/src/agent/routing/routing-engine.service.ts index ad7c5a2f..f023f0f4 100644 --- a/apps/gateway/src/agent/routing/routing-engine.service.ts +++ b/apps/gateway/src/agent/routing/routing-engine.service.ts @@ -1,5 +1,6 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { routingRules, type Db, and, asc, eq, or } from '@mosaicstack/db'; +import type { ProviderHealthStatus } from '@mosaicstack/types'; import { DB } from '../../database/database.module.js'; import { ProviderService } from '../provider.service.js'; import { classifyTask } from './task-classifier.js'; @@ -49,7 +50,7 @@ export class RoutingEngineService { async resolve( message: string, userId?: string, - availableProviders?: Record, + availableProviders?: Record, ): Promise { const classification = classifyTask(message); this.logger.debug( @@ -69,9 +70,8 @@ export class RoutingEngineService { if (!this.matchConditions(rule, classification)) continue; const providerStatus = health[rule.action.provider]?.status; - const isHealthy = providerStatus === 'up' || providerStatus === 'ok'; - if (!isHealthy) { + if (!this.isRoutable(providerStatus)) { this.logger.debug( `Rule "${rule.name}" matched but provider "${rule.action.provider}" is unhealthy (status: ${providerStatus ?? 'unknown'})`, ); @@ -111,6 +111,10 @@ export class RoutingEngineService { // ─── Private helpers ─────────────────────────────────────────────────────── + private isRoutable(status: ProviderHealthStatus | undefined): boolean { + return status === 'healthy' || status === 'degraded'; + } + private evaluateCondition( condition: RoutingCondition, classification: TaskClassification, @@ -186,11 +190,12 @@ export class RoutingEngineService { * Walk the fallback chain and return the first healthy provider/model pair. * If none are healthy, return the first entry unconditionally (last resort). */ - private applyFallbackChain(health: Record): RoutingDecision { + private applyFallbackChain( + health: Record, + ): RoutingDecision { for (const candidate of FALLBACK_CHAIN) { const providerStatus = health[candidate.provider]?.status; - const isHealthy = providerStatus === 'up' || providerStatus === 'ok'; - if (isHealthy) { + if (this.isRoutable(providerStatus)) { this.logger.debug(`Fallback resolved: ${candidate.provider}/${candidate.model}`); return { provider: candidate.provider, diff --git a/apps/gateway/src/agent/routing/routing-engine.test.ts b/apps/gateway/src/agent/routing/routing-engine.test.ts index 645d079a..0daf2f94 100644 --- a/apps/gateway/src/agent/routing/routing-engine.test.ts +++ b/apps/gateway/src/agent/routing/routing-engine.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ProviderHealthStatus } from '@mosaicstack/types'; import { RoutingEngineService } from './routing-engine.service.js'; import type { RoutingRule, TaskClassification } from './routing.types.js'; @@ -29,7 +30,7 @@ function makeClassification(overrides: Partial = {}): TaskCl /** Build a minimal RoutingEngineService with mocked DB and ProviderService. */ function makeService( rules: RoutingRule[] = [], - healthMap: Record = {}, + healthMap: Record = {}, ): RoutingEngineService { const mockDb = { select: vi.fn().mockReturnValue({ @@ -217,7 +218,10 @@ describe('RoutingEngineService.resolve — priority ordering', () => { }), ]; - const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } }); + const service = makeService(rules, { + anthropic: { status: 'healthy' }, + openai: { status: 'healthy' }, + }); const decision = await service.resolve('implement a function'); expect(decision.ruleName).toBe('high priority'); @@ -241,7 +245,10 @@ describe('RoutingEngineService.resolve — priority ordering', () => { }), ]; - const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } }); + const service = makeService(rules, { + anthropic: { status: 'healthy' }, + openai: { status: 'healthy' }, + }); const decision = await service.resolve('implement a function'); expect(decision.ruleName).toBe('coding rule'); @@ -270,7 +277,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => { const service = makeService(rules, { anthropic: { status: 'down' }, // primary is unhealthy - openai: { status: 'up' }, + openai: { status: 'healthy' }, }); const decision = await service.resolve('implement a function'); @@ -290,7 +297,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => { ]; const service2 = makeService(unhealthyRules, { - anthropic: { status: 'up' }, + anthropic: { status: 'healthy' }, openai: { status: 'down' }, }); @@ -306,7 +313,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => { const service = makeService(rules, { anthropic: { status: 'down' }, // Sonnet is on anthropic — down - ollama: { status: 'up' }, // Haiku is also on anthropic — use Ollama as next + ollama: { status: 'healthy' }, // Haiku is also on anthropic — use Ollama as next }); const decision = await service.resolve('hello there'); @@ -345,7 +352,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', () }), ]; - const service = makeService(rules, { anthropic: { status: 'up' } }); + const service = makeService(rules, { anthropic: { status: 'healthy' } }); const decision = await service.resolve('completely unrelated message xyz'); expect(decision.ruleName).toBe('catch-all'); @@ -369,7 +376,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', () }), ]; - const service = makeService(rules, { anthropic: { status: 'up' } }); + const service = makeService(rules, { anthropic: { status: 'healthy' } }); const codingDecision = await service.resolve('implement a function'); expect(codingDecision.ruleName).toBe('specific coding rule'); @@ -401,7 +408,7 @@ describe('RoutingEngineService.resolve — disabled rules', () => { }), ]; - const service = makeService(rules, { anthropic: { status: 'up' } }); + const service = makeService(rules, { anthropic: { status: 'healthy' } }); const decision = await service.resolve('implement a function'); expect(decision.ruleName).toBe('enabled fallback'); @@ -452,9 +459,45 @@ describe('RoutingEngineService.resolve — availableProviders override', () => { ps: unknown, ) => RoutingEngineService)(mockDb, mockProviderService); - const preSupplied = { anthropic: { status: 'up' } }; + const preSupplied: Record = { + anthropic: { status: 'healthy' }, + }; await service.resolve('implement a function', undefined, preSupplied); expect(mockHealthCheckAll).not.toHaveBeenCalled(); }); }); + +// ─── resolve — canonical ProviderHealthStatus values ────────────────────────── + +describe('RoutingEngineService.resolve — canonical health status routing', () => { + it('routes healthy and degraded providers by rule, and falls through to fallback when down', async () => { + const codingRule = makeRule({ + name: 'coding rule', + priority: 1, + conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }], + action: { provider: 'openai', model: 'gpt-4o' }, + }); + + // healthy → selected by its own rule, not the fallback chain + const healthyService = makeService([codingRule], { openai: { status: 'healthy' } }); + const healthyDecision = await healthyService.resolve('implement a function'); + expect(healthyDecision.ruleName).toBe('coding rule'); + expect(healthyDecision.provider).toBe('openai'); + + // down → rule is skipped as unroutable, falls through to the fallback chain + const downService = makeService([codingRule], { + openai: { status: 'down' }, + anthropic: { status: 'healthy' }, + }); + const downDecision = await downService.resolve('implement a function'); + expect(downDecision.ruleName).toBe('fallback'); + expect(downDecision.provider).toBe('anthropic'); + + // degraded → still routable, selected by its own rule, not the fallback chain + const degradedService = makeService([codingRule], { openai: { status: 'degraded' } }); + const degradedDecision = await degradedService.resolve('implement a function'); + expect(degradedDecision.ruleName).toBe('coding rule'); + expect(degradedDecision.provider).toBe('openai'); + }); +}); diff --git a/apps/gateway/src/app.module.spec.ts b/apps/gateway/src/app.module.spec.ts new file mode 100644 index 00000000..66a2d237 --- /dev/null +++ b/apps/gateway/src/app.module.spec.ts @@ -0,0 +1,624 @@ +import 'reflect-metadata'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import * as nodeOs from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import * as nodeUrl from 'node:url'; +import { MODULE_METADATA } from '@nestjs/common/constants.js'; +import { describe, expect, it, vi } from 'vitest'; +import type { MosaicConfig } from '@mosaicstack/config'; + +interface ComposedModuleGraph { + imports: readonly unknown[]; + federationModule: unknown; + bootLogLines: readonly string[]; + mosaicConfig: MosaicConfig; + resolvedConfigPath: string; +} + +type StorageTier = 'local' | 'standalone' | 'federated'; + +interface ModuleGraphFixture { + tempRoot: string; + anchor: string; + homePath: string; + cwdPath: string; + monorepoRootEnvPath: string; + gatewayLocalEnvPath: string; + daemonEnvPath: string; + monorepoRootConfigPath: string; + gatewayLocalConfigPath: string; +} + +interface ModuleGraphFixtureOptions { + rootEnvMode?: 'present' | 'absent'; + rootTier?: StorageTier; + rootEnvContents?: string; + redactionMarker?: string; + gatewayLocalTier?: StorageTier; + gatewayLocalEnvContents?: string; + daemonEnvContents?: string; + inheritedTier?: StorageTier; + expectedProcessTier?: string; + setup?: (fixture: ModuleGraphFixture) => Promise; +} + +// 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 { + return { ...process.env }; +} + +function restoreProcessEnv(snapshot: Record): 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 { + 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 { + const originalEnv = snapshotProcessEnv(); + const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-module-')); + let consoleInfoSpy: ReturnType | undefined; + let cwdSpy: ReturnType | 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 => { + 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 => { + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'local', + setup: async (fixture: ModuleGraphFixture): Promise => { + 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 => { + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'local', + setup: async (fixture: ModuleGraphFixture): Promise => { + 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 => { + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'local', + setup: async (fixture: ModuleGraphFixture): Promise => { + 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 => { + let gatewayLocalConfigPath = ''; + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'local', + setup: async (fixture: ModuleGraphFixture): Promise => { + 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 => { + let daemonConfigPath = ''; + const graph = await loadModuleGraphFromDotenv({ + rootEnvMode: 'absent', + setup: async (fixture: ModuleGraphFixture): Promise => { + 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 => { + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'federated', + setup: async (fixture: ModuleGraphFixture): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'local', + setup: async (fixture: ModuleGraphFixture): Promise => { + 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 => { + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'local', + setup: async (fixture: ModuleGraphFixture): Promise => { + 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, + ); +}); diff --git a/apps/gateway/src/app.module.ts b/apps/gateway/src/app.module.ts index dc9a6e32..7c2388a8 100644 --- a/apps/gateway/src/app.module.ts +++ b/apps/gateway/src/app.module.ts @@ -21,11 +21,22 @@ import { AdminModule } from './admin/admin.module.js'; import { CommandsModule } from './commands/commands.module.js'; import { PreferencesModule } from './preferences/preferences.module.js'; import { GCModule } from './gc/gc.module.js'; +import { HarnessModule } from './harness/harness.module.js'; import { ReloadModule } from './reload/reload.module.js'; import { WorkspaceModule } from './workspace/workspace.module.js'; import { QueueModule } from './queue/queue.module.js'; import { FederationModule } from './federation/federation.module.js'; 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({ imports: [ @@ -50,10 +61,11 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; PreferencesModule, CommandsModule, GCModule, + HarnessModule, QueueModule, ReloadModule, WorkspaceModule, - FederationModule, + ...(federationEnabled ? [FederationModule] : []), ], controllers: [HealthController], providers: [ diff --git a/apps/gateway/src/chat/__tests__/chat-security.test.ts b/apps/gateway/src/chat/__tests__/chat-security.test.ts index 45bd1f71..61af835d 100644 --- a/apps/gateway/src/chat/__tests__/chat-security.test.ts +++ b/apps/gateway/src/chat/__tests__/chat-security.test.ts @@ -1,10 +1,24 @@ import 'reflect-metadata'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { validateSync } from 'class-validator'; -import { describe, expect, it, vi } from 'vitest'; +import { ValidationPipe, type ArgumentMetadata } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { validateSync, type ValidationError } from 'class-validator'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types'; +import { AgentService } from '../../agent/agent.service.js'; +import { AuthGuard } from '../../auth/auth.guard.js'; +import { HarnessRegistry } from '../../harness/harness.registry.js'; +import { HARNESS_REGISTRY } from '../../harness/harness.tokens.js'; +import type { AuthenticatedUserLike } from '../../auth/session-scope.js'; import { SendMessageDto } from '../../conversations/conversations.dto.js'; -import { ChatRequestDto } from '../chat.dto.js'; +import { ChatController } from '../chat.controller.js'; +import { ChatGateway } from '../chat.gateway.js'; +import { ChatRuntimeRouter } from '../chat-runtime-router.js'; +import { EmbeddedChatRuntime } from '../embedded-chat.runtime.js'; +import { HarnessChatRuntime } from '../harness-chat.runtime.js'; +import type { ChatRuntime } from '../chat-runtime.js'; +import { ChatRequestDto, HarnessTurnSendDto } from '../chat.dto.js'; import { validateSocketSession } from '../chat.gateway-auth.js'; describe('Chat controller source hardening', () => { @@ -17,6 +31,328 @@ describe('Chat controller source hardening', () => { }); }); +describe('Chat runtime routing hardening (Task Five)', () => { + it('routes /api/chat through the exclusive ChatRuntimeRouter, never the embedded AgentService', () => { + const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8'); + + // pi-rpc /api/chat must resolve execution through the one runtime router and never + // reach into embedded agent execution. Legacy embedded behaviour lives behind + // EmbeddedChatRuntime, reachable only via the router in legacy mode. + expect(source).toContain('ChatRuntimeRouter'); + expect(source).not.toContain('@Inject(AgentService)'); + expect(source).not.toContain("from '../agent/agent.service.js'"); + }); + + it('gateway no longer injects the embedded AgentService or RoutingEngineService', () => { + const source = readFileSync(resolve('src/chat/chat.gateway.ts'), 'utf8'); + + expect(source).toContain('ChatRuntimeRouter'); + expect(source).not.toContain('@Inject(AgentService)'); + expect(source).not.toContain('@Inject(RoutingEngineService)'); + }); +}); + +describe('Harness turn:send DTO validation (Task Five, group 2 — frozen wire contract, production pipe)', () => { + // Correction #2 (Scrappy fe3e02): drive PLAIN wire payloads through the EXACT production + // validation the gateway applies to inbound bodies — the global ValidationPipe in + // apps/gateway/src/main.ts: { whitelist, forbidNonWhitelisted, transform }. This exercises + // the real plainToInstance transform, nested @Type/@ValidateNested recursion, and whitelist + // stripping — the path a turn:send actually travels — rather than a hand-built class instance + // fed to validateSync (which never runs @Type and is masked green by class-validator's + // empty-metadata unknownValue behaviour). Anti-masking: every VALUE-rule red asserts the field + // carries a REAL value constraint (not `whitelistValidation`/`unknownValue`), which the + // decorator-less stub can NEVER produce; every authority-field red asserts the forbidden field + // is rejected while a valid field is NOT — false against the stub, which over-rejects everything. + const PRODUCTION_PIPE = () => + new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }); + // Same production configuration, but hand back the raw ValidationError[] instead of throwing a + // BadRequestException, so the test can inspect per-field constraint keys and nested children. + const failPipe = new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + exceptionFactory: (errs: ValidationError[]) => errs as unknown as Error, + }); + const asBody = (metatype: ArgumentMetadata['metatype']): ArgumentMetadata => ({ + type: 'body', + metatype, + data: '', + }); + + const UUID_V4 = '11111111-1111-4111-8111-111111111111'; + const validSelection = () => ({ harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' }); + const validPayload = () => ({ + conversationId: UUID_V4, + content: 'hello there', + selection: validSelection(), + idempotencyKey: UUID_V4, + }); + + // Constraint keys that mean "the field was rejected for existing", NOT "its VALUE failed a real + // rule". The decorator-less RED stub can only ever emit these (or nothing), so requiring a REAL + // value constraint on a field is unmaskable until Step Three attaches the decorators. + const NON_VALUE = new Set(['whitelistValidation', 'unknownValue']); + + // Flatten the error tree to `dotted.path -> Set` (parent + nested children). + const collect = (errors: ValidationError[], prefix = ''): Map> => { + const map = new Map>(); + const add = (path: string, keys: Iterable): void => { + const set = map.get(path) ?? new Set(); + for (const key of keys) set.add(key); + map.set(path, set); + }; + for (const error of errors) { + const path = prefix ? `${prefix}.${error.property}` : error.property; + if (error.constraints) add(path, Object.keys(error.constraints)); + if (error.children?.length) for (const [p, s] of collect(error.children, path)) add(p, s); + } + return map; + }; + + // Run the production pipe over a plain payload; return the ValidationError[] it raised (empty + // when the payload is accepted). + const errorsFor = async ( + payload: unknown, + metatype: ArgumentMetadata['metatype'] = HarnessTurnSendDto, + ): Promise => { + try { + await failPipe.transform(payload, asBody(metatype)); + return []; + } catch (thrown) { + return thrown as ValidationError[]; + } + }; + + // True when `path` is rejected by a REAL value rule (IsUUID, IsNotEmpty, MaxLength, …), i.e. a + // constraint that is not a mere existence/whitelist rejection. + const hasValueConstraint = async ( + payload: unknown, + path: string, + metatype: ArgumentMetadata['metatype'] = HarnessTurnSendDto, + ): Promise => { + const keys = collect(await errorsFor(payload, metatype)).get(path); + return keys ? [...keys].some((key) => !NON_VALUE.has(key)) : false; + }; + + // Paths rejected purely for existing outside the whitelist (authority / unknown-field defence). + const forbiddenFields = async (payload: unknown): Promise => { + const out: string[] = []; + for (const [path, keys] of collect(await errorsFor(payload))) { + if (keys.has('whitelistValidation')) out.push(path); + } + return out; + }; + + // --- GREEN controls: prove the production pipe machinery genuinely accepts a well-formed, + // already-decorated DTO AND enforces its constraints — so the group's reds below are the + // STUB's missing decorators, not a broken harness. Both pass today. --- + it('GREEN control: the production pipe accepts a well-formed, decorated ChatRequestDto', async () => { + await expect( + PRODUCTION_PIPE().transform({ content: 'hello there' }, asBody(ChatRequestDto)), + ).resolves.toBeTruthy(); + }); + + it('GREEN control: the same production pipe rejects over-long ChatRequestDto content (engine truly enforces)', async () => { + expect( + await hasValueConstraint({ content: 'x'.repeat(10_001) }, 'content', ChatRequestDto), + ).toBe(true); + }); + + // --- RED value-rule fence: each asserts the turn:send field is rejected by a REAL value rule. + // All fail against the decorator-less stub; they go green when Step Three adds the decorators. + // No validation implementation is permitted during RED collection. --- + it('requires a conversation id (rejects a missing conversationId)', async () => { + expect( + await hasValueConstraint({ ...validPayload(), conversationId: undefined }, 'conversationId'), + ).toBe(true); + }); + + it('requires a UUID conversation id (rejects a non-UUID conversationId)', async () => { + expect( + await hasValueConstraint( + { ...validPayload(), conversationId: 'not-a-uuid' }, + 'conversationId', + ), + ).toBe(true); + }); + + it('rejects empty / whitespace-only content (content is trimmed 1..10000)', async () => { + expect(await hasValueConstraint({ ...validPayload(), content: ' ' }, 'content')).toBe(true); + }); + + it('rejects content above 10000 characters', async () => { + expect( + await hasValueConstraint({ ...validPayload(), content: 'x'.repeat(10_001) }, 'content'), + ).toBe(true); + }); + + it('requires the nested selection triple (rejects a missing selection)', async () => { + expect(await hasValueConstraint({ ...validPayload(), selection: undefined }, 'selection')).toBe( + true, + ); + }); + + it('rejects malformed selection nesting (a non-object selection)', async () => { + expect( + await hasValueConstraint( + { ...validPayload(), selection: 'pi/anthropic/claude' }, + 'selection', + ), + ).toBe(true); + }); + + it('rejects a selection with a blank harnessId (each id must be non-empty)', async () => { + const payload = { + ...validPayload(), + selection: { harnessId: '', providerId: 'anthropic', modelId: 'claude' }, + }; + expect(await hasValueConstraint(payload, 'selection.harnessId')).toBe(true); + }); + + it('rejects a selection missing the modelId', async () => { + const payload = { ...validPayload(), selection: { harnessId: 'pi', providerId: 'anthropic' } }; + expect(await hasValueConstraint(payload, 'selection.modelId')).toBe(true); + }); + + it('requires a UUID-v4 idempotency key (rejects a missing key)', async () => { + expect( + await hasValueConstraint({ ...validPayload(), idempotencyKey: undefined }, 'idempotencyKey'), + ).toBe(true); + }); + + it('rejects a non-UUID-v4 idempotency key', async () => { + expect( + await hasValueConstraint( + { ...validPayload(), idempotencyKey: 'not-a-key' }, + 'idempotencyKey', + ), + ).toBe(true); + }); + + // --- RED authority/unknown-field fence: the forbidden field is rejected while a valid field is + // NOT spuriously rejected. False against the stub (which over-rejects every field, including + // `content`); true only once Step Three whitelists the legitimate fields. --- + it('rejects a top-level authority field (provider) without flagging valid fields', async () => { + const forbidden = await forbiddenFields({ ...validPayload(), provider: 'openai' }); + expect(forbidden).toContain('provider'); + expect(forbidden).not.toContain('content'); + }); + + it('rejects a top-level modelId authority field without flagging valid fields', async () => { + const forbidden = await forbiddenFields({ ...validPayload(), modelId: 'gpt-5' }); + expect(forbidden).toContain('modelId'); + expect(forbidden).not.toContain('content'); + }); + + it('rejects an attachments field (not part of the frozen turn:send contract)', async () => { + const forbidden = await forbiddenFields({ ...validPayload(), attachments: [{ id: 'a1' }] }); + expect(forbidden).toContain('attachments'); + expect(forbidden).not.toContain('content'); + }); +}); + +describe('Chat runtime routing — behavioural /api/chat fence (Task Five, group 3)', () => { + // The router's own runtime tokens. The controller must reach chat execution ONLY through the + // router; the embedded AgentService below is the forbidden path proven untouched. + const embedded: ChatRuntime = { kind: 'embedded' }; + const harness: ChatRuntime = { kind: 'harness' }; + + // Structurally-complete, non-sentinel conversation service; its methods are never invoked here. + const boundConversationService = { + attach: () => Promise.reject(new Error('unused')), + detach: () => Promise.reject(new Error('unused')), + send: () => Promise.reject(new Error('unused')), + + subscribeFrom: async function* () { + throw new Error('unused'); + }, + } as unknown as HarnessConversationService; + + it('handles an /api/chat turn without invoking the embedded AgentService (behavioural, zero calls)', async () => { + const calls = { getSession: 0, createSession: 0, onEvent: 0, prompt: 0 }; + // A spy standing in for the forbidden embedded runtime. `createSession` rejects so today's + // controller bails immediately (never reaching the 120s agent-response wait) while still + // recording that it reached into the embedded path — the RED anchor. Under Step Three the + // router owns execution and this spy is never touched, so every counter stays 0 (GREEN). + // Any masking mutation that re-enters embedded execution flips a counter and re-reds the test. + const agentSpy = { + getSession: () => { + calls.getSession += 1; + return undefined; + }, + createSession: () => { + calls.createSession += 1; + return Promise.reject(new Error('spy: embedded AgentService must not be used')); + }, + onEvent: () => { + calls.onEvent += 1; + return () => {}; + }, + prompt: () => { + calls.prompt += 1; + return Promise.resolve(); + }, + }; + + const moduleRef = await Test.createTestingModule({ + controllers: [ChatController], + providers: [ + { provide: AgentService, useValue: agentSpy }, + { + // Provided so the Step-Three controller (which injects the router) still resolves here; + // the real, empty registry seeded with a pi adapter keeps the router pi-rpc-ready. + provide: HARNESS_REGISTRY, + useFactory: () => { + const registry = new HarnessRegistry(); + registry.register({ + id: 'pi', + describe: () => Promise.reject(new Error('unused')), + catalog: () => Promise.reject(new Error('unused')), + create: () => Promise.reject(new Error('unused')), + resume: () => Promise.reject(new Error('unused')), + } as HarnessAdapter); + return registry; + }, + }, + { + provide: ChatRuntimeRouter, + useFactory: (registry: HarnessRegistry) => + new ChatRuntimeRouter(registry, boundConversationService, embedded, harness, 'pi-rpc'), + inject: [HARNESS_REGISTRY], + }, + ], + }) + // ChatController's @UseGuards(AuthGuard) is resolved during instance loading; AuthGuard + // injects AUTH, an HTTP-only concern never exercised by a direct handler call. Stub it so + // the graph resolves and the test reds on BEHAVIOUR, not on a DI collection error. + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + try { + const controller = moduleRef.get(ChatController, { strict: false }); + const user = { id: 'user-1' } as AuthenticatedUserLike; + try { + await controller.chat({ content: 'route me' } as ChatRequestDto, user); + } catch { + // Today: SERVICE_UNAVAILABLE from the rejecting spy. Under Step Three: the router path may + // reject on the deliberately-unbound fake conversation service. Either way the + // embedded-call counters below are the contract, not the handler's return value. + } + expect(calls).toEqual({ getSession: 0, createSession: 0, onEvent: 0, prompt: 0 }); + } finally { + await moduleRef.close(); + } + }); + + it('wires the exclusive ChatRuntimeRouter into the chat module graph (defense-in-depth source check)', () => { + const source = readFileSync(resolve('src/chat/chat.module.ts'), 'utf8'); + // The controller can only inject the router if the module actually provides it. RED today: + // ChatModule provides only ChatGateway. GREEN once Step Three registers ChatRuntimeRouter. + expect(source).toContain('ChatRuntimeRouter'); + }); +}); + describe('WebSocket session authentication', () => { it('returns null when the handshake does not resolve to a session', async () => { const result = await validateSocketSession( @@ -47,6 +383,791 @@ describe('WebSocket session authentication', () => { }); }); +describe('Non-Discord ("Telegram-equivalent") socket ingress rejection (Task Five, both runtime modes)', () => { + // Scrappy C adjudication regression: a non-Discord service socket — modelled as a "Telegram" + // client presenting a handshake token the gateway does NOT honour and carrying no Better-Auth + // session — must be DISCONNECTED at handleConnection, never gain the `discordService` trust flag + // or any user scope, receive no manifest and no ack, and reach NEITHER the embedded runtime NOR + // the harness. This must hold in BOTH legacy and pi-rpc modes: introducing the exclusive + // ChatRuntimeRouter / pi-rpc path must not open a second, non-Discord service ingress. This is a + // GREEN control (it holds on this branch and must keep holding through Step Three); no Telegram + // production route or plugin exists or is added — the assertion is that no such surface is + // reachable. The runtime slot is fronted with a REAL, ready ChatRuntimeRouter over + // EmbeddedChatRuntime + HarnessChatRuntime so that any accidental dispatch would flip a spy + // rather than silently pass; the router is never resolved because the socket is rejected first. + let priorMode: string | undefined; + beforeEach(() => { + priorMode = process.env['CHAT_HARNESS_RUNTIME']; + }); + afterEach(() => { + if (priorMode === undefined) delete process.env['CHAT_HARNESS_RUNTIME']; + else process.env['CHAT_HARNESS_RUNTIME'] = priorMode; + }); + + // A pi-ready registry so a pi-rpc router resolves the harness cleanly at onModuleInit — modelling + // the hostile condition where the harness is live yet the non-Discord socket is still rejected. + const readyPiRegistry = (): HarnessRegistry => { + const registry = new HarnessRegistry(); + registry.register({ + id: 'pi', + describe: () => Promise.reject(new Error('unused')), + catalog: () => Promise.reject(new Error('unused')), + create: () => Promise.reject(new Error('unused')), + resume: () => Promise.reject(new Error('unused')), + } as HarnessAdapter); + return registry; + }; + + // Non-sentinel conversation service so pi-rpc onModuleInit resolves the harness (does not throw + // conversation_service_unavailable); its methods must never be invoked on the rejection path. + const availableConversationService = { + attach: () => Promise.reject(new Error('unused')), + detach: () => Promise.reject(new Error('unused')), + send: () => Promise.reject(new Error('unused')), + + subscribeFrom: async function* () { + throw new Error('unused'); + }, + } as unknown as HarnessConversationService; + + const readyRouter = ( + mode: 'legacy' | 'pi-rpc', + agentService: unknown, + harnessConversations: unknown, + ): ChatRuntimeRouter => { + const embedded = new EmbeddedChatRuntime(agentService as never); + const harness = new HarnessChatRuntime(harnessConversations as never); + const router = new ChatRuntimeRouter( + readyPiRegistry(), + availableConversationService, + embedded, + harness, + mode, + ); + router.onModuleInit(); + return router; + }; + + it.each(['legacy', 'pi-rpc'] as const)( + 'disconnects a Telegram-shaped unauthenticated socket and dispatches to no runtime (%s mode)', + async (mode) => { + process.env['CHAT_HARNESS_RUNTIME'] = mode; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn(), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + // Auth stub that resolves NO session for the Telegram socket's headers — the sole gate a + // non-Discord client must pass, and does not. + const auth = { api: { getSession: vi.fn().mockResolvedValue(null) } }; + const gateway = new ChatGateway( + readyRouter(mode, agentService, harnessConversations) as never, + auth as never, + { conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never, + {} as never, + {} as never, + {} as never, + ); + + const client = { + id: `telegram-raw-${mode}`, + // A non-Discord service handshake: the gateway only honours `discordServiceToken`, so this + // token is ignored, and there is no session cookie for validateSocketSession to resolve. + handshake: { auth: { telegramServiceToken: 'ignored-non-discord-token' }, headers: {} }, + data: {} as Record, + emit: vi.fn(), + disconnect: vi.fn(), + }; + + await gateway.handleConnection(client as never); + + // Rejected at the door: disconnected, no trust flag, no user scope, no manifest, and — the + // Task 5 send-capability rule — no send-protocol advertisement to an unauthenticated socket. + expect(client.disconnect).toHaveBeenCalled(); + expect(client.data.discordService).not.toBe(true); + expect(client.data.user).toBeUndefined(); + expect(client.emit).not.toHaveBeenCalledWith('commands:manifest', expect.anything()); + expect(client.emit).not.toHaveBeenCalledWith('chat:send-capability', expect.anything()); + + // Even if the ignored socket then attempts a message, it carries no scope, so the send path + // never begins and no runtime is dispatched. + await gateway.handleMessage( + client as never, + { + conversationId: 'Nova:telegram:chat-1', + content: 'via telegram', + } as never, + ); + + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(agentService.createSession).not.toHaveBeenCalled(); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }, + ); + + // A minimal authenticated-browser connection harness for the send-capability advertisement. + const connectAuthedBrowser = async ( + mode: 'legacy' | 'pi-rpc', + clientId: string, + ): Promise<{ emit: ReturnType; disconnect: ReturnType }> => { + process.env['CHAT_HARNESS_RUNTIME'] = mode; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn(), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + const auth = { + api: { + getSession: vi + .fn() + .mockResolvedValue({ user: { id: 'user-a' }, session: { id: 'session-a' } }), + }, + }; + const gateway = new ChatGateway( + readyRouter(mode, agentService, harnessConversations) as never, + auth as never, + { conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never, + { getManifest: vi.fn().mockReturnValue({ commands: [] }) } as never, + {} as never, + {} as never, + ); + const client = { + id: clientId, + handshake: { auth: {}, headers: { cookie: 'session=abc' } }, + data: {} as Record, + emit: vi.fn(), + disconnect: vi.fn(), + }; + await gateway.handleConnection(client as never); + return client as never; + }; + + it('advertises legacy-message exactly once to an authenticated browser in legacy mode (Task 5 MAJOR-1)', async () => { + const client = await connectAuthedBrowser('legacy', 'browser-cap-legacy'); + + // Legacy mode: the connected Gateway handles the `message` event, so it advertises + // `legacy-message` — targeted, connection-bound, exactly once. + expect(client.emit).toHaveBeenCalledWith('chat:send-capability', { + protocol: 'legacy-message', + connectionId: 'browser-cap-legacy', + }); + const capabilityCalls = client.emit.mock.calls.filter( + (call: unknown[]) => call[0] === 'chat:send-capability', + ); + expect(capabilityCalls).toHaveLength(1); + expect(client.disconnect).not.toHaveBeenCalled(); + }); + + it('advertises unavailable (never turn-send) to an authenticated browser in pi-rpc mode (Task 5 MAJOR-1)', async () => { + const client = await connectAuthedBrowser('pi-rpc', 'browser-cap-pirpc'); + + // pi-rpc mode: the legacy `message` handler fails closed and the authenticated `turn:send` + // handler lands in Task 15, so Task 5 advertises `unavailable` — never `turn-send`. + expect(client.emit).toHaveBeenCalledWith('chat:send-capability', { + protocol: 'unavailable', + connectionId: 'browser-cap-pirpc', + }); + const capabilityCalls = client.emit.mock.calls.filter( + (call: unknown[]) => call[0] === 'chat:send-capability', + ); + expect(capabilityCalls).toHaveLength(1); + expect(capabilityCalls[0]?.[1]).not.toMatchObject({ protocol: 'turn-send' }); + }); + + it.each(['legacy', 'pi-rpc'] as const)( + 'never advertises send-capability to a Discord service socket (%s mode, Task 5 MAJOR-1)', + async (mode) => { + process.env['CHAT_HARNESS_RUNTIME'] = mode; + process.env['DISCORD_SERVICE_TOKEN'] = 'super-secret-discord-token'; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn(), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const gateway = new ChatGateway( + readyRouter(mode, agentService, { append: vi.fn() }) as never, + { api: { getSession: vi.fn() } } as never, + { conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: `discord-service-${mode}`, + handshake: { auth: { discordServiceToken: 'super-secret-discord-token' }, headers: {} }, + data: {} as Record, + emit: vi.fn(), + disconnect: vi.fn(), + }; + + await gateway.handleConnection(client as never); + + // The trusted Discord service socket is not a browser; it never receives a browser + // send-protocol advertisement. + expect(client.data.discordService).toBe(true); + expect(client.emit).not.toHaveBeenCalledWith('chat:send-capability', expect.anything()); + delete process.env['DISCORD_SERVICE_TOKEN']; + }, + ); + + // Record-and-forward instrumentation at the REAL returned-lease boundary: wrap the lease's own + // dispatch/dispose so the test observes the Gateway's invocation counts through the real + // ChatRuntimeRouter -> EmbeddedChatRuntime path — no canned lease, synthesized method, or shim. + const instrumentLease = ( + router: ChatRuntimeRouter, + order: string[], + counters: { dispatch: number; dispose: number }, + ): void => { + const realPrepare = router.prepareLegacySocketTurn.bind(router) as ( + ...args: unknown[] + ) => Promise<{ ok: boolean; value?: { dispatch: () => unknown; dispose: () => unknown } }>; + vi.spyOn(router, 'prepareLegacySocketTurn').mockImplementation((async (...args: unknown[]) => { + const result = await realPrepare(...args); + if (result.ok && result.value) { + const lease = result.value; + const realDispatch = lease.dispatch.bind(lease); + const realDispose = lease.dispose.bind(lease); + lease.dispatch = (): unknown => { + counters.dispatch += 1; + order.push('dispatch'); + return realDispatch(); + }; + lease.dispose = (): unknown => { + counters.dispose += 1; + return realDispose(); + }; + } + return result; + }) as never); + }; + + it('orders a legacy browser turn persist -> ack -> lease.dispatch -> prompt, each exactly once (Task 5 G2)', async () => { + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const order: string[] = []; + const counters = { dispatch: 0, dispose: 0 }; + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn().mockResolvedValue(session), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockImplementation(async (): Promise => { + order.push('prompt'); + }), + }; + const harnessConversations = { append: vi.fn() }; + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'conversation-order-1' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage: vi.fn().mockImplementation(async (): Promise<{ id: string }> => { + order.push('persist'); + return { id: 'message-order-1' }; + }), + }, + }; + const router = readyRouter('legacy', agentService, harnessConversations); + instrumentLease(router, order, counters); + const gateway = new ChatGateway( + router as never, + { api: { getSession: vi.fn() } } as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'browser-order-1', + data: { user: { id: 'user-a' } }, + emit: vi.fn().mockImplementation((event: string): void => { + if (event === 'message:ack') order.push('ack'); + }), + }; + + await gateway.handleMessage( + client as never, + { + conversationId: 'conversation-order-1', + content: 'ordered hello', + } as never, + ); + + // The Gateway persists the user turn, THEN acks, THEN invokes the one-shot lease dispatch which + // finally prompts. Observed at the real lease boundary: dispatch is invoked exactly once and the + // runtime prompts exactly once. + expect(order).toEqual(['persist', 'ack', 'dispatch', 'prompt']); + expect(counters.dispatch).toBe(1); + expect(agentService.prompt).toHaveBeenCalledTimes(1); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('drops a legacy browser turn on persistence failure: zero info/ack/dispatch/prompt, one clean disposal (Task 5 G2)', async () => { + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const order: string[] = []; + const counters = { dispatch: 0, dispose: 0 }; + const unsub = vi.fn(); + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn().mockResolvedValue(session), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue(unsub), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'conversation-fail-1' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage: vi.fn().mockRejectedValue(new Error('persistence unavailable')), + }, + }; + const router = readyRouter('legacy', agentService, harnessConversations); + instrumentLease(router, order, counters); + const gateway = new ChatGateway( + router as never, + { api: { getSession: vi.fn() } } as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'browser-fail-1', + data: { user: { id: 'user-a' } }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + { + conversationId: 'conversation-fail-1', + content: 'will fail persistence', + } as never, + ); + + // A failed user-message persistence aborts the turn with no accept-then-lose: no session:info, + // no ack, the lease never dispatches or prompts, and the just-prepared lease is disposed exactly + // once (listener/channel cleanup) without throwing. The fixed safe error is surfaced. + expect(client.emit).not.toHaveBeenCalledWith('session:info', expect.anything()); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(counters.dispatch).toBe(0); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(counters.dispose).toBe(1); + expect(unsub).toHaveBeenCalledTimes(1); + expect(client.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ code: 'persist_failed' }), + ); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('drops a legacy browser turn when persistence RESOLVES nullish (not only on rejection): zero info/ack/dispatch/prompt, one clean disposal (Task 5 finding 2)', async () => { + // Companion to the rejected-promise case above. A brain adapter that resolves `undefined`/`null` + // instead of throwing must be treated as a persistence FAILURE, never as a saved message — the + // pre-fix code accepted a nullish resolve and dispatched a turn whose user message was never + // durably stored. RED before finding-2: the turn acks + dispatches + prompts on a phantom persist. + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const order: string[] = []; + const counters = { dispatch: 0, dispose: 0 }; + const unsub = vi.fn(); + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn().mockResolvedValue(session), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue(unsub), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'conversation-nullish-1' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + // Resolves nullish rather than throwing: the fix must still fail the turn closed. + addMessage: vi.fn().mockResolvedValue(undefined), + }, + }; + const router = readyRouter('legacy', agentService, harnessConversations); + instrumentLease(router, order, counters); + const gateway = new ChatGateway( + router as never, + { api: { getSession: vi.fn() } } as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'browser-nullish-1', + data: { user: { id: 'user-a' } }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + { + conversationId: 'conversation-nullish-1', + content: 'persist resolves undefined', + } as never, + ); + + expect(client.emit).not.toHaveBeenCalledWith('session:info', expect.anything()); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(counters.dispatch).toBe(0); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(counters.dispose).toBe(1); + expect(unsub).toHaveBeenCalledTimes(1); + expect(client.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ code: 'persist_failed' }), + ); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it("fails a legacy browser send closed when the supplied conversationId is not the sender's: conversation_unavailable, zero persist/mint/dispatch/prompt (Task 5 finding 1)", async () => { + // A browser socket that supplies a conversationId it does not own must be refused at admission, + // BEFORE any runtime effect. Pre-fix, an unresolved/foreign id fell through to session mint + + // dispatch, letting a caller attach to (or resurrect) a conversation outside their scope. + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const order: string[] = []; + const counters = { dispatch: 0, dispose: 0 }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn().mockResolvedValue(undefined), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + const addMessage = vi.fn().mockResolvedValue({ id: 'must-not-persist' }); + const brain = { + conversations: { + // Scoped lookup: the sender does not own this id, so admission resolves undefined. + findById: vi.fn().mockResolvedValue(undefined), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const router = readyRouter('legacy', agentService, harnessConversations); + instrumentLease(router, order, counters); + const gateway = new ChatGateway( + router as never, + { api: { getSession: vi.fn() } } as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'browser-foreign-1', + data: { user: { id: 'user-a' } }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + { + conversationId: 'conversation-foreign-1', + content: 'let me into a conversation I do not own', + } as never, + ); + + // Admission consults the scoped durable record with the SENDER's id, then fails closed: no + // persist, no session mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal. + expect(brain.conversations.findById).toHaveBeenCalledWith('conversation-foreign-1', 'user-a'); + expect(addMessage).not.toHaveBeenCalled(); + expect(brain.conversations.create).not.toHaveBeenCalled(); + expect(agentService.createSession).not.toHaveBeenCalled(); + expect(agentService.onEvent).not.toHaveBeenCalled(); + expect(agentService.addChannel).not.toHaveBeenCalled(); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(counters.dispatch).toBe(0); + expect(counters.dispose).toBe(0); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(client.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ code: 'conversation_unavailable' }), + ); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('admits a legacy browser send with NO conversationId by minting a scoped durable record first, then dispatches once (Task 5 finding 1)', async () => { + // The distinct server-minted-new path: a client that omits conversationId is a brand-new + // conversation. Admission must CREATE the durable record (scoped to the sender) before the turn + // persists/dispatches, and must not consult the ownership lookup (there is nothing to authorize + // yet). This guards the fix from over-reaching and breaking new-conversation creation. + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const order: string[] = []; + const counters = { dispatch: 0, dispose: 0 }; + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn().mockResolvedValue(session), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + // The durable store honours the mint: it returns the exact record asked for (same id, same + // scoped owner). Admission requires that non-null, correctly-identified record before any effect. + const create = vi.fn((data: { id: string; userId: string }) => + Promise.resolve({ id: data.id, userId: data.userId }), + ); + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue(undefined), + findMessages: vi.fn().mockResolvedValue([]), + create, + update: vi.fn().mockResolvedValue(undefined), + addMessage: vi.fn().mockResolvedValue({ id: 'persisted-new' }), + }, + }; + const router = readyRouter('legacy', agentService, harnessConversations); + instrumentLease(router, order, counters); + const gateway = new ChatGateway( + router as never, + { api: { getSession: vi.fn() } } as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'browser-new-1', + data: { user: { id: 'user-a' } }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + { + content: 'first message with no conversationId', + } as never, + ); + + // The durable record is minted for the sender (create with the same server id the turn persists + // under), and the admitted new-conversation turn dispatches exactly once. + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String), userId: 'user-a' }), + ); + expect(counters.dispatch).toBe(1); + expect(agentService.prompt).toHaveBeenCalledTimes(1); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('fails a legacy browser send with NO conversationId closed when the durable mint resolves nullish, with zero dispatch/persist/prompt/ack (Task 5 finding 1)', async () => { + // Minting is not fire-and-forget: if create resolves nullish (the durable write silently + // produced no record), admission must fail closed BEFORE any runtime effect rather than dispatch + // against a conversation that was never persisted. + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const order: string[] = []; + const counters = { dispatch: 0, dispose: 0 }; + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn().mockResolvedValue(session), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + const create = vi.fn().mockResolvedValue(undefined); + const addMessage = vi.fn().mockResolvedValue({ id: 'persisted-new' }); + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue(undefined), + findMessages: vi.fn().mockResolvedValue([]), + create, + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const router = readyRouter('legacy', agentService, harnessConversations); + instrumentLease(router, order, counters); + const gateway = new ChatGateway( + router as never, + { api: { getSession: vi.fn() } } as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'browser-new-nullish-1', + data: { user: { id: 'user-a' } }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + { + content: 'first message, mint resolves nullish', + } as never, + ); + + // The mint was attempted for the sender, then admission failed closed: no persist, no session + // mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal. + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String), userId: 'user-a' }), + ); + expect(addMessage).not.toHaveBeenCalled(); + expect(agentService.createSession).not.toHaveBeenCalled(); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(counters.dispatch).toBe(0); + expect(counters.dispose).toBe(0); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(client.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ code: 'conversation_unavailable' }), + ); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('fails a legacy browser send with NO conversationId closed when the durable mint throws, with zero dispatch/persist/prompt/ack (Task 5 finding 1)', async () => { + // A create that rejects (durable store error) is a persistence failure, not a reason to proceed: + // the exception is caught at the admission seam and collapses to the same fail-closed refusal. + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const order: string[] = []; + const counters = { dispatch: 0, dispose: 0 }; + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn().mockResolvedValue(session), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + const create = vi.fn().mockRejectedValue(new Error('durable store unavailable')); + const addMessage = vi.fn().mockResolvedValue({ id: 'persisted-new' }); + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue(undefined), + findMessages: vi.fn().mockResolvedValue([]), + create, + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const router = readyRouter('legacy', agentService, harnessConversations); + instrumentLease(router, order, counters); + const gateway = new ChatGateway( + router as never, + { api: { getSession: vi.fn() } } as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'browser-new-throw-1', + data: { user: { id: 'user-a' } }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + { + content: 'first message, mint throws', + } as never, + ); + + // The mint was attempted for the sender, then admission failed closed on the thrown error: no + // persist, no session mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal. + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String), userId: 'user-a' }), + ); + expect(addMessage).not.toHaveBeenCalled(); + expect(agentService.createSession).not.toHaveBeenCalled(); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(counters.dispatch).toBe(0); + expect(counters.dispose).toBe(0); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(client.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ code: 'conversation_unavailable' }), + ); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); +}); + describe('Chat DTO validation', () => { it('rejects unsupported message roles', () => { const dto = Object.assign(new SendMessageDto(), { diff --git a/apps/gateway/src/chat/chat-runtime-router.spec.ts b/apps/gateway/src/chat/chat-runtime-router.spec.ts new file mode 100644 index 00000000..b5fb9660 --- /dev/null +++ b/apps/gateway/src/chat/chat-runtime-router.spec.ts @@ -0,0 +1,920 @@ +import 'reflect-metadata'; +import { Global, Module } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types'; +import { AgentService } from '../agent/agent.service.js'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { CommandsModule } from '../commands/commands.module.js'; +import { HarnessModule } from '../harness/harness.module.js'; +import { ChatModule } from './chat.module.js'; +import { ChatGateway } from './chat.gateway.js'; +import { HarnessRegistry } from '../harness/harness.registry.js'; +import { + HARNESS_CONVERSATION_SERVICE, + HARNESS_CONVERSATION_SERVICE_UNAVAILABLE, + HARNESS_REGISTRY, + type HarnessConversationServiceBinding, +} from '../harness/harness.tokens.js'; +import { ChatRuntimeRouter } from './chat-runtime-router.js'; +import { + ChatRuntimeUnavailableError, + ownConversation, + type ChatRuntime, + type ChatRuntimeMode, + type LegacyEmbeddedChatPort, + type LegacyRuntimeStream, + type LegacySessionPresentation, + type LegacySocketTurnLease, + type OwnedConversationContext, +} from './chat-runtime.js'; +import { AppModule } from '../app.module.js'; +import { ProviderService } from '../agent/provider.service.js'; + +/** + * Task Five, Step One (router). Proves the `ChatRuntimeRouter` resolves exactly one + * runtime by mode, fails closed at init when `pi-rpc` preconditions are unmet, and + * never downgrades `pi-rpc` to embedded execution. Red-first: the router is an + * unimplemented stub, so every behavioural assertion below fails until Step Three. + */ + +const embedded: ChatRuntime = { kind: 'embedded' }; +const harness: ChatRuntime = { kind: 'harness' }; + +/** A structurally-complete, non-sentinel conversation service. Its methods are never invoked here. */ +const boundConversationService = { + attach: () => Promise.reject(new Error('unused')), + detach: () => Promise.reject(new Error('unused')), + send: () => Promise.reject(new Error('unused')), + + subscribeFrom: async function* () { + throw new Error('unused'); + }, +} as unknown as HarnessConversationService; + +function registryWith(adapterIds: readonly string[]): HarnessRegistry { + const registry = new HarnessRegistry(); + for (const id of adapterIds) { + registry.register({ + id, + describe: () => Promise.reject(new Error('unused')), + catalog: () => Promise.reject(new Error('unused')), + create: () => Promise.reject(new Error('unused')), + resume: () => Promise.reject(new Error('unused')), + } as HarnessAdapter); + } + return registry; +} + +function buildRouter( + mode: ChatRuntimeMode, + opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding }, +): ChatRuntimeRouter { + return new ChatRuntimeRouter(registryWith(opts.adapters), opts.service, embedded, harness, mode); +} + +/** + * Tear down a module that was deliberately driven to a fail-closed init. + * `NestApplicationContext.close()` re-awaits the module's `initializationPromise` before disposing + * (nest-application-context.js:127); when `init()` rejected, that await re-throws the SAME typed + * startup error, this time into teardown. Each caller here has already captured and asserted that + * exact `ChatRuntimeUnavailableError` via `initError`, so the re-throw is expected teardown noise — + * swallow ONLY that error, and surface anything else so a genuine teardown fault still fails loudly. + */ +async function closeIgnoringFailedInit(moduleRef: TestingModule): Promise { + await moduleRef.close().catch((err: unknown) => { + if (err instanceof ChatRuntimeUnavailableError) return; + throw err; + }); +} + +describe('ChatRuntimeRouter', () => { + it('resolves only the harness runtime in pi-rpc mode when pi adapter and conversation service are present', () => { + const router = buildRouter('pi-rpc', { + adapters: ['pi'], + service: boundConversationService, + }); + + expect(() => router.onModuleInit()).not.toThrow(); + expect(router.active).toBe(harness); + expect(router.active.kind).toBe('harness'); + }); + + it('resolves only the embedded runtime in legacy mode and skips the pi preconditions', () => { + // Empty registry + unavailable service: legacy must ignore both and still start. + const router = buildRouter('legacy', { + adapters: [], + service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE, + }); + + expect(() => router.onModuleInit()).not.toThrow(); + expect(router.active).toBe(embedded); + expect(router.active.kind).toBe('embedded'); + }); + + it('fails closed at init when pi-rpc mode has no registered pi adapter', () => { + const router = buildRouter('pi-rpc', { + adapters: [], + service: boundConversationService, + }); + + expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError); + try { + router.onModuleInit(); + expect.unreachable('onModuleInit must throw when the pi adapter is absent'); + } catch (err) { + expect(err).toBeInstanceOf(ChatRuntimeUnavailableError); + expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable'); + expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported'); + } + }); + + it('fails closed at init when pi-rpc mode has the unavailable conversation-service sentinel', () => { + const router = buildRouter('pi-rpc', { + adapters: ['pi'], + service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE, + }); + + try { + router.onModuleInit(); + expect.unreachable('onModuleInit must throw when the conversation service is unbound'); + } catch (err) { + expect(err).toBeInstanceOf(ChatRuntimeUnavailableError); + expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable'); + expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported'); + } + }); + + it('never falls back to embedded execution when pi-rpc preconditions are unmet', () => { + const router = buildRouter('pi-rpc', { + adapters: [], + service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE, + }); + + expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError); + // A failed pi-rpc init must not silently expose the embedded runtime. + expect(() => router.active).toThrow(); + let leaked: ChatRuntime | undefined; + try { + leaked = router.active; + } catch { + leaked = undefined; + } + expect(leaked).not.toBe(embedded); + }); + + it('exposes only fixed, browser-safe failure text (no raw provider or exception detail)', () => { + const router = buildRouter('pi-rpc', { + adapters: [], + service: boundConversationService, + }); + + try { + router.onModuleInit(); + expect.unreachable('onModuleInit must throw'); + } catch (err) { + const message = (err as ChatRuntimeUnavailableError).message; + expect(message).toBe( + 'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.', + ); + expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(/); + } + }); +}); + +/** + * Task Five, Step Three — legacy port operations fail closed under pi-rpc (direct valid-input). + * + * The unit suite above constructs the router but never invokes a legacy port operation, so the + * six per-operation inner `if (this.mode === 'pi-rpc')` guards are unexercised — a mutation that + * deletes one of them SURVIVES for lack of a test that drives that operation. This group closes + * that gap the right way: it drives each of the six operations DIRECTLY, in pi-rpc mode, with a + * valid branded {@link OwnedConversationContext} and valid input, against a recording embedded + * stub whose method returns a distinguishable `ok:true` success and increments a per-op counter. + * + * For each operation: + * - pi-rpc test asserts the exact frozen `{ ok:false, code:'runtime_unsupported', retryable:false }` + * result AND that the embedded stub was touched zero times (no effects); + * - the paired legacy test proves that same stub method IS reached and returns its distinguishable + * success when the mode does not refuse — so the pi-rpc zero-invocation assertion is meaningful, + * not vacuously true because the stub could never be called. + * + * Deleting ONLY one operation's inner guard makes THAT operation's pi-rpc test behaviorally RED + * (the router returns the embedded `ok:true` value and records the call), with every outer guard + * and the other five inner guards intact. `next` is untouched; nothing here changes production. + */ +describe('ChatRuntimeRouter — legacy port ops fail closed under pi-rpc (Task Five, Step Three)', () => { + const RUNTIME_UNSUPPORTED = { + ok: false, + code: 'runtime_unsupported', + retryable: false, + } as const; + + const PRESENTATION: LegacySessionPresentation = { + provider: 'embedded-provider', + modelId: 'embedded-model', + thinkingLevel: 'low', + availableThinkingLevels: ['low', 'high'], + }; + + const stream: LegacyRuntimeStream = { + channelId: 'websocket:test-socket', + onEvent: () => {}, + }; + + const ctx = (): OwnedConversationContext => + ownConversation('conversation-1', { userId: 'user-1', tenantId: 'tenant-1' }); + + /** + * Per-operation invocation counters with declared keys (not an index signature) so each + * `calls.` is definitely `number` under `noUncheckedIndexedAccess`. + */ + type LegacyPortCallCounts = { + completeLegacyRestTurn: number; + prepareLegacySocketTurn: number; + setLegacyThinking: number; + abortLegacyTurn: number; + applyLegacyModelOverride: number; + readLegacySessionPresentation: number; + dispatchVerifiedDiscordIngress: number; + }; + + /** + * An embedded port that records every invocation and returns a distinguishable `ok:true` + * value per operation. If a router op reaches it (its guard removed), both the recorded call + * count and the returned `ok:true` value diverge from the frozen `runtime_unsupported` result. + */ + function recordingEmbeddedPort(): { + port: ChatRuntime & LegacyEmbeddedChatPort; + calls: LegacyPortCallCounts; + } { + const calls: LegacyPortCallCounts = { + completeLegacyRestTurn: 0, + prepareLegacySocketTurn: 0, + setLegacyThinking: 0, + abortLegacyTurn: 0, + applyLegacyModelOverride: 0, + readLegacySessionPresentation: 0, + dispatchVerifiedDiscordIngress: 0, + }; + const lease: LegacySocketTurnLease = { + presentation: PRESENTATION, + dispatch: () => Promise.resolve({ ok: true, value: undefined }), + dispose: () => Promise.resolve(), + }; + const port: ChatRuntime & LegacyEmbeddedChatPort = { + kind: 'embedded', + completeLegacyRestTurn: () => { + calls.completeLegacyRestTurn += 1; + return Promise.resolve({ + ok: true, + value: { text: 'EMBEDDED-REST', presentation: PRESENTATION }, + }); + }, + prepareLegacySocketTurn: () => { + calls.prepareLegacySocketTurn += 1; + return Promise.resolve({ ok: true, value: lease }); + }, + setLegacyThinking: () => { + calls.setLegacyThinking += 1; + return { ok: true, value: PRESENTATION }; + }, + abortLegacyTurn: () => { + calls.abortLegacyTurn += 1; + return Promise.resolve({ ok: true, value: undefined }); + }, + applyLegacyModelOverride: () => { + calls.applyLegacyModelOverride += 1; + return { ok: true, value: PRESENTATION }; + }, + readLegacySessionPresentation: () => { + calls.readLegacySessionPresentation += 1; + return { ok: true, value: PRESENTATION }; + }, + dispatchVerifiedDiscordIngress: () => { + calls.dispatchVerifiedDiscordIngress += 1; + return Promise.resolve({ + ok: true, + value: { + presentation: PRESENTATION, + dispatch: () => Promise.resolve({ ok: true, value: undefined }), + dispose: () => Promise.resolve(), + }, + }); + }, + }; + return { port, calls }; + } + + function piRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter { + return new ChatRuntimeRouter( + registryWith(['pi']), + boundConversationService, + port, + harness, + 'pi-rpc', + ); + } + function legacyRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter { + return new ChatRuntimeRouter( + registryWith([]), + boundConversationService, + port, + harness, + 'legacy', + ); + } + + // completeLegacyRestTurn --------------------------------------------------- + it('completeLegacyRestTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => { + const { port, calls } = recordingEmbeddedPort(); + const result = await piRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' }); + expect(result).toEqual(RUNTIME_UNSUPPORTED); + expect(calls.completeLegacyRestTurn).toBe(0); + }); + it('completeLegacyRestTurn delegates to embedded under legacy (guard is the sole gate)', async () => { + const { port, calls } = recordingEmbeddedPort(); + const result = await legacyRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' }); + expect(result.ok).toBe(true); + expect(calls.completeLegacyRestTurn).toBe(1); + }); + + // prepareLegacySocketTurn -------------------------------------------------- + it('prepareLegacySocketTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => { + const { port, calls } = recordingEmbeddedPort(); + const result = await piRouter(port).prepareLegacySocketTurn( + ctx(), + { content: 'hello' }, + stream, + ); + expect(result).toEqual(RUNTIME_UNSUPPORTED); + expect(calls.prepareLegacySocketTurn).toBe(0); + }); + it('prepareLegacySocketTurn delegates to embedded under legacy (guard is the sole gate)', async () => { + const { port, calls } = recordingEmbeddedPort(); + const result = await legacyRouter(port).prepareLegacySocketTurn( + ctx(), + { content: 'hello' }, + stream, + ); + expect(result.ok).toBe(true); + expect(calls.prepareLegacySocketTurn).toBe(1); + }); + + // setLegacyThinking (sync) ------------------------------------------------- + it('setLegacyThinking refuses with runtime_unsupported and never touches embedded under pi-rpc', () => { + const { port, calls } = recordingEmbeddedPort(); + const result = piRouter(port).setLegacyThinking(ctx(), 'high'); + expect(result).toEqual(RUNTIME_UNSUPPORTED); + expect(calls.setLegacyThinking).toBe(0); + }); + it('setLegacyThinking delegates to embedded under legacy (guard is the sole gate)', () => { + const { port, calls } = recordingEmbeddedPort(); + const result = legacyRouter(port).setLegacyThinking(ctx(), 'high'); + expect(result.ok).toBe(true); + expect(calls.setLegacyThinking).toBe(1); + }); + + // abortLegacyTurn ---------------------------------------------------------- + it('abortLegacyTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => { + const { port, calls } = recordingEmbeddedPort(); + const result = await piRouter(port).abortLegacyTurn(ctx()); + expect(result).toEqual(RUNTIME_UNSUPPORTED); + expect(calls.abortLegacyTurn).toBe(0); + }); + it('abortLegacyTurn delegates to embedded under legacy (guard is the sole gate)', async () => { + const { port, calls } = recordingEmbeddedPort(); + const result = await legacyRouter(port).abortLegacyTurn(ctx()); + expect(result.ok).toBe(true); + expect(calls.abortLegacyTurn).toBe(1); + }); + + // applyLegacyModelOverride (sync) ------------------------------------------ + it('applyLegacyModelOverride refuses with runtime_unsupported and never touches embedded under pi-rpc', () => { + const { port, calls } = recordingEmbeddedPort(); + const result = piRouter(port).applyLegacyModelOverride(ctx(), 'model-x'); + expect(result).toEqual(RUNTIME_UNSUPPORTED); + expect(calls.applyLegacyModelOverride).toBe(0); + }); + it('applyLegacyModelOverride delegates to embedded under legacy (guard is the sole gate)', () => { + const { port, calls } = recordingEmbeddedPort(); + const result = legacyRouter(port).applyLegacyModelOverride(ctx(), 'model-x'); + expect(result.ok).toBe(true); + expect(calls.applyLegacyModelOverride).toBe(1); + }); + + // readLegacySessionPresentation (sync) ------------------------------------- + it('readLegacySessionPresentation refuses with runtime_unsupported and never touches embedded under pi-rpc', () => { + const { port, calls } = recordingEmbeddedPort(); + const result = piRouter(port).readLegacySessionPresentation(ctx()); + expect(result).toEqual(RUNTIME_UNSUPPORTED); + expect(calls.readLegacySessionPresentation).toBe(0); + }); + it('readLegacySessionPresentation delegates to embedded under legacy (guard is the sole gate)', () => { + const { port, calls } = recordingEmbeddedPort(); + const result = legacyRouter(port).readLegacySessionPresentation(ctx()); + expect(result.ok).toBe(true); + expect(calls.readLegacySessionPresentation).toBe(1); + }); + + // dispatchVerifiedDiscordIngress delegates in BOTH modes (embedded-only, no guard) --------- + it('dispatchVerifiedDiscordIngress delegates to embedded under pi-rpc (embedded-only, no mode guard)', async () => { + const { port, calls } = recordingEmbeddedPort(); + const discordCtx = ctx() as unknown as Parameters< + ChatRuntimeRouter['dispatchVerifiedDiscordIngress'] + >[0]; + const result = await piRouter(port).dispatchVerifiedDiscordIngress(discordCtx, stream); + expect(result.ok).toBe(true); + expect(calls.dispatchVerifiedDiscordIngress).toBe(1); + }); +}); + +/** + * Task Five, Step Two — group 1 (real Nest module-graph readiness). + * + * The unit suite above constructs the router directly. This group drives the SAME contract + * through a real NestJS graph: it imports the production `HarnessModule` (the proven-booting + * idiom from harness.controller.spec.ts) so the router resolves the REAL, empty `HarnessRegistry` + * via the real `HARNESS_REGISTRY` token, then runs the router's `OnModuleInit` through the Nest + * lifecycle (`moduleRef.init()`). Red-first: the router is an unimplemented stub whose + * `onModuleInit` throws a generic Error, so: + * - readiness cases fail because the graph never comes up (init rejects), and + * - fail-closed cases fail because a generic stub throw is NOT the SPECIFIC typed + * `ChatRuntimeUnavailableError` (reason/code) the contract demands — a stub that + * "throws anything" cannot mask these greens. + * The router is NOT wired into a production module yet, so it is provided here via a factory + * over the real registry token. Importing the real `ChatModule` bare is deliberately avoided: + * it injects `AgentService` without importing `AgentModule`, so its graph fails to RESOLVE — a + * collection/DI error, not a behavioural red. `next` is untouched; nothing here implements the router. + */ +describe('ChatRuntimeRouter — real Nest module-graph readiness (Task Five, Step Two group 1)', () => { + async function bootRouterGraph( + mode: ChatRuntimeMode, + opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding }, + ) { + const moduleRef = await Test.createTestingModule({ + imports: [HarnessModule], + providers: [ + { + provide: ChatRuntimeRouter, + useFactory: (registry: HarnessRegistry) => + new ChatRuntimeRouter(registry, opts.service, embedded, harness, mode), + inject: [HARNESS_REGISTRY], + }, + ], + }) + // The imported HarnessModule's controllers reference AuthGuard (an HTTP-only concern, + // never exercised here); stub it so the graph resolves. The registry is NOT overridden — + // group 1 asserts against the genuine production HarnessRegistry. + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + // Resolve the production registry singleton and register the requested adapters ON IT, so + // the router (which injects the same singleton) sees them when its lifecycle hook runs. + const registry = moduleRef.get(HARNESS_REGISTRY, { strict: false }); + for (const id of opts.adapters) { + registry.register({ + id, + describe: () => Promise.reject(new Error('unused')), + catalog: () => Promise.reject(new Error('unused')), + create: () => Promise.reject(new Error('unused')), + resume: () => Promise.reject(new Error('unused')), + } as HarnessAdapter); + } + return moduleRef; + } + + // Capture an init rejection without letting a resolved init masquerade as success. + const initError = (moduleRef: { init(): Promise }): Promise => + moduleRef.init().then( + () => new Error('module init resolved but the contract requires it to reject'), + (err: unknown) => err, + ); + + it('brings the graph up and resolves only the harness runtime in pi-rpc mode (pi adapter + bound service)', async () => { + const moduleRef = await bootRouterGraph('pi-rpc', { + adapters: ['pi'], + service: boundConversationService, + }); + try { + await moduleRef.init(); + const router = moduleRef.get(ChatRuntimeRouter, { strict: false }); + expect(router.active).toBe(harness); + expect(router.active.kind).toBe('harness'); + } finally { + await moduleRef.close(); + } + }); + + it('brings the graph up in legacy mode over the REAL empty HarnessRegistry and resolves only the embedded runtime', async () => { + const moduleRef = await bootRouterGraph('legacy', { + adapters: [], + service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE, + }); + try { + // Defense-in-depth: the production module wires the genuine registry, empty by default — + // guards against a test-double registry silently satisfying the readiness check. + const registry = moduleRef.get(HARNESS_REGISTRY, { strict: false }); + expect(registry).toBeInstanceOf(HarnessRegistry); + expect(registry.list()).toHaveLength(0); + + await moduleRef.init(); + const router = moduleRef.get(ChatRuntimeRouter, { strict: false }); + expect(router.active).toBe(embedded); + expect(router.active.kind).toBe('embedded'); + } finally { + await moduleRef.close(); + } + }); + + it('fails closed at module init when pi-rpc mode has no registered pi adapter (specific typed error, not a stub throw)', async () => { + const moduleRef = await bootRouterGraph('pi-rpc', { + adapters: [], + service: boundConversationService, + }); + try { + const err = await initError(moduleRef); + expect(err).toBeInstanceOf(ChatRuntimeUnavailableError); + expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable'); + expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported'); + } finally { + await closeIgnoringFailedInit(moduleRef); + } + }); + + it('fails closed at module init when pi-rpc mode has the unavailable conversation-service sentinel', async () => { + const moduleRef = await bootRouterGraph('pi-rpc', { + adapters: ['pi'], + service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE, + }); + try { + const err = await initError(moduleRef); + expect(err).toBeInstanceOf(ChatRuntimeUnavailableError); + expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable'); + expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported'); + } finally { + await closeIgnoringFailedInit(moduleRef); + } + }); + + it('surfaces only fixed, browser-safe failure text when the graph fails closed (no stub/exception detail)', async () => { + const moduleRef = await bootRouterGraph('pi-rpc', { + adapters: [], + service: boundConversationService, + }); + try { + const err = await initError(moduleRef); + expect(err).toBeInstanceOf(ChatRuntimeUnavailableError); + const message = (err as ChatRuntimeUnavailableError).message; + expect(message).toBe( + 'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.', + ); + expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(|not implemented/); + } finally { + await closeIgnoringFailedInit(moduleRef); + } + }); +}); + +/** + * Task Five, Step Two — group 1b (production ChatModule wiring, declaration proof). + * + * Correction #1 (Scrappy fe3e02) asked for a red that imports the real `ChatModule` and calls + * `module.init()`. Investigated and found impractical/masking-prone: `ChatModule` provides + * `ChatGateway`, whose 10-argument constructor injects app-global providers (AgentService, AUTH, + * BRAIN, RoutingEngineService) plus the Commands/GC/Mcp/Reload subsystems across a forwardRef + * cycle. Booting it in isolation is a full-app integration boot — "override only unrelated + * dependencies" balloons into faking ~4 subsystems, and `overrideProvider` cannot even grant the + * cross-module export-scope visibility ChatGateway needs (probe: `ChatGateway` unresolved at + * `CommandExecutorService`). That is exactly the STOP-and-return branch of the directive. + * + * The faithful, unmaskable cover instead of a fragile boot: read the PRODUCTION `ChatModule`'s own + * Nest `@Module` metadata to prove it DECLARES the exclusive router provider and imports the real + * `HarnessModule` (the genuine registry source). This inspects the actual module object — not + * source text, not a test factory — so nothing can mask it. Group 1 above separately proves the + * router RESOLVES against the real, empty `HarnessRegistry` through the Nest lifecycle; the union + * of the two covers "the router is wired through ChatModule to the real registry" without the + * impractical single-graph boot. RED today (ChatModule provides only ChatGateway and imports only + * CommandsModule); GREEN once Step Three registers the router and imports HarnessModule. + */ +describe('ChatModule production wiring (Task Five, Step Two group 1b — declaration proof)', () => { + // Unwrap a forwardRef(() => Module) import to the module it references; pass others through. + const resolveImport = (imp: unknown): unknown => + imp && + typeof imp === 'object' && + typeof (imp as { forwardRef?: unknown }).forwardRef === 'function' + ? (imp as { forwardRef: () => unknown }).forwardRef() + : imp; + + // A provider entry is either a class (shorthand) or a { provide, ... } object; take its token. + const providerToken = (provider: unknown): unknown => + typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide; + + it('declares the exclusive ChatRuntimeRouter as a provider on the production ChatModule', () => { + const providers: unknown[] = Reflect.getMetadata('providers', ChatModule) ?? []; + expect(providers.map(providerToken)).toContain(ChatRuntimeRouter); + }); + + it('imports the real HarnessModule into the production ChatModule (registry source, not a test double)', () => { + const imports: unknown[] = Reflect.getMetadata('imports', ChatModule) ?? []; + expect(imports.map(resolveImport)).toContain(HarnessModule); + }); +}); + +/** + * Task Five, Step Two — group 1c (bounded real-`ChatModule` boot). + * + * Scrappy adjudication d67d2b (option c): boot the ACTUAL production `ChatModule` as the SUT and + * assert the exclusive router resolves THROUGH it — the single-graph proof group 1 (router over the + * real registry) and group 1b (production-module metadata) each cover only a half of. The heavy, + * UNRELATED cycle is the only thing bounded away, per the established isolation pattern in + * `apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts`: + * - `CommandsModule` (drags the Commands <-> Reload <-> Chat forwardRef cycle plus GC/Mcp/queue) + * is replaced wholesale with an empty module via `.overrideModule(...).useModule(...)`; + * - `ChatGateway` (10-arg constructor, an HTTP/socket concern never exercised here) is replaced + * with an inert value; + * - the sole legacy-controller dependency, `AgentService`, is supplied by a tiny `@Global()` stub; + * - the HTTP-only `AuthGuard` is stubbed. + * Nothing about the router, `HarnessModule`, the registry, or the conversation-service binding is + * faked in the production-legacy case — those are retrieved from the REAL `ChatModule` graph. Mode + * is driven only through the production `CHAT_HARNESS_RUNTIME` env contract (`resolveChatRuntimeMode`). + * + * Red-first: today `ChatModule` neither imports `HarnessModule` nor provides `ChatRuntimeRouter`, so + * the booted graph contains no router/registry/conversation-service tokens. `init()` may resolve + * (there is no router lifecycle hook yet to reject), so every case fails on the MISSING actual + * router/registry/service wiring — not on unrelated DI, which is bounded away. GREEN at Step Three + * once `ChatModule` imports `HarnessModule`, provides the exclusive router, and binds the + * conversation-service token (defaulting to the unavailable sentinel). + */ +describe('ChatModule bounded real boot (Task Five, Step Two group 1c)', () => { + // The unrelated heavy cycle, replaced wholesale — not stubbed provider-by-provider. + @Module({}) + class EmptyCommandsModule {} + + // The ONLY genuine legacy dependency of the real ChatController, supplied inertly and globally so + // the pre-refactor controller instantiates without dragging AgentModule into the graph. + @Global() + @Module({ + providers: [{ provide: AgentService, useValue: {} }], + exports: [AgentService], + }) + class LegacyControllerDepsModule {} + + const ORIGINAL_RUNTIME_ENV = process.env['CHAT_HARNESS_RUNTIME']; + afterEach(() => { + if (ORIGINAL_RUNTIME_ENV === undefined) delete process.env['CHAT_HARNESS_RUNTIME']; + else process.env['CHAT_HARNESS_RUNTIME'] = ORIGINAL_RUNTIME_ENV; + }); + + /** + * Boot the real ChatModule with only the unrelated cycle bounded away. `mode` is set through the + * genuine production env contract before providers instantiate. The optional overrides replace + * the registry / conversation-service the router injects, exercising the pi-rpc precondition + * branches through the ACTUAL module (they are no-ops today because those tokens are not yet in + * the graph — which is exactly why the router-retrieval assertions go red). + */ + async function bootChatModule( + mode: ChatRuntimeMode, + overrides: { + registryAdapters?: readonly string[]; + conversationService?: HarnessConversationServiceBinding; + } = {}, + ): Promise { + if (mode === 'pi-rpc') process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + else delete process.env['CHAT_HARNESS_RUNTIME']; + + let builder = Test.createTestingModule({ + imports: [LegacyControllerDepsModule, ChatModule], + }) + .overrideModule(CommandsModule) + .useModule(EmptyCommandsModule) + .overrideProvider(ChatGateway) + .useValue({}) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }); + + if (overrides.registryAdapters) { + builder = builder + .overrideProvider(HARNESS_REGISTRY) + .useValue(registryWith(overrides.registryAdapters)); + } + if (overrides.conversationService !== undefined) { + builder = builder + .overrideProvider(HARNESS_CONVERSATION_SERVICE) + .useValue(overrides.conversationService); + } + return builder.compile(); + } + + // Capture an init rejection without letting a resolved init masquerade as success. + const initError = (moduleRef: TestingModule): Promise => + moduleRef.init().then( + () => new Error('module init resolved but the contract requires it to reject'), + (err: unknown) => err, + ); + + it('legacy mode: the actual router resolves the embedded runtime, the actual registry is empty, and the conversation-service token is the unavailable sentinel', async () => { + const moduleRef = await bootChatModule('legacy'); + try { + await moduleRef.init(); + const router = moduleRef.get(ChatRuntimeRouter, { strict: false }); + expect(router.active.kind).toBe('embedded'); + + const registry = moduleRef.get(HARNESS_REGISTRY, { strict: false }); + expect(registry).toBeInstanceOf(HarnessRegistry); + expect(registry.list()).toHaveLength(0); + + const service = moduleRef.get( + HARNESS_CONVERSATION_SERVICE, + { + strict: false, + }, + ); + expect(service).toBe(HARNESS_CONVERSATION_SERVICE_UNAVAILABLE); + } finally { + await moduleRef.close(); + } + }); + + it('pi-rpc mode over the REAL empty registry fails closed at init with the typed adapter-unavailable error', async () => { + const moduleRef = await bootChatModule('pi-rpc'); + try { + const err = await initError(moduleRef); + expect(err).toBeInstanceOf(ChatRuntimeUnavailableError); + expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable'); + expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported'); + } finally { + await closeIgnoringFailedInit(moduleRef); + } + }); + + it('pi-rpc mode with a pi adapter present but the sentinel conversation service fails closed with the typed conversation-service-unavailable error', async () => { + const moduleRef = await bootChatModule('pi-rpc', { + registryAdapters: ['pi'], + conversationService: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE, + }); + try { + const err = await initError(moduleRef); + expect(err).toBeInstanceOf(ChatRuntimeUnavailableError); + expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable'); + expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported'); + } finally { + await closeIgnoringFailedInit(moduleRef); + } + }); + + it('pi-rpc mode with a pi adapter and a bound conversation service: the actual router selects the harness runtime', async () => { + const moduleRef = await bootChatModule('pi-rpc', { + registryAdapters: ['pi'], + conversationService: boundConversationService, + }); + try { + await moduleRef.init(); + const router = moduleRef.get(ChatRuntimeRouter, { strict: false }); + expect(router.active.kind).toBe('harness'); + } finally { + await moduleRef.close(); + } + }); +}); + +/** + * Task Five, Step Two — group 2 (WHOLE production `AppModule` boot, legacy end-to-end wiring). + * + * The groups above bound away the heavy cycle to isolate the router. This group instead boots the + * ACTUAL production `AppModule` (the exact graph `main.ts` runs) in the default LEGACY chat-runtime + * mode, overriding ONLY the storage/network side-effect adapters so the boot is bounded and offline + * — never the chat/router/harness/reload/commands surface under test. The bounded fakes are exactly + * the disk/network leaves: + * - `ProviderService` (the #1 hang risk: its real `onModuleInit` starts an unref'd health-check + * `setInterval` and fetches Ollama over HTTP) → inert no-op instance; + * - `DB_HANDLE`/`DB` → a fake Drizzle-shaped handle that satisfies `runPgliteMigrations` (the local + * tier's `DatabaseModule.onModuleInit`) AND `DefaultRoutingRulesSeed.onModuleInit` (which reads a + * system-rule count — the fake reports rules already present so the seed insert is skipped), + * opening no real database; + * - `STORAGE_ADAPTER`/`MEMORY`/`MEMORY_ADAPTER`/`AUTH`/`BRAIN`/`LOG_SERVICE` → inert fakes so no + * storage/auth/log backend is contacted. + * Local tier (the repo's `mosaic.config.json`) already disables BullMQ/Redis and the queue handles; + * Discord/Telegram/MCP plugins are env-gated and disarmed by deleting their tokens. Nothing about the + * router, `ChatModule`, `HarnessModule`, or `ChatGateway` is faked — those come from the REAL graph. + * + * The boot+init MUST SUCCEED cleanly (proven by `beforeAll` completing and the ChatGateway test + * passing). Red-first: on this branch `ChatRuntimeRouter` is registered in NO module (ChatModule + * provides only ChatGateway), so `moduleRef.get(ChatRuntimeRouter)` throws `UnknownElementException` + * — a WIRING gap, NOT an init failure. That single retrieval is the intended behavioural red; it + * flips green once Step Three registers the exclusive router. The ChatGateway retrieval and its + * browser-facing method surface are asserted alongside and pass today, pinning that the boot itself + * is healthy so the router failure cannot be mistaken for a mis-shaped fake or an unbounded side + * effect. + */ +describe('AppModule production boot — legacy ChatRuntimeRouter wiring (Task Five, Step Two group 2)', () => { + // A Drizzle-shaped fake that satisfies both DB consumers reached during a local-tier init: + // • runPgliteMigrations(): reads handle.db.$client.exec + handle.db.execute(SELECT hashes); + // exec is a no-op and execute yields an empty ledger, so migration statements no-op through. + // • DefaultRoutingRulesSeed.seedDefaultRules(): db.select().from().where() must resolve to a + // row set — we report a non-zero system-rule count so the seeding INSERT branch is skipped. + const fakeDb = { + $client: { exec: async (): Promise => {} }, + execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }), + select: () => ({ + from: () => ({ + where: async (): Promise> => [{ count: 1 }], + }), + }), + insert: () => ({ values: async (): Promise => {} }), + }; + const fakeDbHandle = { db: fakeDb, close: async (): Promise => {} }; + const fakeStorageAdapter = { + name: 'fake', + migrate: async (): Promise => {}, + close: async (): Promise => {}, + }; + // Inert stand-in for the real ProviderService: no health-check interval, no Ollama fetch. + const fakeProviderService = { + onModuleInit: async (): Promise => {}, + onModuleDestroy: (): void => {}, + getRegistry: () => ({ + getAvailable: () => [], + getAll: () => [], + find: () => undefined, + }), + getDefaultModel: () => undefined, + listAvailableModels: () => [], + listProviders: () => [], + getAdapter: () => undefined, + getProvidersHealth: () => [], + }; + const fakeBrain = { conversations: {}, agents: {} }; + + const BOOT_TIMEOUT_MS = 120_000; + + let moduleRef: TestingModule; + let envSnapshot: Record; + + beforeAll(async () => { + envSnapshot = { ...process.env }; + // Env hygiene: disarm the network-facing plugins/adapters and pin the legacy runtime mode. + delete process.env['DATABASE_URL']; + delete process.env['DISCORD_BOT_TOKEN']; + delete process.env['TELEGRAM_BOT_TOKEN']; + delete process.env['MCP_SERVERS']; + delete process.env['CHAT_HARNESS_RUNTIME']; // resolveChatRuntimeMode → 'legacy' + process.env['MOSAIC_STORAGE_TIER'] = 'local'; + + moduleRef = await Test.createTestingModule({ imports: [AppModule] }) + // Storage/network side-effect adapters ONLY — never the router/chat/harness surface under test. + .overrideProvider('DB_HANDLE') + .useValue(fakeDbHandle) + .overrideProvider('DB') + .useValue(fakeDb) + .overrideProvider('STORAGE_ADAPTER') + .useValue(fakeStorageAdapter) + .overrideProvider('AUTH') + .useValue({}) + .overrideProvider('BRAIN') + .useValue(fakeBrain) + .overrideProvider('LOG_SERVICE') + .useValue({}) + .overrideProvider('MEMORY') + .useValue({}) + .overrideProvider('MEMORY_ADAPTER') + .useValue({}) + .overrideProvider(ProviderService) + .useValue(fakeProviderService) + .compile(); + + // The boot itself MUST succeed cleanly — a rejection here is a bounding failure, not the red. + await moduleRef.init(); + }, BOOT_TIMEOUT_MS); + + afterAll(async () => { + if (moduleRef) await moduleRef.close(); + for (const key of Object.keys(process.env)) { + if (!(key in envSnapshot)) delete process.env[key]; + } + for (const [key, value] of Object.entries(envSnapshot)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + // Passes TODAY: the real ChatGateway is provided by the real ChatModule and its browser-facing + // surface exists. This pins that the whole-AppModule boot came up healthy, so the router failure + // below is unambiguously a wiring gap and not a mis-shaped fake or an unbounded side effect. + it('boots the whole AppModule and exposes the real ChatGateway with its browser-facing methods', () => { + const gateway = moduleRef.get(ChatGateway, { strict: false }); + expect(typeof gateway.broadcastReload).toBe('function'); + expect(typeof gateway.getModelOverride).toBe('function'); + expect(typeof gateway.setModelOverride).toBe('function'); + expect(typeof gateway.broadcastSessionInfo).toBe('function'); + }); + + // RED TODAY: ChatRuntimeRouter is registered in no module on this branch, so this retrieval throws + // UnknownElementException — the intended red-first wiring failure. GREEN once Step Three registers + // the exclusive router in the production graph, where legacy mode resolves the embedded runtime. + it('resolves the exclusive ChatRuntimeRouter to the embedded runtime in legacy mode', () => { + const router = moduleRef.get(ChatRuntimeRouter, { strict: false }); + expect(router.active.kind).toBe('embedded'); + }); +}); diff --git a/apps/gateway/src/chat/chat-runtime-router.ts b/apps/gateway/src/chat/chat-runtime-router.ts new file mode 100644 index 00000000..647947ed --- /dev/null +++ b/apps/gateway/src/chat/chat-runtime-router.ts @@ -0,0 +1,173 @@ +import { Injectable, type OnModuleInit } from '@nestjs/common'; +import { HarnessRegistry } from '../harness/harness.registry.js'; +import { + isHarnessConversationServiceAvailable, + type HarnessConversationServiceBinding, +} from '../harness/harness.tokens.js'; +import type { + ChatRuntime, + ChatRuntimeMode, + LegacyBrowserMessagePayload, + LegacyEmbeddedChatPort, + LegacyRuntimeResult, + LegacyRuntimeStream, + LegacySessionPresentation, + LegacySocketTurnLease, + OwnedConversationContext, + VerifiedDiscordIngressContext, + VerifiedDiscordTurnLease, +} from './chat-runtime.js'; +import { ChatRuntimeUnavailableError, resolveChatRuntimeMode } from './chat-runtime.js'; + +/** The fixed fail-closed result for a legacy browser operation issued under `pi-rpc`. */ +const RUNTIME_UNSUPPORTED = { + ok: false as const, + code: 'runtime_unsupported' as const, + retryable: false as const, +}; + +/** + * Resolves the one live {@link ChatRuntime} for this process and enforces the + * `pi-rpc` readiness preconditions at module init — before the gateway accepts + * traffic. It never falls back from `pi-rpc` to embedded execution: an unmet + * `pi-rpc` precondition is a typed startup failure ({@link ChatRuntimeUnavailableError}), + * and until `onModuleInit` selects a runtime, {@link active} throws rather than + * exposing any runtime — a failed `pi-rpc` init can never leak the embedded one. + */ +@Injectable() +export class ChatRuntimeRouter implements OnModuleInit, LegacyEmbeddedChatPort { + private readonly mode: ChatRuntimeMode; + + /** The single resolved runtime. Undefined until a successful `onModuleInit`. */ + private resolved: ChatRuntime | undefined; + + constructor( + private readonly harnessRegistry: HarnessRegistry, + private readonly conversationService: HarnessConversationServiceBinding, + private readonly embedded: ChatRuntime, + private readonly harness: ChatRuntime, + mode: ChatRuntimeMode = resolveChatRuntimeMode(), + ) { + this.mode = mode; + } + + onModuleInit(): void { + if (this.mode === 'legacy') { + // Legacy ignores the pi-rpc preconditions entirely and always runs embedded. + this.resolved = this.embedded; + return; + } + + // pi-rpc: both preconditions are hard startup failures, checked in a fixed order. + if (!this.harnessRegistry.has('pi')) { + this.resolved = undefined; + throw new ChatRuntimeUnavailableError('adapter_unavailable'); + } + if (!isHarnessConversationServiceAvailable(this.conversationService)) { + this.resolved = undefined; + throw new ChatRuntimeUnavailableError('conversation_service_unavailable'); + } + + this.resolved = this.harness; + } + + get active(): ChatRuntime { + if (this.resolved === undefined) { + // Reached only if init has not run or failed closed; never expose a runtime here. + throw new Error('The chat runtime is not available: startup did not resolve a runtime.'); + } + return this.resolved; + } + + /** + * The process-wide mode, available before {@link onModuleInit}. Production handlers read + * this to fail a legacy browser turn closed under `pi-rpc` *before* parsing the payload as + * either browser-legacy input or a Discord envelope — never to branch into a fallback. + */ + get runtimeMode(): ChatRuntimeMode { + return this.mode; + } + + /** + * The embedded runtime narrowed to its port. Only reached on the legacy path (and for the + * verified-Discord op in both modes), where the injected runtime is always a real + * `EmbeddedChatRuntime`. The router spec constructs the router with a bare `{ kind }` stub + * but never invokes a port op, so this narrowing is never exercised against the stub. + */ + private get embeddedPort(): LegacyEmbeddedChatPort { + return this.embedded as unknown as LegacyEmbeddedChatPort; + } + + // --- LegacyEmbeddedChatPort: legacy browser operations fail closed under pi-rpc --- + + completeLegacyRestTurn( + context: OwnedConversationContext, + input: Readonly<{ content: string }>, + ): Promise< + LegacyRuntimeResult> + > { + if (this.mode === 'pi-rpc') { + return Promise.resolve(RUNTIME_UNSUPPORTED); + } + return this.embeddedPort.completeLegacyRestTurn(context, input); + } + + prepareLegacySocketTurn( + context: OwnedConversationContext, + input: LegacyBrowserMessagePayload, + stream: LegacyRuntimeStream, + ): Promise> { + if (this.mode === 'pi-rpc') { + return Promise.resolve(RUNTIME_UNSUPPORTED); + } + return this.embeddedPort.prepareLegacySocketTurn(context, input, stream); + } + + setLegacyThinking( + context: OwnedConversationContext, + level: string, + ): LegacyRuntimeResult { + if (this.mode === 'pi-rpc') { + return RUNTIME_UNSUPPORTED; + } + return this.embeddedPort.setLegacyThinking(context, level); + } + + abortLegacyTurn(context: OwnedConversationContext): Promise> { + if (this.mode === 'pi-rpc') { + return Promise.resolve(RUNTIME_UNSUPPORTED); + } + return this.embeddedPort.abortLegacyTurn(context); + } + + applyLegacyModelOverride( + context: OwnedConversationContext, + modelId: string, + ): LegacyRuntimeResult { + if (this.mode === 'pi-rpc') { + return RUNTIME_UNSUPPORTED; + } + return this.embeddedPort.applyLegacyModelOverride(context, modelId); + } + + readLegacySessionPresentation( + context: OwnedConversationContext, + ): LegacyRuntimeResult { + if (this.mode === 'pi-rpc') { + return RUNTIME_UNSUPPORTED; + } + return this.embeddedPort.readLegacySessionPresentation(context); + } + + /** + * Verified Discord ingress bypasses browser mode: it is embedded-only in BOTH modes and + * never reaches the harness or routing-engine selection. It is reached only through a + * {@link VerifiedDiscordIngressContext}, which exists only after every ingress check. + */ + dispatchVerifiedDiscordIngress( + context: VerifiedDiscordIngressContext, + stream: LegacyRuntimeStream, + ): Promise> { + return this.embeddedPort.dispatchVerifiedDiscordIngress(context, stream); + } +} diff --git a/apps/gateway/src/chat/chat-runtime.ts b/apps/gateway/src/chat/chat-runtime.ts new file mode 100644 index 00000000..7300091a --- /dev/null +++ b/apps/gateway/src/chat/chat-runtime.ts @@ -0,0 +1,273 @@ +import type { ChannelAttachmentDto, RoutingDecisionInfo } from '@mosaicstack/types'; + +/** + * The single chat execution strategy resolved by {@link ChatRuntimeRouter}. + * + * Exactly one runtime is live per process. There is no union that lets a + * `pi-rpc` deployment silently fall back to embedded execution: an unmet + * `pi-rpc` precondition is a typed startup failure, never a downgrade. + */ +export type ChatRuntimeMode = 'legacy' | 'pi-rpc'; + +export type ChatRuntimeKind = 'embedded' | 'harness'; + +/** The resolved runtime. Slice Zero exposes only its immutable {@link ChatRuntimeKind}. */ +export interface ChatRuntime { + readonly kind: ChatRuntimeKind; +} + +/** Why the `pi-rpc` runtime could not be made ready. Both are hard startup failures. */ +export type ChatRuntimeUnavailableReason = + | 'adapter_unavailable' + | 'conversation_service_unavailable'; + +/** + * Raised at module init when `pi-rpc` mode is selected but its preconditions are + * unmet. Carries only fixed, browser-safe text — never a raw exception message, + * stack, or provider detail — and reports the frozen ack code `runtime_unsupported`. + */ +export class ChatRuntimeUnavailableError extends Error { + readonly code = 'runtime_unsupported' as const; + readonly reason: ChatRuntimeUnavailableReason; + + constructor(reason: ChatRuntimeUnavailableReason) { + super( + reason === 'adapter_unavailable' + ? 'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.' + : 'The pi-rpc chat runtime is unavailable: the harness conversation service is not bound.', + ); + this.name = 'ChatRuntimeUnavailableError'; + this.reason = reason; + } +} + +/** + * Resolves the process-wide chat runtime mode from the environment. Anything other + * than the exact opt-in token `pi-rpc` keeps the legacy embedded runtime. + */ +export function resolveChatRuntimeMode( + env: Record = process.env, +): ChatRuntimeMode { + return env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy'; +} + +// --------------------------------------------------------------------------- +// Transitional embedded chat port (Task Five). +// +// The legacy embedded browser behaviour is moved behind this exact interface so +// neither the controller nor the gateway retains AgentService, RoutingEngine, +// session, `piSession`, metric, listener, or channel access. `EmbeddedChatRuntime` +// implements the port; `ChatRuntimeRouter` exposes the same narrowly named +// operations and returns `runtime_unsupported` before touching Embedded for legacy +// browser operations when the mode is `pi-rpc`. +// +// The names are frozen (spec jarvis-brain@1c629b06). Legacy REST completion, +// legacy Socket streaming, P3 harness turns, and verified Discord are distinct +// transport/trust capabilities — there is deliberately no generic +// `sendConversationTurn` nor an AgentService-shaped mirror on the router. +// --------------------------------------------------------------------------- + +/** + * Phantom brand keeping {@link OwnedConversationContext} nominally distinct so browser + * DTOs are never structurally assignable to it. The factory that mints one may be called + * only after authentication with `scopeFromUser(...)`, never with payload authority fields. + */ +declare const ownedConversationContextBrand: unique symbol; + +/** Gateway-only ownership context. Embedded rechecks owner+tenant on every operation. */ +export interface OwnedConversationContext { + readonly [ownedConversationContextBrand]: true; + readonly conversationId: string; + readonly scope: Readonly<{ userId: string; tenantId: string }>; +} + +/** + * Every non-`ok` legacy runtime outcome. Missing, foreign, and no-longer-owned + * conversations all collapse to `conversation_unavailable`. Ownership/mode/validation + * failures are total results and never throw. + */ +export type LegacyRuntimeFailure = + | { readonly ok: false; readonly code: 'runtime_unsupported'; readonly retryable: false } + | { readonly ok: false; readonly code: 'conversation_unavailable'; readonly retryable: false } + | { readonly ok: false; readonly code: 'request_invalid'; readonly retryable: false } + | { + readonly ok: false; + readonly code: 'thinking_level_invalid'; + readonly retryable: false; + readonly availableThinkingLevels: readonly string[]; + } + | { readonly ok: false; readonly code: 'runtime_unavailable'; readonly retryable: true } + | { readonly ok: false; readonly code: 'turn_already_dispatched'; readonly retryable: false } + | { readonly ok: false; readonly code: 'operation_failed'; readonly retryable: boolean } + | { readonly ok: false; readonly code: 'timeout'; readonly retryable: true }; + +/** Total result: an `ok` value or one of the fixed {@link LegacyRuntimeFailure} codes. */ +export type LegacyRuntimeResult = + | { readonly ok: true; readonly value: T } + | LegacyRuntimeFailure; + +/** User-facing session projection. Carries no session object, handle, or credential path. */ +export interface LegacySessionPresentation { + readonly provider: string; + readonly modelId: string; + readonly thinkingLevel: string; + readonly availableThinkingLevels: readonly string[]; + readonly agentName?: string; + readonly routingDecision?: RoutingDecisionInfo; +} + +/** Terminal usage stats, normalized by Embedded from AgentService metrics. */ +export interface LegacyUsage { + readonly provider: string; + readonly modelId: string; + readonly thinkingLevel: string; + readonly tokens: Readonly<{ + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }>; + readonly cost: number; + readonly context: Readonly<{ percent: number | null; window: number }>; +} + +/** + * Normalized stream event. Exposes no `AgentSession`, `piSession`, native handle, raw + * exception, tool arguments, or credential-bearing path — the gateway sees only these. + */ +export type LegacyRuntimeEvent = + | { readonly type: 'started' } + | { readonly type: 'text_delta'; readonly text: string } + | { readonly type: 'thinking_delta'; readonly text: string } + | { + readonly type: 'tool_started'; + readonly toolCallId: string; + readonly toolName: string; + } + | { + readonly type: 'tool_finished'; + readonly toolCallId: string; + readonly toolName: string; + readonly isError: boolean; + } + | { readonly type: 'settled'; readonly usage?: LegacyUsage }; + +/** Legacy browser message input. Authority fields are advisory only; scope comes from the context. */ +export interface LegacyBrowserMessagePayload { + readonly content: string; + readonly provider?: string; + readonly modelId?: string; + readonly agentId?: string; + readonly attachments?: readonly ChannelAttachmentDto[]; +} + +/** A prepared-but-not-yet-dispatched legacy socket turn. */ +export interface LegacySocketTurnLease { + readonly presentation: LegacySessionPresentation; + /** + * Atomically one-shot and scope-rechecking. A second call returns + * `turn_already_dispatched` and performs zero prompt/tool effects. + */ + dispatch(): Promise>; + /** Idempotent, non-throwing. Removes listener and channel, including partial setup. */ + dispose(): Promise; +} + +/** + * Phantom brand for {@link VerifiedDiscordIngressContext}. Minted only after service-token + * auth plus signature, allowlist, binding, expected-route, replay, configured-agent, + * forced-scope, and attachment-normalization checks. + */ +declare const verifiedDiscordIngressContextBrand: unique symbol; + +/** Fully-verified Discord ingress. Contains no socket, envelope, signature, token, or escape hatch. */ +export interface VerifiedDiscordIngressContext { + readonly [verifiedDiscordIngressContextBrand]: true; + readonly conversationId: string; + readonly scope: Readonly<{ userId: string; tenantId: string }>; + readonly configuredAgent: Readonly<{ agentConfigId: string; instanceId: string }>; + readonly content: string; + readonly attachments?: readonly ChannelAttachmentDto[]; + readonly correlationId: string; + readonly discordMessageId: string; + readonly discordUserId: string; +} + +/** Verified-Discord turn lease. Same atomic one-shot dispatch and idempotent dispose rules. */ +export interface VerifiedDiscordTurnLease { + readonly presentation: LegacySessionPresentation; + dispatch(): Promise>; + dispose(): Promise; +} + +/** Server-owned egress projection the runtime pushes normalized events into. */ +export interface LegacyRuntimeStream { + /** Server-derived, e.g. `websocket:`. Never client-supplied. */ + readonly channelId: string; + onEvent(event: LegacyRuntimeEvent): void; +} + +/** + * The exact transitional port. `EmbeddedChatRuntime` implements it; `ChatRuntimeRouter` + * mirrors the operation names and fails closed with `runtime_unsupported` for legacy + * browser operations under `pi-rpc`. + */ +export interface LegacyEmbeddedChatPort { + completeLegacyRestTurn( + context: OwnedConversationContext, + input: Readonly<{ content: string }>, + ): Promise< + LegacyRuntimeResult> + >; + + prepareLegacySocketTurn( + context: OwnedConversationContext, + input: LegacyBrowserMessagePayload, + stream: LegacyRuntimeStream, + ): Promise>; + + setLegacyThinking( + context: OwnedConversationContext, + level: string, + ): LegacyRuntimeResult; + + abortLegacyTurn(context: OwnedConversationContext): Promise>; + + applyLegacyModelOverride( + context: OwnedConversationContext, + modelId: string, + ): LegacyRuntimeResult; + + readLegacySessionPresentation( + context: OwnedConversationContext, + ): LegacyRuntimeResult; + + dispatchVerifiedDiscordIngress( + context: VerifiedDiscordIngressContext, + stream: LegacyRuntimeStream, + ): Promise>; +} + +/** + * Mints an {@link OwnedConversationContext} from a server-derived scope. Callers must pass + * a scope produced by `scopeFromUser(...)` after authentication — never a client-supplied + * authority field. The brand is phantom, so this is the only way to obtain the branded type. + */ +export function ownConversation( + conversationId: string, + scope: Readonly<{ userId: string; tenantId: string }>, +): OwnedConversationContext { + return { conversationId, scope } as unknown as OwnedConversationContext; +} + +/** + * Mints a {@link VerifiedDiscordIngressContext}. Callers must have already completed every + * ingress check (service-token auth, signature, allowlist, binding, expected-route, replay, + * configured-agent, forced-scope, attachment normalization) before calling this. + */ +export function verifyDiscordIngress( + fields: Omit, +): VerifiedDiscordIngressContext { + return { ...fields } as unknown as VerifiedDiscordIngressContext; +} diff --git a/apps/gateway/src/chat/chat.controller.ts b/apps/gateway/src/chat/chat.controller.ts index 7cd0baba..a5d2c434 100644 --- a/apps/gateway/src/chat/chat.controller.ts +++ b/apps/gateway/src/chat/chat.controller.ts @@ -3,21 +3,20 @@ import { Post, Body, Logger, - ForbiddenException, HttpException, HttpStatus, NotFoundException, - Inject, UseGuards, } from '@nestjs/common'; -import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; import { Throttle } from '@nestjs/throttler'; -import { AgentService } from '../agent/agent.service.js'; import { AuthGuard } from '../auth/auth.guard.js'; import { CurrentUser } from '../auth/current-user.decorator.js'; import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; import { v4 as uuid } from 'uuid'; import { ChatRequestDto } from './chat.dto.js'; +import { ChatRuntimeRouter } from './chat-runtime-router.js'; +import { ownConversation } from './chat-runtime.js'; +import type { LegacyRuntimeFailure } from './chat-runtime.js'; interface ChatResponse { conversationId: string; @@ -29,7 +28,7 @@ interface ChatResponse { export class ChatController { private readonly logger = new Logger(ChatController.name); - constructor(@Inject(AgentService) private readonly agentService: AgentService) {} + constructor(private readonly runtime: ChatRuntimeRouter) {} @Post() @Throttle({ default: { limit: 10, ttl: 60_000 } }) @@ -40,68 +39,38 @@ export class ChatController { const conversationId = body.conversationId ?? uuid(); const scope = scopeFromUser(user); - try { - let agentSession = this.agentService.getSession(conversationId, scope); - if (!agentSession) { - agentSession = await this.agentService.createSession(conversationId, { - userId: scope.userId, - tenantId: scope.tenantId, - }); - } - } catch (err) { - if (err instanceof ForbiddenException) { - throw new NotFoundException('Session not found'); - } - this.logger.error( - `Session creation failed for conversation=${conversationId}`, - err instanceof Error ? err.stack : String(err), - ); - throw new HttpException('Agent session unavailable', HttpStatus.SERVICE_UNAVAILABLE); - } - this.logger.debug(`Handling chat request for user=${user.id}, conversation=${conversationId}`); - let responseText = ''; + // The one exclusive runtime owns execution. In legacy mode this reaches the embedded runtime; + // in pi-rpc it fails closed with `runtime_unsupported` before ever touching embedded execution. + const result = await this.runtime.completeLegacyRestTurn( + ownConversation(conversationId, scope), + { content: body.content }, + ); - const done = new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - this.logger.error(`Agent response timed out after 120s for conversation=${conversationId}`); - reject(new Error('Agent response timed out')); - }, 120_000); - - const cleanup = this.agentService.onEvent( - conversationId, - (event: AgentSessionEvent) => { - if ( - event.type === 'message_update' && - event.assistantMessageEvent.type === 'text_delta' - ) { - responseText += event.assistantMessageEvent.delta; - } - if (event.type === 'agent_end') { - clearTimeout(timer); - cleanup(); - resolve(); - } - }, - scope, - ); - }); - - try { - await this.agentService.prompt(conversationId, body.content, scope); - await done; - } catch (err) { - if (err instanceof HttpException) throw err; - const message = err instanceof Error ? err.message : String(err); - if (message.includes('timed out')) { - throw new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT); - } - this.logger.error(`Chat prompt failed for conversation=${conversationId}`, String(err)); - throw new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR); + if (result.ok) { + return { conversationId, text: result.value.text }; } - return { conversationId, text: responseText }; + throw this.toHttpException(result, conversationId); + } + + /** Maps a total {@link LegacyRuntimeFailure} to the fixed browser-safe HTTP surface. */ + private toHttpException(failure: LegacyRuntimeFailure, conversationId: string): HttpException { + switch (failure.code) { + case 'conversation_unavailable': + return new NotFoundException('Session not found'); + case 'request_invalid': + case 'thinking_level_invalid': + return new HttpException('Invalid chat request', HttpStatus.BAD_REQUEST); + case 'timeout': + return new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT); + case 'runtime_unsupported': + case 'runtime_unavailable': + return new HttpException('Agent runtime unavailable', HttpStatus.SERVICE_UNAVAILABLE); + default: + this.logger.error(`Chat turn failed for conversation=${conversationId}: ${failure.code}`); + return new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR); + } } } diff --git a/apps/gateway/src/chat/chat.dto.ts b/apps/gateway/src/chat/chat.dto.ts index 9bd35867..a5cba53c 100644 --- a/apps/gateway/src/chat/chat.dto.ts +++ b/apps/gateway/src/chat/chat.dto.ts @@ -1,5 +1,14 @@ import type { ChannelAttachmentDto } from '@mosaicstack/types'; -import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; +import { + IsNotEmpty, + IsObject, + IsOptional, + IsString, + IsUUID, + MaxLength, + ValidateNested, +} from 'class-validator'; export class ChatRequestDto { @IsOptional() @@ -37,3 +46,56 @@ export class ChatSocketMessageDto { /** Validated channel attachment references; binary content is not embedded. */ attachments?: readonly ChannelAttachmentDto[]; } + +/** + * Task Five, group 2 — the frozen pi-rpc `turn:send` selection triple. + * + * Each id is a required, non-empty, bounded string. There is no `@IsOptional` and no extra + * field: under `forbidNonWhitelisted` an unknown selection key is rejected, and a missing id + * fails `@IsString` (undefined is not a string) rather than silently passing. + */ +export class HarnessTurnSelectionDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + harnessId!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(255) + providerId!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(255) + modelId!: string; +} + +/** + * Task Five, group 2 — the frozen wire contract for a pi-rpc `turn:send`. + * + * Validated through the production `ValidationPipe({ whitelist, forbidNonWhitelisted, transform })`: + * a UUID conversation id; `content` trimmed then bounded to 1..10_000 characters (whitespace-only + * collapses to empty and fails `@IsNotEmpty`); a nested `selection` object recursed with an + * explicit `@Type` (a bare `@ValidateNested` is masked green by class-validator's empty-metadata + * `unknownValue`); and a UUID-v4 idempotency key. No `provider`/`modelId`/`attachments` or other + * authority field is declared, so `forbidNonWhitelisted` rejects every unknown top-level key. + */ +export class HarnessTurnSendDto { + @IsUUID() + conversationId!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @IsNotEmpty() + @MaxLength(10_000) + content!: string; + + @IsObject() + @ValidateNested() + @Type(() => HarnessTurnSelectionDto) + selection!: HarnessTurnSelectionDto; + + @IsUUID('4') + idempotencyKey!: string; +} diff --git a/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts b/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts index ac297590..08f18f95 100644 --- a/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts +++ b/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts @@ -8,12 +8,31 @@ const payload: SlashCommandPayload = { approvalId: 'approval-1', }; +/** + * Task 5 fence (F, existing control): gateway-owned command authorization/approval must + * cause ZERO chat-runtime dispatch. Placed in the gateway's chat-runtime-router slot (the + * former direct `AgentService` slot) so any accidental chat-runtime resolution throws + * loudly instead of silently passing. Because execute/approval run entirely through the + * command executor dependency and never resolve a chat runtime, this fixture is never + * triggered and the ingress stays a GREEN control. + */ +function failIfUsedChatRuntimeRouter() { + return { + onModuleInit: () => { + throw new Error('chat runtime router must not initialise on the command approval path'); + }, + get active(): never { + throw new Error('chat runtime must not be resolved on the command approval path'); + }, + }; +} + function buildGateway(commandExecutor: { execute: ReturnType; createApproval: ReturnType; }): ChatGateway { return new ChatGateway( - {} as never, + failIfUsedChatRuntimeRouter() as never, {} as never, {} as never, {} as never, @@ -72,3 +91,114 @@ describe('ChatGateway command approval ingress', () => { }); }); }); + +/** + * Task 5 (G3) command runtime fence. Under pi-rpc there is no embedded chat session, so + * embedded slash-commands (/model, /agent, and every other non-audited command) are fixed + * "unsupported" and MUST fail closed BEFORE reaching the command executor — never a silent + * fall-through to embedded execution. Only runtime-independent audited system commands + * (/reload) pass through as a positive control, and the approval path stays runtime-independent. + * The router stub here carries `runtimeMode: 'pi-rpc'` and throws if any runtime is resolved, so + * a fence bypass surfaces as a thrown error rather than a silent embedded dispatch. + */ +function buildPiRpcGateway(commandExecutor: { + execute: ReturnType; + createApproval: ReturnType; +}): ChatGateway { + const piRpcRouter = { + runtimeMode: 'pi-rpc' as const, + onModuleInit: () => { + throw new Error('chat runtime router must not initialise on the pi-rpc command path'); + }, + get active(): never { + throw new Error('chat runtime must not be resolved on the pi-rpc command path'); + }, + }; + return new ChatGateway( + piRpcRouter as never, + {} as never, + {} as never, + {} as never, + commandExecutor as never, + {} as never, + ); +} + +describe('ChatGateway command runtime fence (Task 5 G3, pi-rpc)', () => { + const UNSUPPORTED = 'Slash commands are not available on this deployment.'; + + it.each(['model', 'agent', 'gc'])( + 'fails /%s closed before the executor under pi-rpc (execute never called)', + async (command): Promise => { + const commandExecutor = { + execute: vi + .fn() + .mockResolvedValue({ command, conversationId: 'conversation-1', success: true }), + createApproval: vi.fn(), + }; + const gateway = buildPiRpcGateway(commandExecutor); + const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() }; + + await gateway.handleCommandExecute(client as never, { + command, + conversationId: 'conversation-1', + }); + + expect(commandExecutor.execute).toHaveBeenCalledTimes(0); + expect(client.emit).toHaveBeenCalledWith('command:result', { + command, + conversationId: 'conversation-1', + success: false, + message: UNSUPPORTED, + }); + }, + ); + + it('passes the audited /reload system command through as a positive control under pi-rpc', async (): Promise => { + const reloadResult = { command: 'reload', conversationId: 'conversation-1', success: true }; + const commandExecutor = { + execute: vi.fn().mockResolvedValue(reloadResult), + createApproval: vi.fn(), + }; + const gateway = buildPiRpcGateway(commandExecutor); + const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() }; + + await gateway.handleCommandExecute(client as never, { + command: 'reload', + conversationId: 'conversation-1', + }); + + expect(commandExecutor.execute).toHaveBeenCalledTimes(1); + expect(commandExecutor.execute).toHaveBeenCalledWith( + { command: 'reload', conversationId: 'conversation-1' }, + { userId: 'admin-1', tenantId: 'admin-1' }, + ); + expect(client.emit).toHaveBeenCalledWith('command:result', reloadResult); + }); + + it('keeps command approval runtime-independent under pi-rpc (createApproval still runs)', async (): Promise => { + const commandExecutor = { + execute: vi.fn(), + createApproval: vi.fn().mockResolvedValue({ + approvalId: 'approval-1', + expiresAt: '2026-07-12T00:05:00.000Z', + }), + }; + const gateway = buildPiRpcGateway(commandExecutor); + const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() }; + + await gateway.handleCommandApproval(client as never, { + command: 'gc', + conversationId: 'conversation-1', + }); + + expect(commandExecutor.createApproval).toHaveBeenCalledWith( + { command: 'gc', conversationId: 'conversation-1' }, + { userId: 'admin-1', tenantId: 'admin-1' }, + ); + expect(client.emit).toHaveBeenCalledWith( + 'command:approval', + expect.objectContaining({ success: true, approvalId: 'approval-1' }), + ); + }); +}); diff --git a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts index f43dce34..4db4b215 100644 Binary files a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts and b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts differ diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 188f6cd0..b086d135 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -11,7 +11,6 @@ import { MessageBody, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; -import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; import { verifyDiscordIngressEnvelope, parseDiscordInteractionBindings, @@ -33,7 +32,7 @@ import type { AbortPayload, ChannelAttachmentDto, } from '@mosaicstack/types'; -import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js'; +import type { ConversationHistoryMessage } from '../agent/agent.service.js'; import { RUNTIME_PROVIDER_AUDIT_SINK, RuntimeProviderService, @@ -51,6 +50,15 @@ import { CommandRegistryService } from '../commands/command-registry.service.js' import { CommandExecutorService } from '../commands/command-executor.service.js'; import { CommandAuthorizationService } from '../commands/command-authorization.service.js'; import { RoutingEngineService } from '../agent/routing/routing-engine.service.js'; +import { ChatRuntimeRouter } from './chat-runtime-router.js'; +import { + ownConversation, + verifyDiscordIngress, + type LegacyRuntimeEvent, + type LegacyRuntimeStream, + type LegacySocketTurnLease, + type VerifiedDiscordTurnLease, +} from './chat-runtime.js'; import { v4 as uuid } from 'uuid'; import { ChatSocketMessageDto } from './chat.dto.js'; import { validateDiscordServiceToken, validateSocketSession } from './chat.gateway-auth.js'; @@ -60,7 +68,10 @@ import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js'; interface ClientSession { clientId: string; conversationId: string; - cleanup: () => void; + /** Server-derived egress channel id (`websocket:`) this turn streams over. */ + channelId: string; + /** The prepared runtime turn; disposing it removes the listener and channel. */ + lease: LegacySocketTurnLease | VerifiedDiscordTurnLease; /** Accumulated assistant response text for the current turn. */ assistantText: string; /** Tool calls observed during the current turn. */ @@ -69,8 +80,6 @@ interface ClientSession { pendingToolCalls: Map; /** Server-derived owner/tenant scope for this socket's conversation attachment. */ scope: ActorTenantScope; - /** Last routing decision made for this session (M4-008) */ - lastRoutingDecision?: RoutingDecisionInfo; } /** @@ -78,6 +87,14 @@ interface ClientSession { * Keyed by conversationId, value is the model name to use. */ const modelOverrides = new Map(); +/** + * Task 5 (G3): commands whose effect is runtime-independent — they operate on gateway/system + * state rather than an embedded chat session — and therefore stay available under pi-rpc. Every + * other command is an embedded-session command and is fixed-"unsupported" under pi-rpc, failing + * closed before the executor. Kept as an explicit allowlist so adding a runtime-independent + * command is a deliberate edit, never an accidental fall-through. + */ +const RUNTIME_INDEPENDENT_COMMANDS: ReadonlySet = new Set(['reload']); const MAX_REDACTION_BUFFER_LENGTH = 8_192; const MAX_CHANNEL_ATTACHMENTS = 10; const MAX_ATTACHMENT_METADATA_BYTES = 16_384; @@ -218,12 +235,15 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa private readonly discordReplayProtector = new DiscordReplayProtector(); constructor( - @Inject(AgentService) private readonly agentService: AgentService, + private readonly runtime: ChatRuntimeRouter, @Inject(AUTH) private readonly auth: Auth, @Inject(BRAIN) private readonly brain: Brain, @Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService, @Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService, - @Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService, + // Vestigial arity-only slot: the router is the sole execution authority and the gateway + // never routes. The union type erases to `Object`, so with `@Optional()` and no `@Inject` + // this resolves to `null` in every graph (production and test) and is never invoked. + @Optional() private readonly routingEngine: RoutingEngineService | null = null, @Optional() @Inject(CommandAuthorizationService) private readonly commandAuthorization: CommandAuthorizationService | null = null, @@ -261,18 +281,25 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa client.data.session = session.session; this.logger.log(`Client connected: ${client.id}`); client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() }); + + // Send-protocol advertisement (Task 5): a conversation id or harness selection never proves the + // connected Gateway handles a given wire event, so after BetterAuth authentication and + // user/session assignment advertise — exactly once, bound to this connection — which send event + // the browser may use. Legacy mode handles `message` (`legacy-message`); `pi-rpc` fails that + // handler closed and its authenticated `turn:send` handler lands in Task 15, so it advertises + // `unavailable` and never `turn-send`. Capability is routing information, never authorization. + client.emit('chat:send-capability', { + protocol: this.runtime.runtimeMode === 'pi-rpc' ? 'unavailable' : 'legacy-message', + connectionId: client.id, + }); } handleDisconnect(client: Socket): void { this.logger.log(`Client disconnected: ${client.id}`); for (const [key, session] of this.clientSessions) { if (session.clientId !== client.id) continue; - session.cleanup(); - this.agentService.removeChannel( - session.conversationId, - `websocket:${client.id}`, - session.scope, - ); + // The lease owns listener + channel teardown; dispose is idempotent and non-throwing. + void session.lease.dispose(); this.clientSessions.delete(key); this.textEgressBuffers.delete(key); this.thinkingEgressBuffers.delete(key); @@ -304,284 +331,123 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa @ConnectedSocket() client: Socket, @MessageBody() rawData: unknown, ): Promise { - let discordIngress: DiscordIngressPayload | null = null; - let data: ChatSocketMessageDto; + // Verified-Discord ingress and browser turns are distinct trust surfaces: the service flag + // is set only after handshake-token auth at handleConnection. A forged envelope from a + // non-service socket falls through to the browser path, where it is rejected as malformed. if (client.data.discordService) { - if (!isDiscordIngressEnvelope(rawData)) { - this.logger.warn(`Rejected malformed Discord ingress from ${client.id}`); - return; - } - discordIngress = this.resolveDiscordIngress(client, rawData); - if (!discordIngress) return; - data = { - conversationId: discordIngress.conversationId, - content: discordIngress.content, - ...(discordIngress.attachments - ? { - attachments: discordIngress.attachments.map( - (attachment): ChannelAttachmentDto => ({ - id: attachment.id, - name: attachment.name, - url: attachment.url, - mimeType: attachment.contentType, - ...(attachment.sizeBytes !== undefined - ? { sizeBytes: attachment.sizeBytes } - : {}), - }), - ), - } - : {}), - }; - } else { - if (!isChatSocketMessage(rawData)) { - this.logger.warn(`Rejected malformed chat message from ${client.id}`); - return; - } - data = rawData; - } - const conversationId = data.conversationId ?? uuid(); - const clientConversationKey = this.clientConversationKey(client, conversationId); - const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; - if (discordIngress && !discordServiceUserId) { - this.logger.warn( - `Rejected Discord ingress without configured service owner from ${client.id}`, - ); + await this.handleVerifiedDiscordSend(client, rawData); return; } - const scope = discordIngress - ? { - userId: discordServiceUserId!, - tenantId: process.env['DISCORD_SERVICE_TENANT_ID'] ?? discordServiceUserId!, - } - : this.getClientScope(client); + await this.handleBrowserSend(client, rawData); + } + + private async handleBrowserSend(client: Socket, rawData: unknown): Promise { + // Fail a legacy browser turn closed under pi-rpc BEFORE parsing the payload — never fall back. + if (this.runtime.runtimeMode === 'pi-rpc') { + const conversationId = + typeof rawData === 'object' && + rawData !== null && + typeof (rawData as { conversationId?: unknown }).conversationId === 'string' + ? (rawData as { conversationId: string }).conversationId + : undefined; + client.emit('error', { + conversationId, + code: 'runtime_unsupported', + retryable: false, + error: 'Browser chat is not available on this deployment.', + }); + return; + } + + if (!isChatSocketMessage(rawData)) { + this.logger.warn(`Rejected malformed chat message from ${client.id}`); + return; + } + const data = rawData; + const suppliedConversationId = data.conversationId; + const conversationId = suppliedConversationId ?? uuid(); + const scope = this.getClientScope(client); if (!scope) { client.emit('error', { conversationId, error: 'Authenticated user scope is required.' }); return; } - const userId = scope.userId; - const correlationId = discordIngress?.correlationId; - this.logger.log( - `Message from ${client.id} in conversation ${conversationId}${correlationId ? ` correlation=${correlationId}` : ''}`, - ); - - // Ensure agent session exists for this conversation - let sessionRoutingDecision: RoutingDecisionInfo | undefined; - try { - let agentSession = this.agentService.getSession(conversationId, scope); - if (!agentSession) { - // When resuming an existing conversation, load prior messages to inject as context (M1-004) - const conversationHistory = await this.loadConversationHistory(conversationId, userId); - - // M5-004: Check if there's an existing sessionId bound to this conversation - let existingSessionId: string | undefined; - if (userId) { - existingSessionId = await this.getConversationSessionId(conversationId, userId); - if (existingSessionId) { - this.logger.log( - `Resuming existing sessionId=${existingSessionId} for conversation=${conversationId}`, - ); - } - } - - // Determine provider/model via routing engine or per-session /model override (M4-012 / M4-007) - let resolvedProvider = data.provider; - let resolvedModelId = data.modelId; - - const modelOverride = modelOverrides.get(this.modelOverrideKey(conversationId, scope)); - if (modelOverride) { - // /model override bypasses routing engine (M4-007) - resolvedModelId = modelOverride; - this.logger.log( - `Using /model override "${modelOverride}" for conversation=${conversationId}`, - ); - } else if (!resolvedProvider && !resolvedModelId && !discordIngress) { - // No explicit provider/model from client — use routing engine (M4-012) - try { - const routingDecision = await this.routingEngine.resolve(data.content, userId); - resolvedProvider = routingDecision.provider; - resolvedModelId = routingDecision.model; - sessionRoutingDecision = { - model: routingDecision.model, - provider: routingDecision.provider, - ruleName: routingDecision.ruleName, - reason: routingDecision.reason, - }; - this.logger.log( - `Routing decision for conversation=${conversationId}: ${routingDecision.provider}/${routingDecision.model} (rule="${routingDecision.ruleName}")`, - ); - } catch (routingErr) { - this.logger.warn( - `Routing engine failed for conversation=${conversationId}, using defaults`, - routingErr instanceof Error ? routingErr.message : String(routingErr), - ); - } - } - - let resolvedAgentConfigId = data.agentId; - if (discordIngress) { - const binding = this.discordBindingFor(discordIngress, 'send'); - const agentConfig = binding - ? await this.brain.agents.findById(binding.agentConfigId) - : undefined; - if (!binding || !agentConfig || agentConfig.name !== binding.instanceId) { - throw new Error('Configured Discord logical agent is not provisioned'); - } - resolvedAgentConfigId = agentConfig.id; - } - - // M5-004: Use existingSessionId as sessionId when available (session reuse) - const sessionIdToCreate = existingSessionId ?? conversationId; - agentSession = await this.agentService.createSession(sessionIdToCreate, { - provider: resolvedProvider, - modelId: resolvedModelId, - agentConfigId: resolvedAgentConfigId, - userId, - tenantId: scope.tenantId, - conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, - }); - - if (conversationHistory.length > 0) { - this.logger.log( - `Loaded ${conversationHistory.length} prior messages for conversation=${conversationId}`, - ); - } - } - } catch (err) { - this.logger.error( - `Session creation failed for client=${client.id}, conversation=${conversationId}`, - err instanceof Error ? err.stack : String(err), - ); + // Durable ownership admission BEFORE any runtime/listener/channel effect (security fix, + // finding 1). A browser-supplied conversation id must resolve to THIS socket's own durable + // owner; a missing row and a row owned by another user both fail closed here, so runtime state + // is never allocated under an unowned conversation. A send that omits the id is the distinct + // server-minted-new path: the durable record is created first and a creation failure fails + // closed. Both rejections collapse to conversation_unavailable with zero downstream effects. + if ( + !(await this.admitBrowserConversation(suppliedConversationId, conversationId, scope.userId)) + ) { client.emit('error', { conversationId, + code: 'conversation_unavailable', + retryable: false, + error: 'That conversation is not available.', + }); + return; + } + + this.logger.log(`Message from ${client.id} in conversation ${conversationId}`); + + // Dispose any prior turn on this exact channel BEFORE preparing the next: prepare re-adds the + // same server-derived channel id, so disposing after would tear down the new subscription. + const key = this.clientConversationKey(client, conversationId); + await this.disposeExistingSession(key); + + const stream: LegacyRuntimeStream = { + channelId: `websocket:${client.id}`, + onEvent: (event: LegacyRuntimeEvent): void => this.relayEvent(client, conversationId, event), + }; + + const prepared = await this.runtime.prepareLegacySocketTurn( + ownConversation(conversationId, scope), + { + content: data.content, + ...(data.provider ? { provider: data.provider } : {}), + ...(data.modelId ? { modelId: data.modelId } : {}), + ...(data.agentId ? { agentId: data.agentId } : {}), + ...(data.attachments ? { attachments: data.attachments } : {}), + }, + stream, + ); + if (!prepared.ok) { + client.emit('error', { + conversationId, + code: prepared.code, + retryable: prepared.retryable, error: 'Failed to start agent session. Please try again.', }); return; } - // Ensure conversation record exists in the DB before persisting messages - // M5-004: Also bind the sessionId to the conversation record - if (userId) { - await this.ensureConversation(conversationId, userId); - await this.bindSessionToConversation(conversationId, userId, conversationId); - } - - // M5-007: Count the user message - this.agentService.recordMessage(conversationId); - - // Persist the user message - if (userId) { - try { - await this.brain.conversations.addMessage( - { - conversationId, - role: 'user', - content: redactSensitiveContent(data.content).content, - metadata: { - timestamp: new Date().toISOString(), - ...(correlationId - ? { - correlationId, - discordMessageId: discordIngress?.messageId, - discordUserId: discordIngress?.userId, - } - : {}), - ...(data.attachments && data.attachments.length > 0 - ? { - channelAttachments: data.attachments.map( - (attachment): ChannelAttachmentDto => ({ - ...attachment, - name: redactSensitiveContent(attachment.name).content, - url: redactSensitiveContent(attachment.url).content, - }), - ), - } - : {}), - classifications: redactSensitiveContent(data.content).classifications, - }, - }, - userId, - ); - } catch (err) { - this.logger.error( - `Failed to persist user message for conversation=${conversationId}`, - err instanceof Error ? err.stack : String(err), - ); - } - } - - // Always clean up previous listener to prevent leak - const existing = this.clientSessions.get(clientConversationKey); - if (existing) { - existing.cleanup(); - } - - // Subscribe to agent events and relay to client - const cleanup = this.agentService.onEvent( + this.registerClientSession(client, conversationId, stream.channelId, prepared.value, scope); + // Persist the user turn BEFORE acknowledging or dispatching. If persistence fails, abort the + // turn: tear down the just-prepared lease and surface the failure — never ack-then-lose. + const persisted = await this.persistUserMessage( conversationId, - (event: AgentSessionEvent) => { - this.relayEvent(client, conversationId, event); - }, - scope, + scope.userId, + data.content, + data.attachments, ); - - // Preserve routing decision from the existing client session if we didn't get a new one - const prevClientSession = this.clientSessions.get(clientConversationKey); - const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision; - - this.clientSessions.set(clientConversationKey, { - clientId: client.id, - conversationId, - cleanup, - assistantText: '', - toolCalls: [], - pendingToolCalls: new Map(), - scope, - lastRoutingDecision: routingDecisionToStore, - }); - - // Track channel connection - this.agentService.addChannel(conversationId, `websocket:${client.id}`, scope); - - // Send session info so the client knows the model/provider (M4-008: include routing decision) - // Include agentName when a named agent config is active (M5-001) - { - const agentSession = this.agentService.getSession(conversationId, scope); - if (agentSession) { - const piSession = agentSession.piSession; - client.emit('session:info', { - conversationId, - provider: agentSession.provider, - modelId: agentSession.modelId, - thinkingLevel: piSession.thinkingLevel, - availableThinkingLevels: piSession.getAvailableThinkingLevels(), - ...(agentSession.agentName ? { agentName: agentSession.agentName } : {}), - ...(routingDecisionToStore ? { routingDecision: routingDecisionToStore } : {}), - }); - } + if (!persisted) { + await this.disposeExistingSession(key); + client.emit('error', { + conversationId, + code: 'persist_failed', + retryable: true, + error: 'Your message could not be saved. Please try again.', + }); + return; } - // Send acknowledgment - client.emit('message:ack', { - conversationId, - messageId: uuid(), - ...(correlationId - ? { - correlationId, - discordMessageId: discordIngress?.messageId, - discordUserId: discordIngress?.userId, - } - : {}), - }); + client.emit('session:info', { conversationId, ...prepared.value.presentation }); + client.emit('message:ack', { conversationId, messageId: uuid() }); - // Dispatch to agent - try { - await this.agentService.prompt(conversationId, data.content, scope, data.attachments); - } catch (err) { - this.logger.error( - `Agent prompt failed for client=${client.id}, conversation=${conversationId}`, - err instanceof Error ? err.stack : String(err), - ); + const dispatched = await prepared.value.dispatch(); + if (!dispatched.ok) { client.emit('error', { conversationId, error: 'The agent failed to process your message. Please try again.', @@ -589,6 +455,249 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } } + private async handleVerifiedDiscordSend(client: Socket, rawData: unknown): Promise { + if (!isDiscordIngressEnvelope(rawData)) { + this.logger.warn(`Rejected malformed Discord ingress from ${client.id}`); + return; + } + const ingress = this.resolveDiscordIngress(client, rawData, 'send', false); + if (!ingress) return; + + const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; + if (!discordServiceUserId) { + this.logger.warn( + `Rejected Discord ingress without configured service owner from ${client.id}`, + ); + return; + } + const binding = this.discordBindingFor(ingress, 'send'); + if (!binding) { + this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`); + return; + } + + const scope: ActorTenantScope = { + userId: discordServiceUserId, + tenantId: process.env['DISCORD_SERVICE_TENANT_ID'] ?? discordServiceUserId, + }; + const conversationId = ingress.conversationId; + const attachments = ingress.attachments?.map( + (attachment): ChannelAttachmentDto => ({ + id: attachment.id, + name: attachment.name, + url: attachment.url, + mimeType: attachment.contentType, + ...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}), + }), + ); + + // Reconcile the verified binding against the durable agent record BEFORE claiming the message + // id (security fix, finding 3). The configured agent must exist and its record must match the + // binding EXACTLY on BOTH id and name — a record whose id differs from the binding's + // agentConfigId (an aliased/substituted lookup) is rejected as firmly as a name mismatch. A + // missing record, an id mismatch, a name mismatch, or a lookup error rejects the turn WITHOUT + // consuming the replay claim, so a corrected retry is still admitted. The branded identity is + // derived from the record (id + name), never from the raw binding strings — an unreconciled + // binding must not execute a default or different embedded agent under a verified label. + let configuredAgent: { readonly agentConfigId: string; readonly instanceId: string }; + try { + const record = await this.brain.agents.findById(binding.agentConfigId); + if (!record || record.id !== binding.agentConfigId || record.name !== binding.instanceId) { + this.logger.warn( + `Rejected Discord ingress: configured agent not reconciled binding=${binding.agentConfigId} instance=${binding.instanceId}`, + ); + return; + } + configuredAgent = { agentConfigId: record.id, instanceId: record.name }; + } catch (err) { + this.logger.error( + `Discord configured-agent reconciliation failed binding=${binding.agentConfigId}`, + err instanceof Error ? err.stack : String(err), + ); + return; + } + + // Atomic replay claim LAST — after the configured-identity, binding, forced-scope, and + // attachment checks above have all passed, and immediately before the first effect (existing + // session teardown + dispatch). An envelope rejected by any earlier gate consumes no claim, so + // a corrected byte-identical retry dispatches once; once a turn commits here, a true duplicate + // finds the claim taken and fails closed with no additional dispatch/persist/ack. + if (!this.discordReplayProtector.claim(ingress.messageId)) { + this.logger.warn( + `Rejected replayed Discord message=${ingress.messageId} correlation=${ingress.correlationId}`, + ); + return; + } + + this.logger.log( + `Message from ${client.id} in conversation ${conversationId} correlation=${ingress.correlationId}`, + ); + + const key = this.clientConversationKey(client, conversationId); + await this.disposeExistingSession(key); + + const stream: LegacyRuntimeStream = { + channelId: `websocket:${client.id}`, + onEvent: (event: LegacyRuntimeEvent): void => this.relayEvent(client, conversationId, event), + }; + + // The configured agent is the record reconciled above (finding 3): the embedded runtime rechecks + // scope, refuses to reuse a same-scope session under a different identity, and mints under this + // exact reconciled identity. + const context = verifyDiscordIngress({ + conversationId, + scope, + configuredAgent, + content: ingress.content, + ...(attachments && attachments.length > 0 ? { attachments } : {}), + correlationId: ingress.correlationId, + discordMessageId: ingress.messageId, + discordUserId: ingress.userId, + }); + + const prepared = await this.runtime.dispatchVerifiedDiscordIngress(context, stream); + if (!prepared.ok) { + client.emit('error', { + conversationId, + code: prepared.code, + retryable: prepared.retryable, + error: 'Failed to start agent session. Please try again.', + }); + return; + } + + this.registerClientSession(client, conversationId, stream.channelId, prepared.value, scope); + // Persist BEFORE acknowledging or dispatching; on persistence failure abort the verified turn + // (tear down the lease, surface the error) rather than ack-then-lose the Discord message. + const persisted = await this.persistUserMessage( + conversationId, + scope.userId, + ingress.content, + attachments, + { + correlationId: ingress.correlationId, + discordMessageId: ingress.messageId, + discordUserId: ingress.userId, + }, + ); + if (!persisted) { + await this.disposeExistingSession(key); + client.emit('error', { + conversationId, + code: 'persist_failed', + retryable: true, + correlationId: ingress.correlationId, + discordMessageId: ingress.messageId, + discordUserId: ingress.userId, + error: 'Your message could not be saved. Please try again.', + }); + return; + } + + client.emit('session:info', { conversationId, ...prepared.value.presentation }); + client.emit('message:ack', { + conversationId, + messageId: uuid(), + correlationId: ingress.correlationId, + discordMessageId: ingress.messageId, + discordUserId: ingress.userId, + }); + + const dispatched = await prepared.value.dispatch(); + if (!dispatched.ok) { + client.emit('error', { + conversationId, + error: 'The agent failed to process your message. Please try again.', + }); + } + } + + private registerClientSession( + client: Socket, + conversationId: string, + channelId: string, + lease: LegacySocketTurnLease | VerifiedDiscordTurnLease, + scope: ActorTenantScope, + ): void { + this.clientSessions.set(this.clientConversationKey(client, conversationId), { + clientId: client.id, + conversationId, + channelId, + lease, + assistantText: '', + toolCalls: [], + pendingToolCalls: new Map(), + scope, + }); + } + + private async disposeExistingSession(key: string): Promise { + const existing = this.clientSessions.get(key); + if (!existing) return; + await existing.lease.dispose(); + this.clientSessions.delete(key); + } + + private async persistUserMessage( + conversationId: string, + userId: string | undefined, + content: string, + attachments: readonly ChannelAttachmentDto[] | undefined, + discord?: { correlationId: string; discordMessageId: string; discordUserId: string }, + ): Promise { + if (!userId) return true; + await this.ensureConversation(conversationId, userId); + try { + const saved = await this.brain.conversations.addMessage( + { + conversationId, + role: 'user', + content: redactSensitiveContent(content).content, + metadata: { + timestamp: new Date().toISOString(), + ...(discord + ? { + correlationId: discord.correlationId, + discordMessageId: discord.discordMessageId, + discordUserId: discord.discordUserId, + } + : {}), + ...(attachments && attachments.length > 0 + ? { + channelAttachments: attachments.map( + (attachment): ChannelAttachmentDto => ({ + ...attachment, + name: redactSensitiveContent(attachment.name).content, + url: redactSensitiveContent(attachment.url).content, + }), + ), + } + : {}), + classifications: redactSensitiveContent(content).classifications, + }, + }, + userId, + ); + // A nullish result is durable persistence failure, not success: `addMessage` returns + // undefined when the parent conversation is missing or owned by another user, inserting no + // row. Treat it exactly like a thrown error so the caller tears down the lease and surfaces + // `persist_failed` — never ack/dispatch/prompt on a turn whose user message was not stored. + if (saved === undefined || saved === null) { + this.logger.error( + `User message not persisted for conversation=${conversationId}: no durable record (missing or foreign conversation owner)`, + ); + return false; + } + return true; + } catch (err) { + this.logger.error( + `Failed to persist user message for conversation=${conversationId}`, + err instanceof Error ? err.stack : String(err), + ); + return false; + } + } + @SubscribeMessage('set:thinking') handleSetThinking( @ConnectedSocket() client: Socket, @@ -603,37 +712,35 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return; } - const session = this.agentService.getSession(data.conversationId, scope); - if (!session) { - client.emit('error', { - conversationId: data.conversationId, - error: 'No active session for this conversation.', - }); + const result = this.runtime.setLegacyThinking( + ownConversation(data.conversationId, scope), + data.level, + ); + if (!result.ok) { + if (result.code === 'thinking_level_invalid') { + client.emit('error', { + conversationId: data.conversationId, + error: `Invalid thinking level "${data.level}". Available: ${result.availableThinkingLevels.join(', ')}`, + }); + } else if (result.code === 'conversation_unavailable') { + client.emit('error', { + conversationId: data.conversationId, + error: 'No active session for this conversation.', + }); + } else { + client.emit('error', { + conversationId: data.conversationId, + error: 'Failed to set thinking level.', + }); + } return; } - const validLevels = session.piSession.getAvailableThinkingLevels(); - if (!validLevels.includes(data.level as never)) { - client.emit('error', { - conversationId: data.conversationId, - error: `Invalid thinking level "${data.level}". Available: ${validLevels.join(', ')}`, - }); - return; - } - - session.piSession.setThinkingLevel(data.level as never); this.logger.log( `Thinking level set to "${data.level}" for conversation ${data.conversationId}`, ); - client.emit('session:info', { - conversationId: data.conversationId, - provider: session.provider, - modelId: session.modelId, - thinkingLevel: session.piSession.thinkingLevel, - availableThinkingLevels: session.piSession.getAvailableThinkingLevels(), - ...(session.agentName ? { agentName: session.agentName } : {}), - }); + client.emit('session:info', { conversationId: data.conversationId, ...result.value }); } @SubscribeMessage('abort') @@ -650,28 +757,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return; } - const session = this.agentService.getSession(conversationId, scope); - if (!session) { + const result = await this.runtime.abortLegacyTurn(ownConversation(conversationId, scope)); + if (!result.ok) { client.emit('error', { conversationId, - error: 'No active session to abort.', + error: + result.code === 'conversation_unavailable' + ? 'No active session to abort.' + : 'Failed to abort the agent operation.', }); return; } - - try { - await session.piSession.abort(); - this.logger.log(`Agent session ${conversationId} aborted successfully`); - } catch (err) { - this.logger.error( - `Failed to abort session ${conversationId}`, - err instanceof Error ? err.stack : String(err), - ); - client.emit('error', { - conversationId, - error: 'Failed to abort the agent operation.', - }); - } + this.logger.log(`Agent session ${conversationId} aborted successfully`); } @SubscribeMessage('command:execute') @@ -690,6 +787,22 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return; } + // Task 5 (G3): under pi-rpc there is no embedded chat session, so embedded slash-commands are + // unsupported. Fail closed BEFORE the executor — never fall back to embedded execution — while + // runtime-independent audited system commands (e.g. /reload) still pass through. + if ( + this.runtime.runtimeMode === 'pi-rpc' && + !RUNTIME_INDEPENDENT_COMMANDS.has(payload.command) + ) { + client.emit('command:result', { + command: payload.command, + conversationId: payload.conversationId, + success: false, + message: 'Slash commands are not available on this deployment.', + }); + return; + } + const result = await this.commandExecutor.execute(payload, scope); client.emit('command:result', result); } @@ -741,7 +854,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`); // M5-002: Update the live session's modelId so session:info reflects the new model immediately - this.agentService.updateSessionModel(conversationId, modelName, scope); + this.runtime.applyLegacyModelOverride(ownConversation(conversationId, scope), modelName); // M5-005: Broadcast session:info to all clients subscribed to this conversation this.broadcastSessionInfo(conversationId, scope); @@ -767,17 +880,15 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa scope: ActorTenantScope, extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo }, ): void { - const agentSession = this.agentService.getSession(conversationId, scope); - if (!agentSession) return; + const result = this.runtime.readLegacySessionPresentation( + ownConversation(conversationId, scope), + ); + if (!result.ok) return; - const piSession = agentSession.piSession; - const resolvedAgentName = extra?.agentName ?? agentSession.agentName; + const resolvedAgentName = extra?.agentName ?? result.value.agentName; const payload = { conversationId, - provider: agentSession.provider, - modelId: agentSession.modelId, - thinkingLevel: piSession.thinkingLevel, - availableThinkingLevels: piSession.getAvailableThinkingLevels(), + ...result.value, ...(resolvedAgentName ? { agentName: resolvedAgentName } : {}), ...(extra?.routingDecision ? { routingDecision: extra.routingDecision } : {}), }; @@ -965,6 +1076,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa client: Socket, envelope: DiscordIngressEnvelope, operation: 'send' | 'approve' | 'stop' = 'send', + claimReplay = true, ): DiscordIngressPayload | null { const payload = verifyDiscordIngressEnvelope( envelope, @@ -998,7 +1110,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ); return null; } - if (!this.discordReplayProtector.claim(payload.messageId)) { + // The SEND path passes `claimReplay: false` and claims the message itself only after the + // configured-identity, binding, forced-scope, and attachment checks succeed — immediately + // before its first effect — so a SEND rejected by one of those later gates burns no claim and + // a corrected retry is not mistaken for a replay. The approve/stop paths have no such + // post-resolve gates, so they claim here, at the moment the envelope is fully verified. + if (claimReplay && !this.discordReplayProtector.claim(payload.messageId)) { this.logger.warn( `Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`, ); @@ -1027,6 +1144,42 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa .filter((id: string): boolean => id.length > 0); } + /** + * Durable ownership admission for a browser send, run BEFORE any runtime/listener/channel effect + * (security fix, finding 1). + * + * A supplied conversation id must durably resolve to this socket's own user: `findById` scopes by + * owner, so a missing row and a row owned by another user both return undefined and admission + * fails (returns false) with zero runtime effect. A send that omits the id + * (`suppliedConversationId === undefined`) is the server-minted-new path — the durable record is + * created first and any creation failure fails closed. Never allocate runtime under a conversation + * this socket does not own or could not create. + */ + private async admitBrowserConversation( + suppliedConversationId: string | undefined, + conversationId: string, + userId: string, + ): Promise { + try { + if (suppliedConversationId !== undefined) { + const owned = await this.brain.conversations.findById(suppliedConversationId, userId); + return owned !== undefined; + } + // Minting a new conversation must actually yield the durable record we asked for before any + // runtime effect runs (finding 1). A `create` that resolves nullish, or returns a record that + // is not this exact id owned by this user, is a persistence failure — fail closed so the caller + // never dispatches/persists against an unpersisted or mis-scoped conversation. + const created = await this.brain.conversations.create({ id: conversationId, userId }); + return created != null && created.id === conversationId && created.userId === userId; + } catch (err) { + this.logger.error( + `Conversation admission failed for conversation=${conversationId}`, + err instanceof Error ? err.stack : String(err), + ); + return false; + } + } + private async ensureConversation(conversationId: string, userId: string): Promise { try { const existing = await this.brain.conversations.findById(conversationId, userId); @@ -1044,45 +1197,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } } - /** - * M5-004: Bind the agent sessionId to the conversation record in the DB. - * Updates the sessionId column so future resumes can reuse the session. - */ - private async bindSessionToConversation( - conversationId: string, - userId: string, - sessionId: string, - ): Promise { - try { - await this.brain.conversations.update(conversationId, userId, { sessionId }); - } catch (err) { - this.logger.error( - `Failed to bind sessionId=${sessionId} to conversation=${conversationId}`, - err instanceof Error ? err.stack : String(err), - ); - } - } - - /** - * M5-004: Retrieve the sessionId bound to a conversation, if any. - * Returns undefined when the conversation does not exist or has no bound session. - */ - private async getConversationSessionId( - conversationId: string, - userId: string, - ): Promise { - try { - const conv = await this.brain.conversations.findById(conversationId, userId); - return conv?.sessionId ?? undefined; - } catch (err) { - this.logger.error( - `Failed to get sessionId for conversation=${conversationId}`, - err instanceof Error ? err.stack : String(err), - ); - return undefined; - } - } - /** * Load prior conversation messages from DB for context injection on session resume (M1-004). * Returns an empty array when no history exists, the conversation is not owned by the user, @@ -1251,7 +1365,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return `${this.clientConversationKey(client, conversationId)}:${eventName}`; } - private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void { + /** + * Relay one normalized {@link LegacyRuntimeEvent} to the socket, preserving the exact legacy + * egress event names and shapes. The runtime owns session/token bookkeeping — the gateway never + * reads a pi session or records usage here; usage arrives verbatim on the `settled` event. + */ + private relayEvent(client: Socket, conversationId: string, event: LegacyRuntimeEvent): void { if (!client.connected) { this.logger.warn( `Dropping event ${event.type} for disconnected client=${client.id}, conversation=${conversationId}`, @@ -1261,7 +1380,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const sessionKey = this.clientConversationKey(client, conversationId); switch (event.type) { - case 'agent_start': { + case 'started': { // Reset accumulation buffers for the new turn const cs = this.clientSessions.get(sessionKey); if (cs) { @@ -1277,30 +1396,73 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa break; } - case 'agent_end': { - // Gather usage stats from the Pi session - const activeClientSession = this.clientSessions.get(sessionKey); - const agentSession = activeClientSession - ? this.agentService.getSession(conversationId, activeClientSession.scope) - : undefined; - const piSession = agentSession?.piSession; - const stats = piSession?.getSessionStats(); - const contextUsage = piSession?.getContextUsage(); + case 'text_delta': { + // Keep raw stream material in memory only; persist and emit only redacted text. + const cs = this.clientSessions.get(sessionKey); + if (cs) { + cs.assistantText += event.text; + } + this.appendAndFlushRedactedEgress( + client, + conversationId, + 'agent:text', + this.textEgressBuffers, + event.text, + ); + break; + } - const usagePayload = stats - ? { - provider: agentSession?.provider ?? 'unknown', - modelId: agentSession?.modelId ?? 'unknown', - thinkingLevel: piSession?.thinkingLevel ?? 'off', - tokens: stats.tokens, - cost: stats.cost, - context: { - percent: contextUsage?.percent ?? null, - window: contextUsage?.contextWindow ?? 0, - }, - } - : undefined; + case 'thinking_delta': { + this.appendAndFlushRedactedEgress( + client, + conversationId, + 'agent:thinking', + this.thinkingEgressBuffers, + event.text, + ); + break; + } + case 'tool_started': { + // Track pending tool call for later recording + const cs = this.clientSessions.get(sessionKey); + if (cs) { + cs.pendingToolCalls.set(event.toolCallId, { + toolName: event.toolName, + args: undefined, + }); + } + client.emit('agent:tool:start', { + conversationId, + toolCallId: event.toolCallId, + toolName: event.toolName, + }); + break; + } + + case 'tool_finished': { + // Finalise tool call record + const cs = this.clientSessions.get(sessionKey); + if (cs) { + const pending = cs.pendingToolCalls.get(event.toolCallId); + cs.toolCalls.push({ + toolCallId: event.toolCallId, + toolName: event.toolName, + args: pending?.args ?? null, + isError: event.isError, + }); + cs.pendingToolCalls.delete(event.toolCallId); + } + client.emit('agent:tool:end', { + conversationId, + toolCallId: event.toolCallId, + toolName: event.toolName, + isError: event.isError, + }); + break; + } + + case 'settled': { this.flushRedactedEgress( client, conversationId, @@ -1315,21 +1477,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa this.thinkingEgressBuffers, true, ); - client.emit('agent:end', { - conversationId, - usage: usagePayload, - }); - - // M5-007: Accumulate token usage in session metrics - if (stats?.tokens) { - this.agentService.recordTokenUsage(conversationId, { - input: stats.tokens.input ?? 0, - output: stats.tokens.output ?? 0, - cacheRead: stats.tokens.cacheRead ?? 0, - cacheWrite: stats.tokens.cacheWrite ?? 0, - total: stats.tokens.total ?? 0, - }); - } + client.emit('agent:end', { conversationId, usage: event.usage }); // Persist the assistant message with metadata const cs = this.clientSessions.get(sessionKey); @@ -1337,21 +1485,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa if (cs && userId && cs.assistantText.trim().length > 0) { const metadata: Record = { timestamp: new Date().toISOString(), - model: agentSession?.modelId ?? 'unknown', - provider: agentSession?.provider ?? 'unknown', + model: event.usage?.modelId ?? 'unknown', + provider: event.usage?.provider ?? 'unknown', toolCalls: cs.toolCalls, + ...(event.usage?.tokens ? { tokenUsage: event.usage.tokens } : {}), }; - if (stats?.tokens) { - metadata['tokenUsage'] = { - input: stats.tokens.input, - output: stats.tokens.output, - cacheRead: stats.tokens.cacheRead, - cacheWrite: stats.tokens.cacheWrite, - total: stats.tokens.total, - }; - } - this.brain.conversations .addMessage( { @@ -1379,72 +1518,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } break; } - - case 'message_update': { - const assistantEvent = event.assistantMessageEvent; - if (assistantEvent.type === 'text_delta') { - // Keep raw stream material in memory only; persist and emit only redacted text. - const cs = this.clientSessions.get(sessionKey); - if (cs) { - cs.assistantText += assistantEvent.delta; - } - this.appendAndFlushRedactedEgress( - client, - conversationId, - 'agent:text', - this.textEgressBuffers, - assistantEvent.delta, - ); - } else if (assistantEvent.type === 'thinking_delta') { - this.appendAndFlushRedactedEgress( - client, - conversationId, - 'agent:thinking', - this.thinkingEgressBuffers, - assistantEvent.delta, - ); - } - break; - } - - case 'tool_execution_start': { - // Track pending tool call for later recording - const cs = this.clientSessions.get(sessionKey); - if (cs) { - cs.pendingToolCalls.set(event.toolCallId, { - toolName: event.toolName, - args: event.args, - }); - } - client.emit('agent:tool:start', { - conversationId, - toolCallId: event.toolCallId, - toolName: event.toolName, - }); - break; - } - - case 'tool_execution_end': { - // Finalise tool call record - const cs = this.clientSessions.get(sessionKey); - if (cs) { - const pending = cs.pendingToolCalls.get(event.toolCallId); - cs.toolCalls.push({ - toolCallId: event.toolCallId, - toolName: event.toolName, - args: pending?.args ?? null, - isError: event.isError, - }); - cs.pendingToolCalls.delete(event.toolCallId); - } - client.emit('agent:tool:end', { - conversationId, - toolCallId: event.toolCallId, - toolName: event.toolName, - isError: event.isError, - }); - break; - } } } } diff --git a/apps/gateway/src/chat/chat.module.ts b/apps/gateway/src/chat/chat.module.ts index 026659f7..17a0146c 100644 --- a/apps/gateway/src/chat/chat.module.ts +++ b/apps/gateway/src/chat/chat.module.ts @@ -1,12 +1,59 @@ import { forwardRef, Module } from '@nestjs/common'; import { CommandsModule } from '../commands/commands.module.js'; +import { HarnessModule } from '../harness/harness.module.js'; +import { HarnessRegistry } from '../harness/harness.registry.js'; +import { + HARNESS_CONVERSATION_SERVICE, + HARNESS_REGISTRY, + type HarnessConversationServiceBinding, +} from '../harness/harness.tokens.js'; +import type { HarnessConversationService } from '@mosaicstack/types'; import { ChatGateway } from './chat.gateway.js'; import { ChatController } from './chat.controller.js'; +import { ChatRuntimeRouter } from './chat-runtime-router.js'; +import { EmbeddedChatRuntime } from './embedded-chat.runtime.js'; +import { HarnessChatRuntime } from './harness-chat.runtime.js'; +/** + * Task Five wiring. The exclusive {@link ChatRuntimeRouter} is the single chat-execution + * authority: the controller and gateway inject only the router, never `AgentService`, + * `RoutingEngineService`, or a session/`piSession` handle. The router resolves exactly one + * runtime at module init — {@link EmbeddedChatRuntime} in legacy mode, {@link HarnessChatRuntime} + * in `pi-rpc` — over the REAL {@link HarnessModule} registry and conversation-service binding. + * + * The router and the harness runtime are constructed through factories because their + * dependencies are interface/union types with no runtime injection token (the registry and + * conversation-service arrive via the string tokens exported by `HarnessModule`); the embedded + * runtime injects the class-typed `AgentService` and is provided directly. + */ @Module({ - imports: [forwardRef(() => CommandsModule)], + imports: [forwardRef(() => CommandsModule), HarnessModule], controllers: [ChatController], - providers: [ChatGateway], - exports: [ChatGateway], + providers: [ + ChatGateway, + EmbeddedChatRuntime, + { + provide: HarnessChatRuntime, + useFactory: (conversationService: HarnessConversationServiceBinding) => + new HarnessChatRuntime(conversationService as HarnessConversationService), + inject: [HARNESS_CONVERSATION_SERVICE], + }, + { + provide: ChatRuntimeRouter, + useFactory: ( + registry: HarnessRegistry, + conversationService: HarnessConversationServiceBinding, + embedded: EmbeddedChatRuntime, + harness: HarnessChatRuntime, + ) => new ChatRuntimeRouter(registry, conversationService, embedded, harness), + inject: [ + HARNESS_REGISTRY, + HARNESS_CONVERSATION_SERVICE, + EmbeddedChatRuntime, + HarnessChatRuntime, + ], + }, + ], + exports: [ChatGateway, ChatRuntimeRouter], }) export class ChatModule {} diff --git a/apps/gateway/src/chat/embedded-chat.runtime.ts b/apps/gateway/src/chat/embedded-chat.runtime.ts new file mode 100644 index 00000000..fa6260a2 --- /dev/null +++ b/apps/gateway/src/chat/embedded-chat.runtime.ts @@ -0,0 +1,532 @@ +import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; +import { AgentService, type AgentSession } from '../agent/agent.service.js'; +import type { ActorTenantScope } from '../auth/session-scope.js'; +import type { + ChatRuntime, + LegacyBrowserMessagePayload, + LegacyEmbeddedChatPort, + LegacyRuntimeEvent, + LegacyRuntimeResult, + LegacySessionPresentation, + LegacySocketTurnLease, + LegacyUsage, + OwnedConversationContext, + VerifiedDiscordIngressContext, + VerifiedDiscordTurnLease, + LegacyRuntimeStream, +} from './chat-runtime.js'; + +/** Fixed timeout for a synchronous REST turn, matching the historical controller budget. */ +const REST_TURN_TIMEOUT_MS = 120_000; + +/** + * The `legacy` chat runtime and the sole implementation of {@link LegacyEmbeddedChatPort}. + * + * It owns the embedded in-process execution path — the `AgentService` stack that the + * `ChatController` and `ChatGateway` drove directly before Task Five. Once the + * {@link import('./chat-runtime-router.js').ChatRuntimeRouter} fronts it, the browser + * HTTP/WebSocket legacy path and verified-Discord ingress route through THIS runtime, so + * neither the controller nor the gateway retains `AgentService`, `piSession`, session, + * listener, channel, or metric access. Ownership (`userId`/`tenantId`) is re-checked by + * `AgentService` on every operation; a missing, foreign, or no-longer-owned conversation + * collapses to `conversation_unavailable` and never throws out of the port. + */ +@Injectable() +export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort { + readonly kind = 'embedded' as const; + private readonly logger = new Logger(EmbeddedChatRuntime.name); + + constructor(readonly agentService: AgentService) {} + + // ------------------------------------------------------------------------- + // Legacy REST completion (op A) + // ------------------------------------------------------------------------- + + async completeLegacyRestTurn( + context: OwnedConversationContext, + input: Readonly<{ content: string }>, + ): Promise< + LegacyRuntimeResult> + > { + const scope = toScope(context.scope); + const { conversationId } = context; + + const resolved = await this.resolveOrCreate(conversationId, scope, {}); + if (!resolved.ok) return resolved; + + let responseText = ''; + let timer: ReturnType | undefined; + let detach: (() => void) | undefined; + let disposed = false; + // One idempotent teardown owned OUTSIDE the completion promise: it clears the timeout and + // detaches the event listener exactly once, whichever of agent_end, timeout, or a prompt + // rejection fires first. Without this, a prompt() rejection surfaced through the catch below + // would return while leaving the listener attached (free to consume a later turn's events) and + // the 120s timer live (its rejection later going unobserved). + const dispose = (): void => { + if (disposed) return; + disposed = true; + if (timer !== undefined) clearTimeout(timer); + detach?.(); + }; + const done = new Promise((resolve, reject) => { + timer = setTimeout(() => { + dispose(); + reject(new Error('Agent response timed out')); + }, REST_TURN_TIMEOUT_MS); + + detach = this.agentService.onEvent( + conversationId, + (event: AgentSessionEvent) => { + if ( + event.type === 'message_update' && + event.assistantMessageEvent.type === 'text_delta' + ) { + responseText += event.assistantMessageEvent.delta; + } + if (event.type === 'agent_end') { + dispose(); + resolve(); + } + }, + scope, + ); + }); + + // Attach the prompt and the completion promise CONCURRENTLY. Awaiting prompt() first left the + // timeout unobservable until prompt settled (a hung prompt could never time out) and, worse, + // let the 120s timer reject `done` while nothing yet awaited it — a transient unhandledRejection + // window. Promise.all installs handlers on BOTH synchronously, so the timeout bounds the whole + // turn even while prompt is pending, and neither promise can reject unobserved. Success still + // requires both prompt() to resolve AND agent_end to arrive (identical to the prior sequential + // await). The idempotent dispose() clears the timer + detaches on whichever settles first. + const prompting = this.agentService.prompt(conversationId, input.content, scope); + try { + await Promise.all([prompting, done]); + } catch (err) { + dispose(); + const message = err instanceof Error ? err.message : String(err); + if (message.includes('timed out')) { + return { ok: false, code: 'timeout', retryable: true }; + } + this.logger.error(`Legacy REST turn failed for conversation=${conversationId}`, message); + return { ok: false, code: 'operation_failed', retryable: false }; + } + + const presentation = this.presentationFor(conversationId, scope) ?? resolved.presentation; + return { ok: true, value: { text: responseText, presentation } }; + } + + // ------------------------------------------------------------------------- + // Legacy Socket streaming (op B) + // ------------------------------------------------------------------------- + + async prepareLegacySocketTurn( + context: OwnedConversationContext, + input: LegacyBrowserMessagePayload, + stream: LegacyRuntimeStream, + ): Promise> { + const scope = toScope(context.scope); + const { conversationId } = context; + + const resolved = await this.resolveOrCreate(conversationId, scope, { + ...(input.provider ? { provider: input.provider } : {}), + ...(input.modelId ? { modelId: input.modelId } : {}), + ...(input.agentId ? { agentConfigId: input.agentId } : {}), + }); + if (!resolved.ok) return resolved; + + let detach: () => void; + try { + detach = this.subscribe(conversationId, scope, stream); + } catch (err) { + // A partial listener/channel setup rolled itself back inside subscribe(); surface a total + // safe failure instead of throwing out of the port. Retryable — the attach is transient. + this.logger.error( + `Embedded socket subscription failed for conversation=${conversationId}`, + err instanceof Error ? err.message : String(err), + ); + return { ok: false, code: 'runtime_unavailable', retryable: true }; + } + + return { + ok: true, + value: this.buildLease( + conversationId, + scope, + input.content, + input.attachments, + detach, + resolved.presentation, + ), + }; + } + + // ------------------------------------------------------------------------- + // Thinking level (op C) — synchronous, total + // ------------------------------------------------------------------------- + + setLegacyThinking( + context: OwnedConversationContext, + level: string, + ): LegacyRuntimeResult { + const scope = toScope(context.scope); + const session = this.agentService.getSession(context.conversationId, scope); + if (!session) return CONVERSATION_UNAVAILABLE; + + const availableThinkingLevels = session.piSession.getAvailableThinkingLevels(); + if (!(availableThinkingLevels as readonly string[]).includes(level)) { + return { + ok: false, + code: 'thinking_level_invalid', + retryable: false, + availableThinkingLevels, + }; + } + + session.piSession.setThinkingLevel(level as never); + return { ok: true, value: this.presentationForSession(session) }; + } + + // ------------------------------------------------------------------------- + // Abort (op D) + // ------------------------------------------------------------------------- + + async abortLegacyTurn(context: OwnedConversationContext): Promise> { + const scope = toScope(context.scope); + const session = this.agentService.getSession(context.conversationId, scope); + if (!session) return CONVERSATION_UNAVAILABLE; + + try { + await session.piSession.abort(); + } catch (err) { + this.logger.error( + `Legacy abort failed for conversation=${context.conversationId}`, + err instanceof Error ? err.message : String(err), + ); + return { ok: false, code: 'operation_failed', retryable: false }; + } + return { ok: true, value: undefined }; + } + + // ------------------------------------------------------------------------- + // Model override (synchronous, total) + // ------------------------------------------------------------------------- + + applyLegacyModelOverride( + context: OwnedConversationContext, + modelId: string, + ): LegacyRuntimeResult { + const scope = toScope(context.scope); + const session = this.agentService.getSession(context.conversationId, scope); + if (!session) return CONVERSATION_UNAVAILABLE; + + this.agentService.updateSessionModel(context.conversationId, modelId, scope); + const refreshed = this.agentService.getSession(context.conversationId, scope) ?? session; + return { ok: true, value: this.presentationForSession(refreshed) }; + } + + // ------------------------------------------------------------------------- + // Presentation read (synchronous, total) + // ------------------------------------------------------------------------- + + readLegacySessionPresentation( + context: OwnedConversationContext, + ): LegacyRuntimeResult { + const scope = toScope(context.scope); + const session = this.agentService.getSession(context.conversationId, scope); + if (!session) return CONVERSATION_UNAVAILABLE; + return { ok: true, value: this.presentationForSession(session) }; + } + + // ------------------------------------------------------------------------- + // Verified Discord ingress (embedded-only in both modes) + // ------------------------------------------------------------------------- + + async dispatchVerifiedDiscordIngress( + context: VerifiedDiscordIngressContext, + stream: LegacyRuntimeStream, + ): Promise> { + const scope = toScope(context.scope); + const { conversationId } = context; + + const resolved = await this.resolveOrCreate( + conversationId, + scope, + { agentConfigId: context.configuredAgent.agentConfigId }, + { + agentConfigId: context.configuredAgent.agentConfigId, + instanceId: context.configuredAgent.instanceId, + }, + ); + if (!resolved.ok) return resolved; + + let detach: () => void; + try { + detach = this.subscribe(conversationId, scope, stream); + } catch (err) { + // A partial listener/channel setup rolled itself back inside subscribe(); surface a total + // safe failure instead of throwing out of the port. Retryable — the attach is transient. + this.logger.error( + `Embedded Discord subscription failed for conversation=${conversationId}`, + err instanceof Error ? err.message : String(err), + ); + return { ok: false, code: 'runtime_unavailable', retryable: true }; + } + + return { + ok: true, + value: this.buildLease( + conversationId, + scope, + context.content, + context.attachments, + detach, + resolved.presentation, + ), + }; + } + + // ------------------------------------------------------------------------- + // Shared helpers + // ------------------------------------------------------------------------- + + /** + * Resolves the owned session, creating it on first use. Ownership/scope rejections + * (`Forbidden`/`NotFound`) collapse to `conversation_unavailable`; any other creation + * failure surfaces as the retryable `runtime_unavailable`. On success returns the + * session presentation so callers avoid a redundant `getSession`. + */ + private async resolveOrCreate( + conversationId: string, + scope: ActorTenantScope, + extraOptions: Readonly<{ provider?: string; modelId?: string; agentConfigId?: string }>, + expectedAgent?: Readonly<{ agentConfigId: string; instanceId: string }>, + ): Promise< + | { readonly ok: true; readonly presentation: LegacySessionPresentation } + | Exclude, { ok: true }> + > { + // A verified-Discord turn may only run under a session whose configured identity matches the + // reconciled agent record EXACTLY (config id + resolved name). This holds for BOTH a reused + // pre-existing session AND a freshly created one: a session carrying a different configured + // agent — however it arose — is rejected rather than executed under the verified label, so we + // never silently run a different prompt/model/tool policy. A plain (non-verified) turn passes + // no expectedAgent and skips the check. + const identityMatches = (candidate: AgentSession): boolean => + expectedAgent === undefined || + (candidate.agentConfigId === expectedAgent.agentConfigId && + candidate.agentName === expectedAgent.instanceId); + + let session = this.agentService.getSession(conversationId, scope); + if (session && !identityMatches(session)) { + // Reused same-scope session minted under a different configured identity — reject with zero + // effects rather than dispatch a verified turn onto a foreign agent's session. + return CONVERSATION_UNAVAILABLE; + } + if (!session) { + try { + session = await this.agentService.createSession(conversationId, { + userId: scope.userId, + tenantId: scope.tenantId, + ...extraOptions, + }); + } catch (err) { + if (err instanceof ForbiddenException || err instanceof NotFoundException) { + return CONVERSATION_UNAVAILABLE; + } + this.logger.error( + `Embedded session creation failed for conversation=${conversationId}`, + err instanceof Error ? err.stack : String(err), + ); + return { ok: false, code: 'runtime_unavailable', retryable: true }; + } + // The just-created session must ALSO carry the reconciled identity before any effect. A + // createSession that returns a session under a different configured agent (misconfiguration + // or a substituted factory) is rejected here, before subscribe/persist/ack/prompt. + if (!identityMatches(session)) { + return CONVERSATION_UNAVAILABLE; + } + } + return { ok: true, presentation: this.presentationForSession(session) }; + } + + /** Installs a normalizing event listener that forwards to the server-owned stream. */ + private subscribe( + conversationId: string, + scope: ActorTenantScope, + stream: LegacyRuntimeStream, + ): () => void { + const unsubscribe = this.agentService.onEvent( + conversationId, + (event: AgentSessionEvent) => { + const normalized = this.normalizeEvent(conversationId, scope, event); + if (normalized) stream.onEvent(normalized); + }, + scope, + ); + try { + this.agentService.addChannel(conversationId, stream.channelId, scope); + } catch (err) { + // Partial setup: the listener was acquired but the channel attach failed. Roll back + // exactly what was acquired (the listener) before the failure escapes, so no leaked + // subscription survives; the caller converts the rethrow into a total safe failure. + try { + unsubscribe(); + } catch { + /* idempotent teardown */ + } + throw err; + } + return () => { + try { + unsubscribe(); + } catch { + /* idempotent teardown */ + } + try { + this.agentService.removeChannel(conversationId, stream.channelId, scope); + } catch { + /* idempotent teardown */ + } + }; + } + + /** Builds an atomically one-shot, scope-rechecking dispatch lease. */ + private buildLease( + conversationId: string, + scope: ActorTenantScope, + content: string, + attachments: VerifiedDiscordIngressContext['attachments'], + detach: () => void, + presentation: LegacySessionPresentation, + ): LegacySocketTurnLease & VerifiedDiscordTurnLease { + let dispatched = false; + let disposed = false; + return { + presentation, + dispatch: async (): Promise> => { + if (dispatched) { + return { ok: false, code: 'turn_already_dispatched', retryable: false }; + } + dispatched = true; + try { + await this.agentService.prompt(conversationId, content, scope, attachments); + } catch (err) { + this.logger.error( + `Legacy dispatch failed for conversation=${conversationId}`, + err instanceof Error ? err.message : String(err), + ); + return { ok: false, code: 'operation_failed', retryable: false }; + } + return { ok: true, value: undefined }; + }, + dispose: async (): Promise => { + if (disposed) return; + disposed = true; + detach(); + }, + }; + } + + /** Normalizes a raw agent event into the redaction-agnostic transport event, or drops it. */ + private normalizeEvent( + conversationId: string, + scope: ActorTenantScope, + event: AgentSessionEvent, + ): LegacyRuntimeEvent | undefined { + switch (event.type) { + case 'agent_start': + return { type: 'started' }; + case 'agent_end': + return { type: 'settled', ...this.usageFor(conversationId, scope) }; + case 'message_update': { + const assistant = event.assistantMessageEvent; + if (assistant.type === 'text_delta') return { type: 'text_delta', text: assistant.delta }; + if (assistant.type === 'thinking_delta') { + return { type: 'thinking_delta', text: assistant.delta }; + } + return undefined; + } + case 'tool_execution_start': + return { type: 'tool_started', toolCallId: event.toolCallId, toolName: event.toolName }; + case 'tool_execution_end': + return { + type: 'tool_finished', + toolCallId: event.toolCallId, + toolName: event.toolName, + isError: event.isError, + }; + default: + return undefined; + } + } + + /** + * Gathers terminal usage from the Pi session and records it into session metrics. + * Embedded owns AgentService metrics; the gateway never touches `piSession` stats. + */ + private usageFor(conversationId: string, scope: ActorTenantScope): { usage?: LegacyUsage } { + const session = this.agentService.getSession(conversationId, scope); + const piSession = session?.piSession; + const stats = piSession?.getSessionStats(); + if (!session || !stats) return {}; + const contextUsage = piSession?.getContextUsage(); + + const tokens = { + input: stats.tokens?.input ?? 0, + output: stats.tokens?.output ?? 0, + cacheRead: stats.tokens?.cacheRead ?? 0, + cacheWrite: stats.tokens?.cacheWrite ?? 0, + total: stats.tokens?.total ?? 0, + }; + + this.agentService.recordTokenUsage(conversationId, { ...tokens }); + + return { + usage: { + provider: session.provider, + modelId: session.modelId, + thinkingLevel: piSession?.thinkingLevel ?? 'off', + tokens, + cost: stats.cost ?? 0, + context: { + percent: contextUsage?.percent ?? null, + window: contextUsage?.contextWindow ?? 0, + }, + }, + }; + } + + /** Presentation from a live session id, or undefined when no owned session exists. */ + private presentationFor( + conversationId: string, + scope: ActorTenantScope, + ): LegacySessionPresentation | undefined { + const session = this.agentService.getSession(conversationId, scope); + return session ? this.presentationForSession(session) : undefined; + } + + /** User-facing projection carrying no session handle, credential, or raw stats. */ + private presentationForSession(session: AgentSession): LegacySessionPresentation { + return { + provider: session.provider, + modelId: session.modelId, + thinkingLevel: session.piSession.thinkingLevel, + availableThinkingLevels: session.piSession.getAvailableThinkingLevels(), + ...(session.agentName ? { agentName: session.agentName } : {}), + }; + } +} + +/** The shared terminal `conversation_unavailable` failure (missing/foreign/lost ownership). */ +const CONVERSATION_UNAVAILABLE = { + ok: false as const, + code: 'conversation_unavailable' as const, + retryable: false as const, +}; + +/** Narrows a branded context scope to the `AgentService` actor/tenant scope (identical shape). */ +function toScope(scope: Readonly<{ userId: string; tenantId: string }>): ActorTenantScope { + return { userId: scope.userId, tenantId: scope.tenantId }; +} diff --git a/apps/gateway/src/chat/harness-chat.runtime.spec.ts b/apps/gateway/src/chat/harness-chat.runtime.spec.ts new file mode 100644 index 00000000..badc8c8d --- /dev/null +++ b/apps/gateway/src/chat/harness-chat.runtime.spec.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; +import type { + AttachConversation, + ConversationSnapshot, + DetachConversation, + HarnessActorContext, + HarnessConversationService, + HarnessEventEnvelope, + HarnessSelection, + SendHarnessTurn, + TurnReceipt, +} from '@mosaicstack/types'; +import { HarnessChatRuntime } from './harness-chat.runtime.js'; + +/** + * Task Five, Step One (harness runtime). Proves the `pi-rpc` runtime executes + * exclusively through the {@link HarnessConversationService} RPC boundary and + * forwards the caller's exact selection tuple and idempotency key without + * substitution. Red-first: the runtime is an unimplemented stub, so every + * delegation assertion fails until Step Three. + */ + +const context: HarnessActorContext = { + actorId: 'actor-1', + tenantId: 'tenant-1', + seatId: 'seat-1', + correlationId: 'corr-1', +}; + +const selection: HarnessSelection = { + harnessId: 'pi', + providerId: 'anthropic', + modelId: 'claude-opus-4-8', +}; + +const conversationId = '11111111-1111-4111-8111-111111111111'; +const idempotencyKey = '22222222-2222-4222-8222-222222222222'; + +const sendInput: SendHarnessTurn & { idempotencyKey: string } = { + context, + conversationId, + selection, + turnId: 'turn-abc', + correlationId: 'corr-1', + content: 'hello', + idempotencyKey, +}; + +const attachInput: AttachConversation & { afterSequence?: number } = { + context, + conversationId, + clientId: 'client-1', + selection, + afterSequence: 0, +}; + +const detachInput: DetachConversation = { + context, + conversationId, + clientId: 'client-1', +}; + +interface RecordedCalls { + attach: (AttachConversation & { afterSequence?: number })[]; + detach: DetachConversation[]; + send: (SendHarnessTurn & { idempotencyKey: string })[]; + subscribeFrom: { conversationId: string; afterSequence: number }[]; +} + +const snapshot: ConversationSnapshot = { + session: { + conversationId, + nativeSessionId: 'native-1', + seatId: 'seat-1', + selection, + state: 'idle', + attachedClientIds: ['client-1'], + }, + lastSequence: 0, + replay: [], +}; + +function build(): { runtime: HarnessChatRuntime; calls: RecordedCalls } { + const calls: RecordedCalls = { attach: [], detach: [], send: [], subscribeFrom: [] }; + const service: HarnessConversationService = { + attach: (input) => { + calls.attach.push(input); + return Promise.resolve(snapshot); + }, + detach: (input) => { + calls.detach.push(input); + return Promise.resolve(); + }, + send: (input) => { + calls.send.push(input); + // The service echoes only the requested tuple; there is no representable substitute. + const receipt: TurnReceipt = { + conversationId: input.conversationId, + turnId: 'turn-server', + correlationId: input.correlationId, + state: 'accepted', + selection: input.selection, + }; + return Promise.resolve(receipt); + }, + subscribeFrom: (id, afterSequence) => { + calls.subscribeFrom.push({ conversationId: id, afterSequence }); + + return (async function* (): AsyncIterable { + return; + })(); + }, + }; + return { runtime: new HarnessChatRuntime(service), calls }; +} + +describe('HarnessChatRuntime', () => { + it('is the harness runtime kind and needs only a HarnessConversationService', () => { + const { runtime } = build(); + expect(runtime.kind).toBe('harness'); + }); + + it('delegates send to the conversation service with the exact tuple and idempotency key', async () => { + const { runtime, calls } = build(); + + const receipt = await runtime.send(sendInput); + + expect(calls.send).toHaveLength(1); + const firstSend = calls.send[0]!; + expect(firstSend).toEqual(sendInput); + expect(firstSend.idempotencyKey).toBe(idempotencyKey); + expect(firstSend.selection).toEqual(selection); + // The runtime must not substitute an effective tuple onto the receipt. + expect(receipt.selection).toEqual(selection); + }); + + it('delegates attach to the conversation service and returns its snapshot', async () => { + const { runtime, calls } = build(); + + const result = await runtime.attach(attachInput); + + expect(calls.attach).toHaveLength(1); + expect(calls.attach[0]).toEqual(attachInput); + expect(result).toBe(snapshot); + }); + + it('delegates detach to the conversation service', async () => { + const { runtime, calls } = build(); + + await runtime.detach(detachInput); + + expect(calls.detach).toHaveLength(1); + expect(calls.detach[0]).toEqual(detachInput); + }); + + it('delegates subscribeFrom to the conversation service journal replay', async () => { + const { runtime, calls } = build(); + + const iterable = runtime.subscribeFrom(conversationId, 7); + // Drain to prove it is the service-backed async iterable, not a fabricated one. + const drained: unknown[] = []; + for await (const event of iterable) { + drained.push(event); + } + expect(drained).toHaveLength(0); + + expect(calls.subscribeFrom).toHaveLength(1); + expect(calls.subscribeFrom[0]).toEqual({ conversationId, afterSequence: 7 }); + }); +}); diff --git a/apps/gateway/src/chat/harness-chat.runtime.ts b/apps/gateway/src/chat/harness-chat.runtime.ts new file mode 100644 index 00000000..566d9820 --- /dev/null +++ b/apps/gateway/src/chat/harness-chat.runtime.ts @@ -0,0 +1,47 @@ +import type { + AttachConversation, + ConversationSnapshot, + DetachConversation, + HarnessConversationService, + HarnessEventEnvelope, + SendHarnessTurn, + TurnReceipt, +} from '@mosaicstack/types'; +import type { ChatRuntime } from './chat-runtime.js'; + +/** + * The `pi-rpc` chat runtime. It executes browser chat exclusively through the + * harness-neutral {@link HarnessConversationService} RPC boundary — it never + * touches the embedded `AgentService`/`ProviderService`/`RoutingEngineService` + * stack, and it forwards the caller's exact selection tuple and idempotency key + * without substitution. + * + * It owns no state and adds no policy: every method forwards the caller's exact + * argument to the injected {@link HarnessConversationService} and returns its + * result unchanged, so the requested selection tuple and idempotency key can + * never be substituted on the way through. + */ +export class HarnessChatRuntime implements ChatRuntime { + readonly kind = 'harness' as const; + + constructor(private readonly conversations: HarnessConversationService) {} + + attach(input: AttachConversation & { afterSequence?: number }): Promise { + return this.conversations.attach(input); + } + + detach(input: DetachConversation): Promise { + return this.conversations.detach(input); + } + + send(input: SendHarnessTurn & { idempotencyKey: string }): Promise { + return this.conversations.send(input); + } + + subscribeFrom( + conversationId: string, + afterSequence: number, + ): AsyncIterable { + return this.conversations.subscribeFrom(conversationId, afterSequence); + } +} diff --git a/apps/gateway/src/commands/command-executor-p8012.spec.ts b/apps/gateway/src/commands/command-executor-p8012.spec.ts index b22ed05a..28fbedad 100644 --- a/apps/gateway/src/commands/command-executor-p8012.spec.ts +++ b/apps/gateway/src/commands/command-executor-p8012.spec.ts @@ -1,3 +1,4 @@ +import { Logger } from '@nestjs/common'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { CommandExecutorService } from './command-executor.service.js'; import type { SlashCommandPayload } from '@mosaicstack/types'; @@ -12,6 +13,7 @@ const mockRegistry = { { name: 'agent', aliases: ['a'], 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: 'mcp', aliases: [], scope: 'agent', execution: 'socket', available: true }, ], skills: [], })), @@ -72,7 +74,20 @@ const mockChatGateway = { broadcastSessionInfo: vi.fn(), }; -function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService { +const mockMcpClient = { + reconnectServer: vi.fn().mockResolvedValue(undefined), + getServerStatuses: vi.fn(() => []), + getToolDefinitions: vi.fn(() => []), +}; + +function buildService( + redis: typeof mockRedis | null = mockRedis, + mcpClient: { + reconnectServer: ReturnType; + getServerStatuses: ReturnType; + getToolDefinitions: ReturnType; + } = mockMcpClient, +): CommandExecutorService { return new CommandExecutorService( mockRegistry as never, mockAgentService as never, @@ -82,7 +97,7 @@ function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecut mockBrain as never, null, mockChatGateway as never, - null, + mcpClient as never, ); } @@ -258,4 +273,124 @@ describe('CommandExecutorService — P8-012 commands', () => { expect(result.command).toBe('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 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(); + }); }); diff --git a/apps/gateway/src/commands/command-executor-tess-security.spec.ts b/apps/gateway/src/commands/command-executor-tess-security.spec.ts index 8356ead0..a07b7d34 100644 --- a/apps/gateway/src/commands/command-executor-tess-security.spec.ts +++ b/apps/gateway/src/commands/command-executor-tess-security.spec.ts @@ -36,6 +36,12 @@ const authorization = { ), }; +const mockMcpClient = { + getServerStatuses: vi.fn(() => []), + getToolDefinitions: vi.fn(() => []), + reconnectServer: vi.fn().mockResolvedValue(undefined), +}; + function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService { return new CommandExecutorService( registry as never, @@ -46,7 +52,7 @@ function buildExecutor(authorizationService: unknown = authorization): CommandEx { agents: {} } as never, null, null, - null, + mockMcpClient as never, authorizationService as never, ); } diff --git a/apps/gateway/src/commands/command-executor.service.ts b/apps/gateway/src/commands/command-executor.service.ts index ca2d3cfd..78a4efcb 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -34,9 +34,7 @@ export class CommandExecutorService { @Optional() @Inject(forwardRef(() => ChatGateway)) private readonly chatGateway: ChatGateway | null, - @Optional() - @Inject(McpClientService) - private readonly mcpClient: McpClientService | null, + @Inject(McpClientService) private readonly mcpClient: McpClientService, @Optional() @Inject(CommandAuthorizationService) private readonly authorization: CommandAuthorizationService | null = null, @@ -159,8 +157,13 @@ export class CommandExecutorService { }; } } catch (err) { - this.logger.error(`Command /${command} failed: ${err}`); - return { command, conversationId, success: false, message: String(err) }; + this.logger.error(`Command /${command} failed`, err); + return { + command, + conversationId, + success: false, + message: 'Command failed due to an internal error.', + }; } } @@ -336,11 +339,11 @@ export class CommandExecutorService { data: { agentId: newAgent.id, agentName: newAgent.name }, }; } catch (err) { - this.logger.error(`Failed to create agent: ${err}`); + this.logger.error(`Failed to create agent "${namePart}" for user ${userId}`, err); return { command: 'agent', success: false, - message: `Failed to create agent: ${String(err)}`, + message: 'Failed to create agent due to an internal error.', conversationId, }; } @@ -391,11 +394,11 @@ export class CommandExecutorService { data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model }, }; } catch (err) { - this.logger.error(`Failed to switch agent "${agentName}": ${err}`); + this.logger.error(`Failed to switch agent "${agentName}"`, err); return { command: 'agent', success: false, - message: `Failed to switch agent: ${String(err)}`, + message: 'Failed to switch agent due to an internal error.', conversationId, }; } @@ -543,15 +546,6 @@ export class CommandExecutorService { args: string | null, conversationId: string, ): Promise { - if (!this.mcpClient) { - return { - command: 'mcp', - conversationId, - success: false, - message: 'MCP client service is not available.', - }; - } - const action = args?.trim().split(/\s+/)[0] ?? 'status'; switch (action) { @@ -608,11 +602,12 @@ export class CommandExecutorService { message: `MCP server "${serverName}" reconnected successfully.`, }; } catch (err) { + this.logger.error(`Failed to reconnect MCP server "${serverName}"`, err); return { command: 'mcp', conversationId, success: false, - message: `Failed to reconnect MCP server "${serverName}": ${err instanceof Error ? err.message : String(err)}`, + message: `Failed to reconnect MCP server "${serverName}" due to an internal error.`, }; } } diff --git a/apps/gateway/src/commands/commands.integration.spec.ts b/apps/gateway/src/commands/commands.integration.spec.ts index 2001bba5..e1806bcd 100644 --- a/apps/gateway/src/commands/commands.integration.spec.ts +++ b/apps/gateway/src/commands/commands.integration.spec.ts @@ -11,6 +11,8 @@ * - Unknown command returns descriptive error */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CommandsModule } from './commands.module.js'; +import { McpClientModule } from '../mcp-client/mcp-client.module.js'; import { CommandRegistryService } from './command-registry.service.js'; import { CommandExecutorService } from './command-executor.service.js'; import type { SlashCommandPayload } from '@mosaicstack/types'; @@ -47,6 +49,12 @@ const mockBrain = { }, }; +const mockMcpClient = { + getServerStatuses: vi.fn(() => []), + getToolDefinitions: vi.fn(() => []), + reconnectServer: vi.fn().mockResolvedValue(undefined), +}; + // ─── Helpers ───────────────────────────────────────────────────────────────── function buildRegistry(): CommandRegistryService { @@ -65,7 +73,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService mockBrain as never, null, // reloadService (optional) null, // chatGateway (optional) - null, // mcpClient (optional) + mockMcpClient as never, ); } @@ -153,6 +161,15 @@ describe('CommandRegistryService — integration', () => { } }); +// ─── Module Wiring Tests ────────────────────────────────────────────────────── + +describe('CommandsModule — Nest wiring', () => { + it('CommandsModule imports McpClientModule in its Nest metadata', () => { + const imports = Reflect.getMetadata('imports', CommandsModule) ?? []; + expect(imports).toContain(McpClientModule); + }); +}); + // ─── Executor Tests ─────────────────────────────────────────────────────────── describe('CommandExecutorService — integration', () => { @@ -259,4 +276,14 @@ describe('CommandExecutorService — integration', () => { expect(result.command).toBe(cmd); }); } + + // /mcp status reaches the required McpClientService and never reports it unavailable + it('/mcp status calls the wired McpClientService and reports the no-servers message', async () => { + const payload: SlashCommandPayload = { command: 'mcp', conversationId }; + const result = await executor.execute(payload, userScope); + expect(mockMcpClient.getServerStatuses).toHaveBeenCalledOnce(); + expect(result.success).toBe(true); + expect(result.message).toContain('No MCP servers configured.'); + expect(result.message).not.toBe('MCP client service is not available.'); + }); }); diff --git a/apps/gateway/src/commands/commands.module.ts b/apps/gateway/src/commands/commands.module.ts index de37216d..29c30b83 100644 --- a/apps/gateway/src/commands/commands.module.ts +++ b/apps/gateway/src/commands/commands.module.ts @@ -4,6 +4,7 @@ import type { MosaicConfig } from '@mosaicstack/config'; import { MOSAIC_CONFIG } from '../config/config.module.js'; import { ChatModule } from '../chat/chat.module.js'; import { GCModule } from '../gc/gc.module.js'; +import { McpClientModule } from '../mcp-client/mcp-client.module.js'; import { ReloadModule } from '../reload/reload.module.js'; import { CommandAuthorizationService } from './command-authorization.service.js'; import { CommandExecutorService } from './command-executor.service.js'; @@ -14,7 +15,12 @@ import { COMMANDS_REDIS } from './commands.tokens.js'; const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE'; @Module({ - imports: [GCModule, forwardRef(() => ReloadModule), forwardRef(() => ChatModule)], + imports: [ + GCModule, + McpClientModule, + forwardRef(() => ReloadModule), + forwardRef(() => ChatModule), + ], providers: [ { provide: COMMANDS_QUEUE_HANDLE, diff --git a/apps/gateway/src/config/config.module.ts b/apps/gateway/src/config/config.module.ts index 5b65137a..d18cdd59 100644 --- a/apps/gateway/src/config/config.module.ts +++ b/apps/gateway/src/config/config.module.ts @@ -1,5 +1,6 @@ import { Global, Module } from '@nestjs/common'; import { loadConfig, type MosaicConfig } from '@mosaicstack/config'; +import { resolveGatewayConfigPath } from '../env.js'; export const MOSAIC_CONFIG = 'MOSAIC_CONFIG'; @@ -8,7 +9,7 @@ export const MOSAIC_CONFIG = 'MOSAIC_CONFIG'; providers: [ { provide: MOSAIC_CONFIG, - useFactory: (): MosaicConfig => loadConfig(), + useFactory: (): MosaicConfig => loadConfig(resolveGatewayConfigPath()), }, ], exports: [MOSAIC_CONFIG], diff --git a/apps/gateway/src/conversations/conversations-harness-fence.spec.ts b/apps/gateway/src/conversations/conversations-harness-fence.spec.ts new file mode 100644 index 00000000..d95355f7 --- /dev/null +++ b/apps/gateway/src/conversations/conversations-harness-fence.spec.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ChatRuntimeMode } from '../chat/chat-runtime.js'; +import { ConversationsController } from './conversations.controller.js'; + +/** + * Task 5 harness fence for the conversations REST write path. + * + * Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the + * legacy direct-repository write via `POST /api/conversations/:id/messages` must be refused with a + * fixed typed `runtime_unsupported` BEFORE the repository is touched — never a duplicate write. + * Under `legacy` the endpoint keeps its current behaviour and writes through `brain.conversations`. + * + * Item 3 (single runtime-mode source of truth): the mode is the router's ONE init-time resolution, + * injected into the controller and read as `router.runtimeMode`. It is NOT re-derived from + * `process.env` at request time. The two "env is flipped after construction" tests below are the + * load-bearing guard: they pass only because the controller reads the fixed injected mode, and turn + * RED the instant the fence is reverted to `resolveChatRuntimeMode(process.env)`. + */ +const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222'; +const USER = { id: 'user-1' }; + +function sendMessageDto() { + return { + role: 'user' as const, + content: 'hello from the legacy REST write path', + metadata: undefined, + }; +} + +function brainWithMessageSpy() { + const addMessage = vi.fn().mockResolvedValue({ + id: 'message-1', + conversationId: CONVERSATION_ID, + role: 'user', + content: 'hello from the legacy REST write path', + }); + return { + brain: { conversations: { addMessage } } as never, + addMessage, + }; +} + +/** The controller only needs the router's immutable `runtimeMode`; supply exactly that. */ +function routerFixedTo(mode: ChatRuntimeMode) { + return { runtimeMode: mode }; +} + +let priorMode: string | undefined; + +describe('conversations REST write path — Task 5 harness fence', () => { + beforeEach(() => { + priorMode = process.env['CHAT_HARNESS_RUNTIME']; + }); + + afterEach(() => { + if (priorMode === undefined) delete process.env['CHAT_HARNESS_RUNTIME']; + else process.env['CHAT_HARNESS_RUNTIME'] = priorMode; + }); + + it('refuses the legacy repository write when the router resolved pi-rpc, before any write', async () => { + const { brain, addMessage } = brainWithMessageSpy(); + const controller = new ConversationsController(brain, routerFixedTo('pi-rpc')); + + await expect( + controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER), + ).rejects.toMatchObject({ code: 'runtime_unsupported' }); + + // Load-bearing: the durable/harness path owns pi-rpc persistence — the legacy repo must not be + // written, so no duplicate message can be produced. + expect(addMessage).not.toHaveBeenCalled(); + }); + + it('writes through the repository when the router resolved legacy (GREEN control)', async () => { + const { brain, addMessage } = brainWithMessageSpy(); + const controller = new ConversationsController(brain, routerFixedTo('legacy')); + + const result = await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER); + + expect(addMessage).toHaveBeenCalledWith( + { + conversationId: CONVERSATION_ID, + role: 'user', + content: 'hello from the legacy REST write path', + metadata: undefined, + }, + USER.id, + ); + expect(result).toMatchObject({ id: 'message-1', conversationId: CONVERSATION_ID }); + }); + + it('keeps refusing under a pi-rpc router even when CHAT_HARNESS_RUNTIME is flipped to legacy after startup', async () => { + // The runtime mode is fixed at module init. A later env mutation must not reopen the fence: + // a request-time `resolveChatRuntimeMode(process.env)` read would see `legacy` and wrongly write. + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const { brain, addMessage } = brainWithMessageSpy(); + const controller = new ConversationsController(brain, routerFixedTo('pi-rpc')); + + await expect( + controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER), + ).rejects.toMatchObject({ code: 'runtime_unsupported' }); + + expect(addMessage).not.toHaveBeenCalled(); + }); + + it('keeps writing under a legacy router even when CHAT_HARNESS_RUNTIME is flipped to pi-rpc after startup', async () => { + // Symmetric guard: a legacy-resolved router must keep writing regardless of the live env, so a + // request-time env read of `pi-rpc` cannot spuriously refuse a legitimate legacy write. + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + const { brain, addMessage } = brainWithMessageSpy(); + const controller = new ConversationsController(brain, routerFixedTo('legacy')); + + await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER); + + expect(addMessage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/gateway/src/conversations/conversations.controller.ts b/apps/gateway/src/conversations/conversations.controller.ts index 69dc8697..b6eaf480 100644 --- a/apps/gateway/src/conversations/conversations.controller.ts +++ b/apps/gateway/src/conversations/conversations.controller.ts @@ -6,6 +6,7 @@ import { ForbiddenException, Get, HttpCode, + HttpException, HttpStatus, Inject, NotFoundException, @@ -19,6 +20,7 @@ import type { Brain } from '@mosaicstack/brain'; import { BRAIN } from '../brain/brain.tokens.js'; import { AuthGuard } from '../auth/auth.guard.js'; import { CurrentUser } from '../auth/current-user.decorator.js'; +import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js'; import { CreateConversationDto, UpdateConversationDto, @@ -26,10 +28,41 @@ import { SearchMessagesDto, } from './conversations.dto.js'; +/** + * Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the + * legacy direct-repository write must fail closed with a fixed typed `runtime_unsupported` before + * the repository is touched — never a duplicate write. The `code` field is exposed at the top level + * so callers can discriminate the refusal while the 503 status carries the browser-safe surface. + */ +class HarnessRuntimeWriteUnsupportedException extends HttpException { + readonly code = 'runtime_unsupported' as const; + + constructor() { + super( + { + code: 'runtime_unsupported', + message: + 'Conversation message writes are handled by the harness runtime on this deployment.', + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } +} + @Controller('api/conversations') @UseGuards(AuthGuard) export class ConversationsController { - constructor(@Inject(BRAIN) private readonly brain: Brain) {} + /** + * `router` supplies the ONE immutable runtime mode resolved at module init (Task 5, item 3). + * The pre-write fence reads `router.runtimeMode`, never `resolveChatRuntimeMode(process.env)` at + * request time — a single source of truth, so the controller cannot disagree with the router + * about the live runtime if the environment is mutated after startup. Narrowed to `runtimeMode` + * so this class depends on nothing else the router exposes. + */ + constructor( + @Inject(BRAIN) private readonly brain: Brain, + @Inject(ChatRuntimeRouter) private readonly router: Pick, + ) {} @Get() async list(@CurrentUser() user: { id: string }) { @@ -94,6 +127,13 @@ export class ConversationsController { @Body() dto: SendMessageDto, @CurrentUser() user: { id: string }, ) { + // Fail the legacy repository write closed under pi-rpc BEFORE touching the repository — the + // harness path owns persistence there, so a direct write would duplicate the message. The mode + // comes from the router's init-time resolution, not a request-time env read. + if (this.router.runtimeMode === 'pi-rpc') { + throw new HarnessRuntimeWriteUnsupportedException(); + } + const message = await this.brain.conversations.addMessage( { conversationId: id, diff --git a/apps/gateway/src/conversations/conversations.module.ts b/apps/gateway/src/conversations/conversations.module.ts index 19f3912f..ac56d566 100644 --- a/apps/gateway/src/conversations/conversations.module.ts +++ b/apps/gateway/src/conversations/conversations.module.ts @@ -1,7 +1,14 @@ import { Module } from '@nestjs/common'; +import { ChatModule } from '../chat/chat.module.js'; import { ConversationsController } from './conversations.controller.js'; +/** + * Imports {@link ChatModule} solely to inject its exported {@link ChatRuntimeRouter} into + * {@link ConversationsController}, so the REST write fence reads the same init-time runtime mode the + * router resolved — one source of truth, no duplicate provider, no global token, no AppModule edit. + */ @Module({ + imports: [ChatModule], controllers: [ConversationsController], }) export class ConversationsModule {} diff --git a/apps/gateway/src/coord/interaction-coordination.di.test.ts b/apps/gateway/src/coord/interaction-coordination.di.test.ts new file mode 100644 index 00000000..75b63e20 --- /dev/null +++ b/apps/gateway/src/coord/interaction-coordination.di.test.ts @@ -0,0 +1,19 @@ +import 'reflect-metadata'; +import { Test } from '@nestjs/testing'; +import { describe, expect, it } from 'vitest'; +import { CoordModule } from './coord.module.js'; +import { InteractionCoordinationService } from './interaction-coordination.service.js'; +import { AuthGuard } from '../auth/auth.guard.js'; + +describe('CoordModule DI (compiled-metadata boot)', () => { + it('resolves InteractionCoordinationService through Nest DI', async () => { + const moduleRef = await Test.createTestingModule({ imports: [CoordModule] }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: (): boolean => true }) + .compile(); + expect(moduleRef.get(InteractionCoordinationService)).toBeInstanceOf( + InteractionCoordinationService, + ); + await moduleRef.close(); + }); +}); diff --git a/apps/gateway/src/coord/interaction-coordination.service.ts b/apps/gateway/src/coord/interaction-coordination.service.ts index cf16e64a..a1035af9 100644 --- a/apps/gateway/src/coord/interaction-coordination.service.ts +++ b/apps/gateway/src/coord/interaction-coordination.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Optional } from '@nestjs/common'; import { InteractionCoordinationClient, type CoordinationObservation, @@ -13,6 +13,7 @@ import type { CreateHandoffDto } from './interaction-coordination.dto.js'; export const COORDINATION_PORT = Symbol('COORDINATION_PORT'); export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG'); +export const HANDOFF_ID_FACTORY = Symbol('HANDOFF_ID_FACTORY'); const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000; const MAX_TRACKED_HANDOFFS = 1_000; @@ -60,6 +61,8 @@ export class InteractionCoordinationService { constructor( @Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort, @Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig, + @Optional() + @Inject(HANDOFF_ID_FACTORY) private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(), ) {} diff --git a/apps/gateway/src/env.ts b/apps/gateway/src/env.ts new file mode 100644 index 00000000..4422343f --- /dev/null +++ b/apps/gateway/src/env.ts @@ -0,0 +1,133 @@ +import { config } from 'dotenv'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { detectFromEnv, loadConfig } from '@mosaicstack/config'; + +type TierSource = + | 'process environment' + | 'daemon .env' + | 'monorepo-root .env' + | 'gateway-local .env' + | 'default'; + +type BootSource = TierSource | 'mosaic.config.json'; + +export interface GatewayDotenvPaths { + daemonEnv: string; + monorepoRootEnv: string; + gatewayLocalEnv: string; +} + +const here = dirname(fileURLToPath(import.meta.url)); + +export function resolveGatewayDotenvPaths( + anchor: string = here, + homeBase: string = homedir(), +): GatewayDotenvPaths { + return { + daemonEnv: join(homeBase, '.config', 'mosaic', 'gateway', '.env'), + monorepoRootEnv: resolve(anchor, '../../..', '.env'), + gatewayLocalEnv: resolve(anchor, '..', '.env'), + }; +} + +export function resolveGatewayConfigPath(anchor: string = here): string { + // GATEWAY_HOME is daemon-created 0700; its env override adds no authority because env can set MOSAIC_STORAGE_TIER. + const gatewayHome = resolve( + process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'), + ); + const daemonConfig = join(gatewayHome, 'mosaic.config.json'); + const gatewayLocalConfig = resolve(anchor, '..', 'mosaic.config.json'); + const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json'); + + if (existsSync(daemonConfig)) { + return daemonConfig; + } + if (existsSync(gatewayLocalConfig)) { + return gatewayLocalConfig; + } + if (existsSync(monorepoRootConfig)) { + return monorepoRootConfig; + } + + return monorepoRootConfig; +} + +export function loadGatewayEnv(anchor: string = here, homeBase: string = homedir()): void { + const { daemonEnv, monorepoRootEnv, gatewayLocalEnv } = resolveGatewayDotenvPaths( + anchor, + homeBase, + ); + const inheritedTier = process.env['MOSAIC_STORAGE_TIER']; + let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment'; + const inheritedDatabaseUrl = process.env['DATABASE_URL']; + let databaseUrlSource: TierSource = + inheritedDatabaseUrl === undefined ? 'default' : 'process environment'; + + function loadAnchoredDotenv( + path: string, + sourceLabel: Exclude, + ): 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(); diff --git a/apps/gateway/src/harness/harness-selection.controller.spec.ts b/apps/gateway/src/harness/harness-selection.controller.spec.ts new file mode 100644 index 00000000..7714bb53 --- /dev/null +++ b/apps/gateway/src/harness/harness-selection.controller.spec.ts @@ -0,0 +1,164 @@ +import 'reflect-metadata'; +import { + type CanActivate, + type ExecutionContext, + type INestApplication, + ValidationPipe, +} from '@nestjs/common'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { HarnessRegistry } from './harness.registry.js'; +import { HARNESS_REGISTRY } from './harness.tokens.js'; +import { HarnessSelectionRepository } from './harness-selection.repository.js'; +import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js'; +// Import the REAL module (not a hand-listed controllers+mocks list) so an +// unresolved provider fails at app.init() — the #1145-class DI-boot guard. +import { HarnessModule } from './harness.module.js'; + +// A known-available tuple from the fake adapter's default catalog. +const VALID = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-mini' }; +// A tuple whose provider/model are not in any catalog. +const UNKNOWN = { harnessId: 'fake', providerId: 'ghost-provider', modelId: 'ghost-model' }; +// A tuple that is known in the catalog but flagged unavailable. +const UNAVAILABLE = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-legacy' }; + +const authGuard: CanActivate = { + canActivate(context: ExecutionContext): boolean { + const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>(); + requestContext.user = { id: 'user-1' }; + return true; + }, +}; + +function registryWithFake(): HarnessRegistry { + const registry = new HarnessRegistry(); + registry.register(new FakeHarnessAdapter({ id: 'fake' })); + return registry; +} + +describe('Harness selection HTTP surface', () => { + let app: INestApplication; + let repository: HarnessSelectionRepository; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [HarnessModule], + }) + .overrideGuard(AuthGuard) + .useValue(authGuard) + .overrideProvider(HARNESS_REGISTRY) + .useValue(registryWithFake()) + .compile(); + + // Real in-memory repository from the module graph — proves the module wired it. + repository = moduleRef.get(HarnessSelectionRepository); + + app = moduleRef.createNestApplication(new FastifyAdapter()); + app.useGlobalPipes( + new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), + ); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + beforeEach(() => { + // Reset owner-scoped state between tests via the public API surface. + repository.set({ userId: 'user-1', tenantId: 'user-1' }, VALID); + }); + + afterAll(async () => { + await app.close(); + }); + + it('GET selection is server-scoped and ignores caller-supplied scope in the query', async () => { + const response = await request(app.getHttpServer()) + .get('/api/chat/preferences/selection') + .query({ userId: 'attacker', tenantId: 'attacker-tenant', seatId: 'attacker-seat' }); + + expect(response.status).toBe(200); + // The returned selection is user-1's (guard-derived scope), not the query's. + expect(response.body.selection).toEqual(VALID); + }); + + it('PUT with a valid structured tuple persists and round-trips via GET', async () => { + const next = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-pro' }; + + const put = await request(app.getHttpServer()) + .put('/api/chat/preferences/selection') + .send(next) + .set('Content-Type', 'application/json'); + expect(put.status).toBe(200); + expect(put.body.selection).toEqual(next); + + const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection'); + expect(get.status).toBe(200); + expect(get.body.selection).toEqual(next); + }); + + it('PUT with FREE TEXT is rejected 400 and does not mutate the stored selection', async () => { + const response = await request(app.getHttpServer()) + .put('/api/chat/preferences/selection') + .send({ selection: 'gpt-4o' }) + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(400); + + const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection'); + expect(get.body.selection).toEqual(VALID); + }); + + it.each([ + ['seatId', { ...VALID, seatId: 'attacker-seat' }], + ['tenantId', { ...VALID, tenantId: 'attacker-tenant' }], + ['userId', { ...VALID, userId: 'attacker' }], + ['nativeSessionPath', { ...VALID, nativeSessionPath: '/var/native/x.jsonl' }], + ['executable', { ...VALID, executable: '/usr/bin/evil' }], + ['home', { ...VALID, home: '/home/attacker' }], + ['cwd', { ...VALID, cwd: '/tmp/attacker' }], + ])( + 'PUT with an extra authority-bearing field (%s) is rejected 400 and does not mutate stored selection', + async (_name, body) => { + const response = await request(app.getHttpServer()) + .put('/api/chat/preferences/selection') + .send(body) + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(400); + + const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection'); + expect(get.body.selection).toEqual(VALID); + }, + ); + + it('PUT with an UNKNOWN tuple returns selection_invalid, unchanged and echoed unchanged (no fallback)', async () => { + const response = await request(app.getHttpServer()) + .put('/api/chat/preferences/selection') + .send(UNKNOWN) + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(422); + expect(response.body.code).toBe('selection_invalid'); + // Echoed back unchanged: no first-row / first-provider substitution. + expect(response.body.selection).toEqual(UNKNOWN); + + const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection'); + expect(get.body.selection).toEqual(VALID); + }); + + it('PUT with a KNOWN-but-UNAVAILABLE tuple returns model_unavailable, unchanged (distinct from selection_invalid)', async () => { + const response = await request(app.getHttpServer()) + .put('/api/chat/preferences/selection') + .send(UNAVAILABLE) + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(422); + expect(response.body.code).toBe('model_unavailable'); + expect(response.body.selection).toEqual(UNAVAILABLE); + + const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection'); + expect(get.body.selection).toEqual(VALID); + }); +}); diff --git a/apps/gateway/src/harness/harness-selection.controller.ts b/apps/gateway/src/harness/harness-selection.controller.ts new file mode 100644 index 00000000..06c4210c --- /dev/null +++ b/apps/gateway/src/harness/harness-selection.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Get, HttpException, HttpStatus, Put, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; +import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; +import { HarnessOperationError } from './harness.registry.js'; +import { HarnessSelectionService } from './harness-selection.service.js'; +import { HarnessSelectionInputDto, type SelectionResponseDto } from './harness.dto.js'; + +/** + * Chat-preferences selection surface. The scope is ALWAYS derived on the server + * from the authenticated user (`scopeFromUser(CurrentUser)`); the request body and + * query string can never name another user, tenant, or seat. A typed selection + * failure (unknown tuple → `selection_invalid`, known-but-unavailable → + * `model_unavailable`) is returned as 422 with the requested tuple echoed back + * unchanged, and never mutates the stored selection. + */ +@Controller('api/chat/preferences/selection') +@UseGuards(AuthGuard) +export class HarnessSelectionController { + constructor(private readonly selection: HarnessSelectionService) {} + + @Get() + get(@CurrentUser() user: AuthenticatedUserLike): SelectionResponseDto { + return { selection: this.selection.getSelection(scopeFromUser(user)) }; + } + + @Put() + async put( + @CurrentUser() user: AuthenticatedUserLike, + @Body() dto: HarnessSelectionInputDto, + ): Promise { + try { + const stored = await this.selection.setSelection(scopeFromUser(user), { + harnessId: dto.harnessId, + providerId: dto.providerId, + modelId: dto.modelId, + }); + return { selection: stored }; + } catch (error) { + if (error instanceof HarnessOperationError) { + throw new HttpException(error.dto, HttpStatus.UNPROCESSABLE_ENTITY); + } + throw error; + } + } +} diff --git a/apps/gateway/src/harness/harness-selection.repository.ts b/apps/gateway/src/harness/harness-selection.repository.ts new file mode 100644 index 00000000..04734d9f Binary files /dev/null and b/apps/gateway/src/harness/harness-selection.repository.ts differ diff --git a/apps/gateway/src/harness/harness-selection.service.ts b/apps/gateway/src/harness/harness-selection.service.ts new file mode 100644 index 00000000..e77b685a --- /dev/null +++ b/apps/gateway/src/harness/harness-selection.service.ts @@ -0,0 +1,90 @@ +import { randomUUID } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import type { HarnessSelection } from '@mosaicstack/types'; +import type { ActorTenantScope } from '../auth/session-scope.js'; +import { + HarnessAdapterUnavailableError, + HarnessRegistry, + operationError, +} from './harness.registry.js'; +import { HARNESS_REGISTRY } from './harness.tokens.js'; +import { readContextFromScope } from './harness.dto.js'; +import { HarnessSelectionRepository } from './harness-selection.repository.js'; + +/** + * Selection logic for the Slice-Zero chat-preferences surface. It validates the + * requested harness/provider/model tuple against the live catalog with NO + * fallback substitution, then persists it owner-scoped. The stored selection is + * only ever mutated when the tuple is valid AND available. + */ +@Injectable() +export class HarnessSelectionService { + constructor( + @Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry, + private readonly repository: HarnessSelectionRepository, + ) {} + + getSelection(scope: ActorTenantScope): HarnessSelection | null { + return this.repository.get(scope); + } + + async setSelection( + scope: ActorTenantScope, + selection: HarnessSelection, + ): Promise { + // Throws HarnessOperationError (selection_invalid / model_unavailable) with the + // requested tuple echoed back unchanged. The store is untouched on any throw. + await this.assertSelectionAvailable(scope, selection); + return this.repository.set(scope, selection); + } + + private async assertSelectionAvailable( + scope: ActorTenantScope, + selection: HarnessSelection, + ): Promise { + const correlationId = randomUUID(); + + let adapter; + try { + adapter = this.registry.get(selection.harnessId); + } catch (error) { + if (error instanceof HarnessAdapterUnavailableError) { + // An unknown harness makes the whole tuple invalid — no fallback adapter. + throw operationError( + 'selection_invalid', + 'The requested harness/provider/model tuple is not in the catalog.', + selection, + correlationId, + ); + } + throw error; + } + + const catalog = await adapter.catalog(readContextFromScope(scope)); + const entry = catalog.models.find( + (candidate) => + candidate.harnessId === selection.harnessId && + candidate.providerId === selection.providerId && + candidate.modelId === selection.modelId, + ); + + if (!entry) { + // No first-row / first-provider fallback: reject the requested tuple unchanged. + throw operationError( + 'selection_invalid', + 'The requested harness/provider/model tuple is not in the catalog.', + selection, + correlationId, + ); + } + if (entry.availability === 'unavailable') { + throw operationError( + 'model_unavailable', + 'The requested model is currently unavailable.', + selection, + correlationId, + true, + ); + } + } +} diff --git a/apps/gateway/src/harness/harness.controller.spec.ts b/apps/gateway/src/harness/harness.controller.spec.ts new file mode 100644 index 00000000..4e6f5e45 --- /dev/null +++ b/apps/gateway/src/harness/harness.controller.spec.ts @@ -0,0 +1,138 @@ +import 'reflect-metadata'; +import { + type CanActivate, + type ExecutionContext, + type INestApplication, + ValidationPipe, +} from '@nestjs/common'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { HarnessRegistry } from './harness.registry.js'; +import { HARNESS_REGISTRY } from './harness.tokens.js'; +import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js'; +// The real module under test — importing it (not a hand-listed controllers/mocks +// list) is what makes an unresolved provider fail loudly at app.init() (#1145 guard). +import { HarnessModule } from './harness.module.js'; + +// Fields that must NEVER surface on a browser-facing catalog/list response. +const FORBIDDEN_KEYS = [ + 'executable', + 'executablePath', + 'home', + 'homeDir', + 'cwd', + 'workingDir', + 'workingDirectory', + 'nativeSessionPath', + 'sessionPath', + 'env', + 'secret', + 'secrets', + 'token', + 'apiKey', +]; + +function assertNoForbiddenLeak(payload: unknown): void { + const serialized = JSON.stringify(payload).toLowerCase(); + for (const key of FORBIDDEN_KEYS) { + expect(serialized).not.toContain(key.toLowerCase()); + } +} + +const authGuard: CanActivate = { + canActivate(context: ExecutionContext): boolean { + const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>(); + requestContext.user = { id: 'user-1' }; + return true; + }, +}; + +function registryWithFake(): HarnessRegistry { + const registry = new HarnessRegistry(); + registry.register(new FakeHarnessAdapter({ id: 'fake' })); + return registry; +} + +describe('Harness catalog HTTP surface', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [HarnessModule], + }) + .overrideGuard(AuthGuard) + .useValue(authGuard) + .overrideProvider(HARNESS_REGISTRY) + .useValue(registryWithFake()) + .compile(); + + app = moduleRef.createNestApplication(new FastifyAdapter()); + app.useGlobalPipes( + new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), + ); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('boots the real HarnessModule so all providers resolve at app.init()', () => { + // If HarnessModule failed to resolve a provider, beforeAll's app.init() would + // have thrown and this suite would never reach here. + expect(app).toBeDefined(); + }); + + it('GET /api/harnesses returns 200 with safe fields only', async () => { + const response = await request(app.getHttpServer()).get('/api/harnesses'); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body.length).toBeGreaterThan(0); + const summary = response.body[0]; + expect(Object.keys(summary).sort()).toEqual(['capabilities', 'displayName', 'id']); + expect(summary.id).toBe('fake'); + expect(typeof summary.displayName).toBe('string'); + expect(Array.isArray(summary.capabilities)).toBe(true); + assertNoForbiddenLeak(response.body); + }); + + it('GET /api/harnesses/:harnessId/catalog returns 200 with safe catalog fields only', async () => { + const response = await request(app.getHttpServer()).get('/api/harnesses/fake/catalog'); + + expect(response.status).toBe(200); + expect(response.body.harnessId).toBe('fake'); + expect(typeof response.body.version).toBe('string'); + expect(typeof response.body.fingerprint).toBe('string'); + expect(Array.isArray(response.body.models)).toBe(true); + expect(response.body.models.length).toBeGreaterThan(0); + const entry = response.body.models[0]; + // Whitelisted catalog-entry fields only (no executables/paths/secrets). + expect(Object.keys(entry).sort()).toEqual( + [ + 'authState', + 'availability', + 'displayName', + 'harnessId', + 'inputTypes', + 'modelId', + 'providerId', + 'reasoningCapability', + ].sort(), + ); + assertNoForbiddenLeak(response.body); + }); + + it('GET catalog for an unknown harnessId returns a typed adapter_unavailable error, never a fallback catalog', async () => { + const response = await request(app.getHttpServer()).get('/api/harnesses/ghost-harness/catalog'); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('adapter_unavailable'); + // A fallback catalog would carry a models array; a typed error must not. + expect(response.body.models).toBeUndefined(); + }); +}); diff --git a/apps/gateway/src/harness/harness.controller.ts b/apps/gateway/src/harness/harness.controller.ts new file mode 100644 index 00000000..75da289d --- /dev/null +++ b/apps/gateway/src/harness/harness.controller.ts @@ -0,0 +1,65 @@ +import { + Controller, + Get, + HttpException, + HttpStatus, + Inject, + Param, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; +import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; +import { HarnessAdapterUnavailableError, HarnessRegistry } from './harness.registry.js'; +import { HARNESS_REGISTRY } from './harness.tokens.js'; +import { + readContextFromScope, + toHarnessSummary, + toSafeCatalog, + type HarnessCatalogDto, + type HarnessSummaryDto, +} from './harness.dto.js'; + +/** + * Generic harness catalog surface. It exposes only harness-neutral, browser-safe + * fields (identity, capabilities, provider/model catalog) — never executables, + * native paths, home/cwd, env, or secrets. There is NO provider-probe route here; + * `/api/providers` and `POST /api/providers/test` are intentionally out of scope. + */ +@Controller('api/harnesses') +@UseGuards(AuthGuard) +export class HarnessController { + constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {} + + @Get() + async list(@CurrentUser() user: AuthenticatedUserLike): Promise { + const context = readContextFromScope(scopeFromUser(user)); + const summaries: HarnessSummaryDto[] = []; + for (const adapter of this.registry.list()) { + summaries.push(toHarnessSummary(await adapter.describe(context))); + } + return summaries; + } + + @Get(':harnessId/catalog') + async catalog( + @CurrentUser() user: AuthenticatedUserLike, + @Param('harnessId') harnessId: string, + ): Promise { + const context = readContextFromScope(scopeFromUser(user)); + let adapter; + try { + adapter = this.registry.get(harnessId); + } catch (error) { + if (error instanceof HarnessAdapterUnavailableError) { + // Typed failure — NEVER a fallback catalog for an unknown harness id. + throw new HttpException( + { code: error.code, message: error.message, harnessId }, + HttpStatus.NOT_FOUND, + ); + } + throw error; + } + return toSafeCatalog(await adapter.catalog(context)); + } +} diff --git a/apps/gateway/src/harness/harness.dto.ts b/apps/gateway/src/harness/harness.dto.ts new file mode 100644 index 00000000..deee6848 --- /dev/null +++ b/apps/gateway/src/harness/harness.dto.ts @@ -0,0 +1,116 @@ +import { randomUUID } from 'node:crypto'; +import { IsNotEmpty, IsString } from 'class-validator'; +import type { + HarnessActorContext, + HarnessAuthState, + HarnessCapability, + HarnessCatalog, + HarnessCatalogEntry, + HarnessDescriptor, + HarnessInputType, + HarnessModelAvailability, + HarnessSelection, +} from '@mosaicstack/types'; +import type { ActorTenantScope } from '../auth/session-scope.js'; + +/** + * Structured selection tuple accepted on `PUT /api/chat/preferences/selection`. + * + * The body is a STRUCTURED tuple (harness + provider + model), never a free-text + * model string. With `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })` + * any extra property — including smuggled server-authority fields such as + * `seatId`, `tenantId`, `userId`, `nativeSessionPath`, `executable`, `home`, `cwd` — + * is rejected with 400. There is deliberately no field through which a caller can + * name a scope; scope is derived on the server from the authenticated session. + */ +export class HarnessSelectionInputDto { + @IsString() + @IsNotEmpty() + harnessId!: string; + + @IsString() + @IsNotEmpty() + providerId!: string; + + @IsString() + @IsNotEmpty() + modelId!: string; +} + +/** Browser-safe harness summary — identity and capabilities only. */ +export interface HarnessSummaryDto { + readonly id: string; + readonly displayName: string; + readonly capabilities: readonly HarnessCapability[]; +} + +/** Browser-safe catalog entry — no executables, paths, secrets, or env. */ +export interface HarnessCatalogEntryDto { + readonly harnessId: string; + readonly providerId: string; + readonly modelId: string; + readonly displayName: string; + readonly reasoningCapability: boolean; + readonly inputTypes: readonly HarnessInputType[]; + readonly authState: HarnessAuthState; + readonly availability: HarnessModelAvailability; +} + +/** Browser-safe catalog envelope. */ +export interface HarnessCatalogDto { + readonly harnessId: string; + readonly version: string; + readonly fingerprint: string; + readonly models: readonly HarnessCatalogEntryDto[]; +} + +/** Response envelope for the caller's current selection (null when unset). */ +export interface SelectionResponseDto { + readonly selection: HarnessSelection | null; +} + +/** + * Derive a server-trusted {@link HarnessActorContext} for read operations from the + * session-derived {@link ActorTenantScope}. All authority originates on the server; + * nothing here is caller-supplied. A fresh correlation id is minted per call. + */ +export function readContextFromScope(scope: ActorTenantScope): HarnessActorContext { + return { + actorId: scope.userId, + tenantId: scope.tenantId, + seatId: scope.userId, + correlationId: randomUUID(), + }; +} + +/** Project a descriptor onto the browser-safe summary shape (whitelist by construction). */ +export function toHarnessSummary(descriptor: HarnessDescriptor): HarnessSummaryDto { + return { + id: descriptor.id, + displayName: descriptor.displayName, + capabilities: [...descriptor.capabilities], + }; +} + +/** Project a catalog onto the browser-safe shape (whitelist by construction). */ +export function toSafeCatalog(catalog: HarnessCatalog): HarnessCatalogDto { + return { + harnessId: catalog.harnessId, + version: catalog.version, + fingerprint: catalog.fingerprint, + models: catalog.models.map(toSafeCatalogEntry), + }; +} + +function toSafeCatalogEntry(entry: HarnessCatalogEntry): HarnessCatalogEntryDto { + return { + harnessId: entry.harnessId, + providerId: entry.providerId, + modelId: entry.modelId, + displayName: entry.displayName, + reasoningCapability: entry.reasoningCapability, + inputTypes: [...entry.inputTypes], + authState: entry.authState, + availability: entry.availability, + }; +} diff --git a/apps/gateway/src/harness/harness.module.ts b/apps/gateway/src/harness/harness.module.ts new file mode 100644 index 00000000..b4453f1f --- /dev/null +++ b/apps/gateway/src/harness/harness.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { HarnessRegistry } from './harness.registry.js'; +import { HarnessService } from './harness.service.js'; +import { + HARNESS_CONVERSATION_SERVICE, + HARNESS_CONVERSATION_SERVICE_UNAVAILABLE, + HARNESS_REGISTRY, + HARNESS_SERVICE, +} from './harness.tokens.js'; +import { HarnessController } from './harness.controller.js'; +import { HarnessSelectionController } from './harness-selection.controller.js'; +import { HarnessSelectionService } from './harness-selection.service.js'; +import { HarnessSelectionRepository } from './harness-selection.repository.js'; + +/** + * Wires the harness-neutral registry/service (Task Two) together with the + * Slice-Zero catalog and selection HTTP surfaces (Task Three). + * + * The registry is provided empty here; real harness adapters are registered in a + * later task. Because the controllers/services resolve their collaborators through + * this real module graph, an unresolved provider fails loudly at `app.init()`. + */ +@Module({ + controllers: [HarnessController, HarnessSelectionController], + providers: [ + { provide: HARNESS_REGISTRY, useFactory: () => new HarnessRegistry() }, + { provide: HARNESS_SERVICE, useClass: HarnessService }, + // Task Five: bind the conversation-service token to its explicit "not yet bound" + // sentinel. The pi-rpc router treats this as a hard, typed startup failure; Task 14 + // replaces it with a real service. Exported so ChatModule's router can inject it. + { provide: HARNESS_CONVERSATION_SERVICE, useValue: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE }, + HarnessSelectionRepository, + HarnessSelectionService, + ], + exports: [HARNESS_REGISTRY, HARNESS_SERVICE, HARNESS_CONVERSATION_SERVICE], +}) +export class HarnessModule {} diff --git a/apps/gateway/src/harness/harness.registry.spec.ts b/apps/gateway/src/harness/harness.registry.spec.ts new file mode 100644 index 00000000..66b1fb7c --- /dev/null +++ b/apps/gateway/src/harness/harness.registry.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { + HarnessAdapterUnavailableError, + HarnessRegistrationError, + HarnessRegistry, +} from './harness.registry.js'; +import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js'; + +describe('HarnessRegistry', () => { + it('registers and looks up an adapter by harness id', () => { + const registry = new HarnessRegistry(); + const adapter = new FakeHarnessAdapter({ id: 'fake' }); + + registry.register(adapter); + + expect(registry.get('fake')).toBe(adapter); + expect(registry.has('fake')).toBe(true); + expect(registry.list().map((entry) => entry.id)).toEqual(['fake']); + }); + + it('rejects a blank adapter id', () => { + const registry = new HarnessRegistry(); + + let error: unknown; + try { + registry.register(new FakeHarnessAdapter({ id: ' ' })); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(HarnessRegistrationError); + expect((error as HarnessRegistrationError).reason).toBe('blank_id'); + expect(registry.list()).toEqual([]); + }); + + it('rejects a duplicate adapter id', () => { + const registry = new HarnessRegistry(); + registry.register(new FakeHarnessAdapter({ id: 'fake' })); + + let error: unknown; + try { + registry.register(new FakeHarnessAdapter({ id: 'fake' })); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(HarnessRegistrationError); + expect((error as HarnessRegistrationError).reason).toBe('duplicate_id'); + expect((error as HarnessRegistrationError).harnessId).toBe('fake'); + // The original registration is untouched. + expect(registry.list()).toHaveLength(1); + }); + + it('returns adapter_unavailable for an unknown harness id', () => { + const registry = new HarnessRegistry(); + + let error: unknown; + try { + registry.get('missing'); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(HarnessAdapterUnavailableError); + expect((error as HarnessAdapterUnavailableError).code).toBe('adapter_unavailable'); + expect((error as HarnessAdapterUnavailableError).harnessId).toBe('missing'); + expect(registry.has('missing')).toBe(false); + }); +}); diff --git a/apps/gateway/src/harness/harness.registry.ts b/apps/gateway/src/harness/harness.registry.ts new file mode 100644 index 00000000..b1e67994 --- /dev/null +++ b/apps/gateway/src/harness/harness.registry.ts @@ -0,0 +1,100 @@ +import { Injectable } from '@nestjs/common'; +import type { + HarnessAdapter, + HarnessErrorCode, + HarnessErrorDto, + HarnessSelection, +} from '@mosaicstack/types'; + +/** + * A typed harness operation failure that carries a fully-formed, browser-safe + * {@link HarnessErrorDto}. The DTO's `selection` is always the exact requested + * tuple — there is no field through which a substituted "effective" selection + * could ever be reported. + */ +export class HarnessOperationError extends Error { + readonly code: HarnessErrorCode; + readonly dto: HarnessErrorDto; + + constructor(dto: HarnessErrorDto) { + super(dto.message); + this.name = 'HarnessOperationError'; + this.code = dto.code; + this.dto = dto; + } +} + +/** Build a {@link HarnessOperationError} that echoes the requested selection unchanged. */ +export function operationError( + code: HarnessErrorCode, + message: string, + selection: HarnessSelection, + correlationId: string, + retryable = false, +): HarnessOperationError { + return new HarnessOperationError({ code, message, retryable, correlationId, selection }); +} + +/** Raised when an unknown harness id is looked up. Discriminated by `code`. */ +export class HarnessAdapterUnavailableError extends Error { + readonly code = 'adapter_unavailable' as const satisfies HarnessErrorCode; + + constructor(readonly harnessId: string) { + super(`No harness adapter is registered for id "${harnessId}".`); + this.name = 'HarnessAdapterUnavailableError'; + } +} + +export type HarnessRegistrationFailure = 'blank_id' | 'duplicate_id'; + +/** Raised when an adapter cannot be registered (blank or duplicate id). */ +export class HarnessRegistrationError extends Error { + constructor( + readonly reason: HarnessRegistrationFailure, + readonly harnessId: string, + ) { + super( + reason === 'blank_id' + ? 'A harness adapter id must be a non-empty string.' + : `A harness adapter is already registered for id "${harnessId}".`, + ); + this.name = 'HarnessRegistrationError'; + } +} + +/** + * Harness-neutral adapter registry. Adapters are keyed by their harness id. + * Registration rejects blank and duplicate ids; lookup of an unknown id fails + * with {@link HarnessAdapterUnavailableError} (`adapter_unavailable`). + */ +@Injectable() +export class HarnessRegistry { + private readonly adapters = new Map(); + + register(adapter: HarnessAdapter): void { + const id = adapter.id; + if (typeof id !== 'string' || id.trim().length === 0) { + throw new HarnessRegistrationError('blank_id', id ?? ''); + } + if (this.adapters.has(id)) { + throw new HarnessRegistrationError('duplicate_id', id); + } + this.adapters.set(id, adapter); + } + + get(harnessId: string): HarnessAdapter { + const adapter = this.adapters.get(harnessId); + if (!adapter) { + throw new HarnessAdapterUnavailableError(harnessId); + } + return adapter; + } + + has(harnessId: string): boolean { + return this.adapters.has(harnessId); + } + + list(): readonly HarnessAdapter[] { + return [...this.adapters.values()]; + } +} diff --git a/apps/gateway/src/harness/harness.service.spec.ts b/apps/gateway/src/harness/harness.service.spec.ts new file mode 100644 index 00000000..ff26e973 --- /dev/null +++ b/apps/gateway/src/harness/harness.service.spec.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import type { HarnessActorContext, HarnessCapability, HarnessSelection } from '@mosaicstack/types'; +import { HARNESS_CAPABILITIES } from '@mosaicstack/types'; +import { HarnessOperationError, HarnessRegistry } from './harness.registry.js'; +import { + HarnessScopeViolationError, + HarnessService, + type TrustedGatewayScope, +} from './harness.service.js'; +import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js'; + +const SCOPE: TrustedGatewayScope = { + actorId: 'actor-trusted', + tenantId: 'tenant-trusted', + seatId: 'seat-trusted', + correlationId: 'correlation-trusted', +}; + +const READ_CONTEXT: HarnessActorContext = { + actorId: SCOPE.actorId, + tenantId: SCOPE.tenantId, + seatId: SCOPE.seatId, + correlationId: SCOPE.correlationId, +}; + +function setup(capabilities?: readonly HarnessCapability[]) { + const registry = new HarnessRegistry(); + const adapter = new FakeHarnessAdapter({ id: 'fake', capabilities }); + registry.register(adapter); + const service = new HarnessService(registry); + return { registry, adapter, service }; +} + +async function availableSelection(adapter: FakeHarnessAdapter): Promise { + const catalog = await adapter.catalog(READ_CONTEXT); + const entry = catalog.models.find((model) => model.availability === 'available'); + if (!entry) { + throw new Error('fixture requires an available model'); + } + return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId }; +} + +describe('HarnessService', () => { + it('derives the actor context from trusted scope on create', async () => { + const { service, adapter } = setup(); + const selection = await availableSelection(adapter); + + const snapshot = await service.createSession(SCOPE, { + conversationId: 'conversation-1', + selection, + }); + + expect(snapshot.seatId).toBe(SCOPE.seatId); + expect(snapshot.state).toBe('idle'); + expect(snapshot.selection).toEqual(selection); + expect(snapshot.nativeSessionId).toBeTruthy(); + }); + + it('rejects server-authority fields supplied by an external caller', async () => { + const { service, adapter } = setup(); + const selection = await availableSelection(adapter); + + const hostile = { + conversationId: 'conversation-1', + selection, + seatId: 'attacker-seat', + executablePath: '/usr/bin/evil', + home: '/home/attacker', + cwd: '/tmp/attacker', + nativeSessionPath: '/var/native/attacker.jsonl', + } as unknown as Parameters[1]; + + let error: unknown; + try { + await service.createSession(SCOPE, hostile); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(HarnessScopeViolationError); + expect((error as HarnessScopeViolationError).field).toBe('seatId'); + }); + + it('returns adapter_unavailable for an unknown harness id, echoing the requested tuple', async () => { + const { service } = setup(); + const selection: HarnessSelection = { + harnessId: 'ghost-harness', + providerId: 'p', + modelId: 'm', + }; + + let error: unknown; + try { + await service.createSession(SCOPE, { conversationId: 'conversation-1', selection }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(HarnessOperationError); + const dto = (error as HarnessOperationError).dto; + expect(dto.code).toBe('adapter_unavailable'); + expect(dto.selection).toEqual(selection); + expect(dto.correlationId).toBe(SCOPE.correlationId); + }); + + it('returns selection_invalid for an unknown provider/model tuple, unchanged', async () => { + const { service } = setup(); + const selection: HarnessSelection = { + harnessId: 'fake', + providerId: 'ghost-provider', + modelId: 'ghost-model', + }; + + let error: unknown; + try { + await service.createSession(SCOPE, { conversationId: 'conversation-1', selection }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(HarnessOperationError); + const dto = (error as HarnessOperationError).dto; + expect(dto.code).toBe('selection_invalid'); + expect(dto.selection).toEqual(selection); + }); + + it('returns model_unavailable without falling back for a known unavailable model', async () => { + const { service, adapter } = setup(); + const catalog = await adapter.catalog(READ_CONTEXT); + const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable'); + expect(unavailable).toBeDefined(); + const selection: HarnessSelection = { + harnessId: unavailable!.harnessId, + providerId: unavailable!.providerId, + modelId: unavailable!.modelId, + }; + + let error: unknown; + try { + await service.createSession(SCOPE, { conversationId: 'conversation-1', selection }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(HarnessOperationError); + const dto = (error as HarnessOperationError).dto; + expect(dto.code).toBe('model_unavailable'); + // No substitution: the DTO tuple is exactly what was requested. + expect(dto.selection).toEqual(selection); + }); + + it('gives create, resume, detach, evict, and end distinct observable effects', async () => { + const { service, adapter } = setup(); + const selection = await availableSelection(adapter); + + const created = await service.createSession(SCOPE, { + conversationId: 'conversation-create', + selection, + }); + expect(created.state).toBe('idle'); + expect(created.processId).toBeTruthy(); + expect(created.attachedClientIds).toEqual([]); + + const resumed = await service.resumeSession(SCOPE, { + conversationId: 'conversation-resume', + nativeSessionId: 'native-preexisting-123', + selection, + }); + // Resume binds the supplied native session; create mints a fresh one. + expect(resumed.nativeSessionId).toBe('native-preexisting-123'); + expect(resumed.nativeSessionId).not.toBe(created.nativeSessionId); + + await service.attach(SCOPE, { + conversationId: 'conversation-create', + clientId: 'browser-1', + }); + const afterAttach = await service.snapshot(SCOPE, 'conversation-create'); + expect(afterAttach.attachedClientIds).toEqual(['browser-1']); + + const afterDetach = await service.detach(SCOPE, { + conversationId: 'conversation-create', + clientId: 'browser-1', + }); + // Detach removes the browser attachment only; the process stays alive. + expect(afterDetach.attachedClientIds).toEqual([]); + expect(afterDetach.state).toBe('idle'); + expect(afterDetach.processId).toBeTruthy(); + + const afterEvict = await service.evict(SCOPE, { + conversationId: 'conversation-create', + reason: 'idle_timeout', + }); + // Evict stops the process but retains the resumable native session. + expect(afterEvict.state).toBe('evicted'); + expect(afterEvict.processId).toBeUndefined(); + expect(afterEvict.nativeSessionId).toBe(created.nativeSessionId); + + const afterEnd = await service.end(SCOPE, { + conversationId: 'conversation-create', + reason: 'session_ended', + }); + // End destructively terminates the native session. + expect(afterEnd.state).toBe('ended'); + }); + + it('fails typed when an unsupported capability is exercised', async () => { + const withoutExtensionUi = HARNESS_CAPABILITIES.filter( + (capability) => capability !== 'extensionUi', + ); + const { service, adapter } = setup(withoutExtensionUi); + const selection = await availableSelection(adapter); + await service.createSession(SCOPE, { conversationId: 'conversation-1', selection }); + + let error: unknown; + try { + await service.respondInteraction(SCOPE, { + conversationId: 'conversation-1', + response: { requestId: 'interaction-1', type: 'confirm', accepted: true }, + }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(HarnessOperationError); + expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported'); + }); +}); diff --git a/apps/gateway/src/harness/harness.service.ts b/apps/gateway/src/harness/harness.service.ts new file mode 100644 index 00000000..a9da7998 --- /dev/null +++ b/apps/gateway/src/harness/harness.service.ts @@ -0,0 +1,285 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { + HarnessActorContext, + HarnessAdapter, + HarnessCatalog, + HarnessCloseReason, + HarnessInteractionResponse, + HarnessSelection, + HarnessSessionHandle, + HarnessSessionSnapshot, +} from '@mosaicstack/types'; +import { + HarnessAdapterUnavailableError, + HarnessRegistry, + operationError, +} from './harness.registry.js'; +import { HARNESS_REGISTRY } from './harness.tokens.js'; + +/** + * Trusted, server-derived authority. In production this is produced by the + * Gateway from the authenticated session — never from a browser/caller DTO. + */ +export interface TrustedGatewayScope { + readonly actorId: string; + readonly tenantId: string; + readonly seatId: string; + readonly correlationId: string; +} + +/** Server-authority fields that must never arrive from an external request DTO. */ +const FORBIDDEN_REQUEST_FIELDS = [ + 'actorId', + 'tenantId', + 'correlationId', + 'seatId', + 'seat', + 'executable', + 'executablePath', + 'home', + 'homeDir', + 'cwd', + 'workingDir', + 'workingDirectory', + 'nativeSessionPath', + 'sessionPath', +] as const; + +/** Raised when an external request DTO smuggles a server-authority field. */ +export class HarnessScopeViolationError extends Error { + constructor(readonly field: string) { + super(`External request supplied server-authority field "${field}".`); + this.name = 'HarnessScopeViolationError'; + } +} + +export interface CreateHarnessSessionRequest { + readonly conversationId: string; + readonly selection: HarnessSelection; +} + +export interface ResumeHarnessSessionRequest { + readonly conversationId: string; + readonly nativeSessionId: string; + readonly selection: HarnessSelection; +} + +export interface AttachClientRequest { + readonly conversationId: string; + readonly clientId: string; +} + +export interface DetachClientRequest { + readonly conversationId: string; + readonly clientId: string; +} + +export interface EvictSessionRequest { + readonly conversationId: string; + readonly reason: HarnessCloseReason; +} + +export interface EndSessionRequest { + readonly conversationId: string; + readonly reason: HarnessCloseReason; +} + +export interface RespondInteractionRequest { + readonly conversationId: string; + readonly response: HarnessInteractionResponse; +} + +interface ActiveSession { + readonly harnessId: string; + readonly handle: HarnessSessionHandle; + readonly correlationId: string; +} + +/** + * Harness-neutral service. It derives the {@link HarnessActorContext} strictly + * from trusted Gateway scope, validates the selected provider/model tuple with + * NO fallback substitution, and exposes distinct create/resume/detach/evict/end + * lifecycle operations. + */ +@Injectable() +export class HarnessService { + private readonly sessions = new Map(); + + constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {} + + async createSession( + scope: TrustedGatewayScope, + request: CreateHarnessSessionRequest, + ): Promise { + assertTrustedRequest(request); + const { conversationId, selection } = request; + const adapter = this.resolveAdapter(scope, selection); + const context = deriveActorContext(scope); + await this.assertSelectionAvailable(scope, adapter.catalog(context), selection); + + const handle = await adapter.create({ context, conversationId, selection }); + this.sessions.set(conversationId, { + harnessId: selection.harnessId, + handle, + correlationId: scope.correlationId, + }); + return handle.snapshot(); + } + + async resumeSession( + scope: TrustedGatewayScope, + request: ResumeHarnessSessionRequest, + ): Promise { + assertTrustedRequest(request); + const { conversationId, nativeSessionId, selection } = request; + const adapter = this.resolveAdapter(scope, selection); + const context = deriveActorContext(scope); + await this.assertSelectionAvailable(scope, adapter.catalog(context), selection); + + const handle = await adapter.resume({ context, conversationId, nativeSessionId, selection }); + this.sessions.set(conversationId, { + harnessId: selection.harnessId, + handle, + correlationId: scope.correlationId, + }); + return handle.snapshot(); + } + + async attach( + scope: TrustedGatewayScope, + request: AttachClientRequest, + ): Promise { + assertTrustedRequest(request); + const handle = this.requireHandle(scope, request.conversationId); + await handle.attach({ clientId: request.clientId }); + return handle.snapshot(); + } + + async detach( + scope: TrustedGatewayScope, + request: DetachClientRequest, + ): Promise { + assertTrustedRequest(request); + const handle = this.requireHandle(scope, request.conversationId); + await handle.detach(request.clientId); + return handle.snapshot(); + } + + async evict( + scope: TrustedGatewayScope, + request: EvictSessionRequest, + ): Promise { + assertTrustedRequest(request); + const handle = this.requireHandle(scope, request.conversationId); + await handle.evictProcess(request.reason); + return handle.snapshot(); + } + + async end( + scope: TrustedGatewayScope, + request: EndSessionRequest, + ): Promise { + assertTrustedRequest(request); + const handle = this.requireHandle(scope, request.conversationId); + await handle.endSession(request.reason); + const snapshot = await handle.snapshot(); + this.sessions.delete(request.conversationId); + return snapshot; + } + + async respondInteraction( + scope: TrustedGatewayScope, + request: RespondInteractionRequest, + ): Promise { + assertTrustedRequest(request); + const handle = this.requireHandle(scope, request.conversationId); + await handle.respondInteraction(request.response); + } + + async snapshot( + scope: TrustedGatewayScope, + conversationId: string, + ): Promise { + const handle = this.requireHandle(scope, conversationId); + return handle.snapshot(); + } + + private resolveAdapter(scope: TrustedGatewayScope, selection: HarnessSelection): HarnessAdapter { + try { + return this.registry.get(selection.harnessId); + } catch (error) { + if (error instanceof HarnessAdapterUnavailableError) { + throw operationError('adapter_unavailable', error.message, selection, scope.correlationId); + } + throw error; + } + } + + private async assertSelectionAvailable( + scope: TrustedGatewayScope, + catalogPromise: Promise, + selection: HarnessSelection, + ): Promise { + const catalog = await catalogPromise; + const entry = catalog.models.find( + (candidate) => + candidate.harnessId === selection.harnessId && + candidate.providerId === selection.providerId && + candidate.modelId === selection.modelId, + ); + if (!entry) { + // No first-row fallback: reject the requested tuple unchanged. + throw operationError( + 'selection_invalid', + 'The requested harness/provider/model tuple is not in the catalog.', + selection, + scope.correlationId, + ); + } + if (entry.availability === 'unavailable') { + throw operationError( + 'model_unavailable', + 'The requested model is currently unavailable.', + selection, + scope.correlationId, + true, + ); + } + } + + private requireHandle(scope: TrustedGatewayScope, conversationId: string): HarnessSessionHandle { + const active = this.sessions.get(conversationId); + if (!active) { + throw operationError( + 'session_not_found', + `No active harness session for conversation "${conversationId}".`, + { harnessId: '', providerId: '', modelId: '' }, + scope.correlationId, + ); + } + return active.handle; + } +} + +/** Build the actor context strictly from trusted scope. No caller data leaks in. */ +export function deriveActorContext(scope: TrustedGatewayScope): HarnessActorContext { + return { + actorId: scope.actorId, + tenantId: scope.tenantId, + seatId: scope.seatId, + correlationId: scope.correlationId, + }; +} + +/** Reject any request object that carries a server-authority field. */ +function assertTrustedRequest(request: object): void { + for (const field of FORBIDDEN_REQUEST_FIELDS) { + if (Object.prototype.hasOwnProperty.call(request, field)) { + throw new HarnessScopeViolationError(field); + } + } +} + +// Re-export the typed operation error so callers importing from the service +// have the discriminated failure type without reaching into the registry. +export { HarnessOperationError } from './harness.registry.js'; diff --git a/apps/gateway/src/harness/harness.tokens.ts b/apps/gateway/src/harness/harness.tokens.ts new file mode 100644 index 00000000..56d12c89 --- /dev/null +++ b/apps/gateway/src/harness/harness.tokens.ts @@ -0,0 +1,45 @@ +/** + * Nest dependency-injection tokens for the harness-neutral registry and service. + * + * String tokens follow the existing Gateway convention (see `memory/memory.tokens.ts`) + * and remain valid Nest `InjectionToken`s for `@Inject(...)`. + */ +import type { HarnessConversationService } from '@mosaicstack/types'; + +export const HARNESS_REGISTRY = 'HARNESS_REGISTRY' as const; +export const HARNESS_SERVICE = 'HARNESS_SERVICE' as const; + +export type HarnessRegistryToken = typeof HARNESS_REGISTRY; +export type HarnessServiceToken = typeof HARNESS_SERVICE; + +/** + * Token for the {@link HarnessConversationService} that {@link HarnessChatRuntime} + * depends on. Until Task 14 provides a real implementation, `HarnessModule` binds + * the {@link HARNESS_CONVERSATION_SERVICE_UNAVAILABLE} sentinel here, and the + * `pi-rpc` router treats that sentinel as a hard, typed startup failure. + */ +export const HARNESS_CONVERSATION_SERVICE = 'HARNESS_CONVERSATION_SERVICE' as const; + +export type HarnessConversationServiceToken = typeof HARNESS_CONVERSATION_SERVICE; + +/** + * Explicit "not yet bound" value for {@link HARNESS_CONVERSATION_SERVICE}. It is a + * distinct sentinel — never `null`/`undefined` — so an unbound service is an + * intentional, checkable state rather than an accidental nil that could read as + * "present". Replaced by a real service in Task 14. + */ +export const HARNESS_CONVERSATION_SERVICE_UNAVAILABLE: unique symbol = Symbol( + 'HARNESS_CONVERSATION_SERVICE_UNAVAILABLE', +); + +/** A binding for {@link HARNESS_CONVERSATION_SERVICE}: a real service or the sentinel. */ +export type HarnessConversationServiceBinding = + | HarnessConversationService + | typeof HARNESS_CONVERSATION_SERVICE_UNAVAILABLE; + +/** Narrows a binding to a usable service, excluding the unavailable sentinel. */ +export function isHarnessConversationServiceAvailable( + binding: HarnessConversationServiceBinding, +): binding is HarnessConversationService { + return binding !== HARNESS_CONVERSATION_SERVICE_UNAVAILABLE; +} diff --git a/apps/gateway/src/harness/testing/fake-harness.adapter.spec.ts b/apps/gateway/src/harness/testing/fake-harness.adapter.spec.ts new file mode 100644 index 00000000..7a449e7a --- /dev/null +++ b/apps/gateway/src/harness/testing/fake-harness.adapter.spec.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import type { HarnessActorContext, HarnessSelection } from '@mosaicstack/types'; +import { HarnessOperationError } from '../harness.registry.js'; +import { FakeHarnessAdapter } from './fake-harness.adapter.js'; +import { runHarnessAdapterContract } from './harness-adapter.contract.js'; + +const CONTEXT: HarnessActorContext = { + actorId: 'actor-1', + tenantId: 'tenant-1', + seatId: 'seat-1', + correlationId: 'correlation-1', +}; + +// The reusable conformance suite. Task 13 re-runs it against the native Pi adapter. +runHarnessAdapterContract('FakeHarnessAdapter', () => new FakeHarnessAdapter({ id: 'fake' })); + +describe('FakeHarnessAdapter no-substitution', () => { + it('never substitutes the first catalog row when a bogus selection is requested', async () => { + const adapter = new FakeHarnessAdapter({ id: 'fake' }); + const catalog = await adapter.catalog(CONTEXT); + const firstRow = catalog.models[0]; + if (!firstRow) { + throw new Error('fixture requires a catalog model'); + } + const available = catalog.models.find( + (entry) => entry.availability === 'available' && entry.modelId !== firstRow.modelId, + ); + expect(available).toBeDefined(); + const selected: HarnessSelection = { + harnessId: available!.harnessId, + providerId: available!.providerId, + modelId: available!.modelId, + }; + + const handle = await adapter.create({ + context: CONTEXT, + conversationId: 'conversation-1', + selection: selected, + }); + + const bogus: HarnessSelection = { + harnessId: 'fake', + providerId: 'ghost-provider', + modelId: 'ghost-model', + }; + + let error: unknown; + try { + await handle.setModel(bogus); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(HarnessOperationError); + const dto = (error as HarnessOperationError).dto; + expect(dto.code).toBe('selection_invalid'); + // The DTO echoes the exact requested tuple, unchanged. + expect(dto.selection).toEqual(bogus); + // No substitution to the first catalog row. + expect(dto.selection).not.toEqual({ + harnessId: firstRow.harnessId, + providerId: firstRow.providerId, + modelId: firstRow.modelId, + }); + // The active selection is untouched by the rejected request. + expect((await handle.snapshot()).selection).toEqual(selected); + }); + + it('reports model_unavailable with the unchanged tuple for a known but unavailable model', async () => { + const adapter = new FakeHarnessAdapter({ id: 'fake' }); + const catalog = await adapter.catalog(CONTEXT); + const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable'); + const available = catalog.models.find((entry) => entry.availability === 'available'); + expect(unavailable).toBeDefined(); + expect(available).toBeDefined(); + + const startingSelection: HarnessSelection = { + harnessId: available!.harnessId, + providerId: available!.providerId, + modelId: available!.modelId, + }; + const handle = await adapter.create({ + context: CONTEXT, + conversationId: 'conversation-2', + selection: startingSelection, + }); + + const requested: HarnessSelection = { + harnessId: unavailable!.harnessId, + providerId: unavailable!.providerId, + modelId: unavailable!.modelId, + }; + + let error: unknown; + try { + await handle.setModel(requested); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(HarnessOperationError); + const dto = (error as HarnessOperationError).dto; + expect(dto.code).toBe('model_unavailable'); + expect(dto.selection).toEqual(requested); + expect((await handle.snapshot()).selection).toEqual(startingSelection); + }); +}); diff --git a/apps/gateway/src/harness/testing/fake-harness.adapter.ts b/apps/gateway/src/harness/testing/fake-harness.adapter.ts new file mode 100644 index 00000000..58b3f431 --- /dev/null +++ b/apps/gateway/src/harness/testing/fake-harness.adapter.ts @@ -0,0 +1,248 @@ +import type { + AttachClient, + CreateHarnessSession, + HarnessAdapter, + HarnessActorContext, + HarnessCapability, + HarnessCatalog, + HarnessCatalogEntry, + HarnessCloseReason, + HarnessDescriptor, + HarnessEvent, + HarnessInteractionResponse, + HarnessPrompt, + HarnessPromptReceipt, + HarnessSelection, + HarnessSessionHandle, + HarnessSessionSnapshot, + HarnessSessionState, + ResumeHarnessSession, +} from '@mosaicstack/types'; +import { HARNESS_CAPABILITIES } from '@mosaicstack/types'; +import { operationError } from '../harness.registry.js'; + +export interface FakeHarnessAdapterOptions { + readonly id: string; + readonly capabilities?: readonly HarnessCapability[]; + readonly catalog?: readonly HarnessCatalogEntry[]; +} + +const FAKE_PROVIDER = 'fake-openai'; + +function defaultCatalog(harnessId: string): readonly HarnessCatalogEntry[] { + return [ + { + harnessId, + providerId: FAKE_PROVIDER, + modelId: 'fake-mini', + displayName: 'Fake Mini', + reasoningCapability: false, + inputTypes: ['text'], + authState: 'ready', + availability: 'available', + }, + { + harnessId, + providerId: FAKE_PROVIDER, + modelId: 'fake-pro', + displayName: 'Fake Pro', + reasoningCapability: true, + inputTypes: ['text', 'image'], + authState: 'ready', + availability: 'available', + }, + { + harnessId, + providerId: FAKE_PROVIDER, + modelId: 'fake-legacy', + displayName: 'Fake Legacy', + reasoningCapability: false, + inputTypes: ['text'], + authState: 'unavailable', + availability: 'unavailable', + }, + ]; +} + +function matches(entry: HarnessCatalogEntry, selection: HarnessSelection): boolean { + return ( + entry.harnessId === selection.harnessId && + entry.providerId === selection.providerId && + entry.modelId === selection.modelId + ); +} + +/** + * In-memory harness session handle used by the fake adapter and by the shared + * conformance suite. It enforces the two invariants the real adapters must also + * honor: model selection is validated against the catalog and is NEVER + * substituted, and unsupported capabilities fail with a typed error. + */ +export class FakeHarnessSessionHandle implements HarnessSessionHandle { + private state: HarnessSessionState = 'idle'; + private processId: string | undefined; + private readonly attachedClientIds = new Set(); + private readonly listeners = new Set<(event: HarnessEvent) => void>(); + + constructor( + private readonly conversationId: string, + private readonly nativeSessionId: string, + private readonly seatId: string, + private selection: HarnessSelection, + private readonly correlationId: string, + private readonly capabilities: readonly HarnessCapability[], + private readonly catalog: readonly HarnessCatalogEntry[], + ) { + this.processId = `process-${nativeSessionId}`; + } + + async snapshot(): Promise { + return { + conversationId: this.conversationId, + nativeSessionId: this.nativeSessionId, + processId: this.processId, + seatId: this.seatId, + selection: this.selection, + state: this.state, + attachedClientIds: [...this.attachedClientIds], + }; + } + + async attach(input: AttachClient): Promise { + this.attachedClientIds.add(input.clientId); + } + + async detach(clientId: string): Promise { + // Removes the browser attachment only; the process and native session persist. + this.attachedClientIds.delete(clientId); + } + + async prompt(input: HarnessPrompt & { idempotencyKey: string }): Promise { + return { + conversationId: this.conversationId, + turnId: input.turnId, + correlationId: input.correlationId, + state: 'accepted', + selection: this.selection, + }; + } + + async setModel(selection: HarnessSelection): Promise { + const entry = this.catalog.find((candidate) => matches(candidate, selection)); + if (!entry) { + // No fallback to the first catalog row: reject with the requested tuple, unchanged. + throw operationError( + 'selection_invalid', + 'The requested harness/provider/model tuple is not in the catalog.', + selection, + this.correlationId, + ); + } + if (entry.availability === 'unavailable') { + throw operationError( + 'model_unavailable', + 'The requested model is currently unavailable.', + selection, + this.correlationId, + true, + ); + } + this.selection = selection; + return this.selection; + } + + async abort(_turnId: string): Promise { + // No active turn machinery in the fake; abort is a no-op acknowledgement. + } + + async respondInteraction(_input: HarnessInteractionResponse): Promise { + if (!this.capabilities.includes('extensionUi')) { + throw operationError( + 'interaction_unsupported', + 'This harness does not support interactive responses.', + this.selection, + this.correlationId, + ); + } + } + + events(listener: (event: HarnessEvent) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + async evictProcess(_reason: HarnessCloseReason): Promise { + // Stop the process but keep the resumable native session. + this.processId = undefined; + this.state = 'evicted'; + } + + async endSession(_reason: HarnessCloseReason): Promise { + // Destructively end the native session. + this.processId = undefined; + this.state = 'ended'; + } +} + +/** + * Minimal in-memory {@link HarnessAdapter} for Slice Zero. It mints a fresh + * native session id on `create` and binds the supplied one on `resume`, so the + * two paths are observably distinct. + */ +export class FakeHarnessAdapter implements HarnessAdapter { + readonly id: string; + private readonly capabilities: readonly HarnessCapability[]; + private readonly catalogEntries: readonly HarnessCatalogEntry[]; + private createdCount = 0; + + constructor(options: FakeHarnessAdapterOptions) { + this.id = options.id; + this.capabilities = options.capabilities ?? [...HARNESS_CAPABILITIES]; + this.catalogEntries = options.catalog ?? defaultCatalog(options.id); + } + + async describe(_context: HarnessActorContext): Promise { + return { + id: this.id, + displayName: `Fake harness (${this.id})`, + capabilities: this.capabilities, + }; + } + + async catalog(_context: HarnessActorContext): Promise { + return { + harnessId: this.id, + version: '1.0.0', + fingerprint: `fake-${this.id}-${this.catalogEntries.length}`, + models: this.catalogEntries, + }; + } + + async create(input: CreateHarnessSession): Promise { + this.createdCount += 1; + const nativeSessionId = `native-${input.conversationId}-${this.createdCount}`; + return new FakeHarnessSessionHandle( + input.conversationId, + nativeSessionId, + input.context.seatId, + input.selection, + input.context.correlationId, + this.capabilities, + this.catalogEntries, + ); + } + + async resume(input: ResumeHarnessSession): Promise { + return new FakeHarnessSessionHandle( + input.conversationId, + input.nativeSessionId, + input.context.seatId, + input.selection, + input.context.correlationId, + this.capabilities, + this.catalogEntries, + ); + } +} diff --git a/apps/gateway/src/harness/testing/harness-adapter.contract.ts b/apps/gateway/src/harness/testing/harness-adapter.contract.ts new file mode 100644 index 00000000..d0769b5b --- /dev/null +++ b/apps/gateway/src/harness/testing/harness-adapter.contract.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; +import type { + HarnessActorContext, + HarnessAdapter, + HarnessCatalogEntry, + HarnessSelection, +} from '@mosaicstack/types'; +import { HarnessOperationError } from '../harness.registry.js'; + +const CONTEXT: HarnessActorContext = { + actorId: 'contract-actor', + tenantId: 'contract-tenant', + seatId: 'contract-seat', + correlationId: 'contract-correlation', +}; + +function toSelection(entry: HarnessCatalogEntry): HarnessSelection { + return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId }; +} + +function pickAvailable(models: readonly HarnessCatalogEntry[]): HarnessCatalogEntry { + const entry = models.find((candidate) => candidate.availability === 'available') ?? models[0]; + if (!entry) { + throw new Error('contract fixture requires at least one catalog model'); + } + return entry; +} + +async function captureError(run: () => Promise): Promise { + try { + await run(); + return undefined; + } catch (caught) { + return caught; + } +} + +/** + * Shared conformance suite every {@link HarnessAdapter} must pass. Slice Zero + * runs it against the fake adapter; Task 13 re-runs the identical suite against + * the native Pi adapter so both share one behavioral contract. + */ +export function runHarnessAdapterContract( + label: string, + createAdapter: () => HarnessAdapter, +): void { + describe(`harness adapter contract: ${label}`, () => { + it('mints a fresh native session on create and binds the supplied one on resume', async () => { + const adapter = createAdapter(); + const catalog = await adapter.catalog(CONTEXT); + const selection = toSelection(pickAvailable(catalog.models)); + + const created = await ( + await adapter.create({ context: CONTEXT, conversationId: 'conv-create', selection }) + ).snapshot(); + const resumed = await ( + await adapter.resume({ + context: CONTEXT, + conversationId: 'conv-resume', + nativeSessionId: 'native-supplied-1', + selection, + }) + ).snapshot(); + + expect(created.nativeSessionId).toBeTruthy(); + expect(resumed.nativeSessionId).toBe('native-supplied-1'); + expect(created.nativeSessionId).not.toBe(resumed.nativeSessionId); + expect(created.seatId).toBe(CONTEXT.seatId); + }); + + it('gives detach, evict, and end distinct effects (not aliases)', async () => { + const adapter = createAdapter(); + const catalog = await adapter.catalog(CONTEXT); + const selection = toSelection(pickAvailable(catalog.models)); + const handle = await adapter.create({ + context: CONTEXT, + conversationId: 'conv-lifecycle', + selection, + }); + + await handle.attach({ clientId: 'browser-1' }); + await handle.detach('browser-1'); + const afterDetach = await handle.snapshot(); + expect(afterDetach.attachedClientIds).toEqual([]); + expect(afterDetach.state).not.toBe('evicted'); + expect(afterDetach.state).not.toBe('ended'); + + await handle.evictProcess('idle_timeout'); + const afterEvict = await handle.snapshot(); + expect(afterEvict.state).toBe('evicted'); + // The native session survives eviction (resumable); the process does not. + expect(afterEvict.nativeSessionId).toBe(afterDetach.nativeSessionId); + expect(afterEvict.processId).toBeUndefined(); + + await handle.endSession('session_ended'); + const afterEnd = await handle.snapshot(); + expect(afterEnd.state).toBe('ended'); + // End is not an alias of evict. + expect(afterEnd.state).not.toBe(afterEvict.state); + }); + + it('never substitutes the first catalog row for an unknown selection', async () => { + const adapter = createAdapter(); + const catalog = await adapter.catalog(CONTEXT); + const firstRow = catalog.models[0]; + if (!firstRow) { + throw new Error('contract fixture requires a catalog model'); + } + const start = toSelection(pickAvailable(catalog.models)); + const handle = await adapter.create({ + context: CONTEXT, + conversationId: 'conv-nosub', + selection: start, + }); + + const bogus: HarnessSelection = { + harnessId: adapter.id, + providerId: 'contract-ghost-provider', + modelId: 'contract-ghost-model', + }; + const error = await captureError(() => handle.setModel(bogus)); + + expect(error).toBeInstanceOf(HarnessOperationError); + const dto = (error as HarnessOperationError).dto; + expect(dto.code).toBe('selection_invalid'); + expect(dto.selection).toEqual(bogus); + expect(dto.selection).not.toEqual(toSelection(firstRow)); + expect((await handle.snapshot()).selection).toEqual(start); + }); + + it('validates capability-gated interactions with a typed error, not a silent no-op', async () => { + const adapter = createAdapter(); + const descriptor = await adapter.describe(CONTEXT); + const catalog = await adapter.catalog(CONTEXT); + const selection = toSelection(pickAvailable(catalog.models)); + const handle = await adapter.create({ + context: CONTEXT, + conversationId: 'conv-interaction', + selection, + }); + + const response = { + requestId: 'interaction-1', + type: 'confirm', + accepted: true, + } as const; + + if (descriptor.capabilities.includes('extensionUi')) { + await expect(handle.respondInteraction(response)).resolves.toBeUndefined(); + } else { + const error = await captureError(() => handle.respondInteraction(response)); + expect(error).toBeInstanceOf(HarnessOperationError); + expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported'); + } + }); + }); +} diff --git a/apps/gateway/src/main.spec.ts b/apps/gateway/src/main.spec.ts new file mode 100644 index 00000000..10999917 --- /dev/null +++ b/apps/gateway/src/main.spec.ts @@ -0,0 +1,164 @@ +import 'reflect-metadata'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import * as nodeOs from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import * as nodeUrl from 'node:url'; +import type { MosaicConfig } from '@mosaicstack/config'; +import type * as MosaicStorage from '@mosaicstack/storage'; +import { describe, expect, it, vi, type MockInstance } from 'vitest'; + +// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs. +const MODULE_IMPORT_TIMEOUT_MS = 120_000; + +function snapshotProcessEnv(): Record { + return { ...process.env }; +} + +function restoreProcessEnv(snapshot: Record): 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 { + 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 { + const originalEnv = snapshotProcessEnv(); + const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-main-preflight-')); + let cwdSpy: ReturnType | undefined; + let exitSpy: MockInstance | undefined; + let consoleInfoSpy: ReturnType | 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(); + 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('@mosaicstack/storage'); + return { + ...actual, + detectAndAssertTier: vi.fn((config: MosaicConfig): Promise => { + 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 => { + 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, + ); +}); diff --git a/apps/gateway/src/main.ts b/apps/gateway/src/main.ts index f70e88a4..d1eacf5f 100644 --- a/apps/gateway/src/main.ts +++ b/apps/gateway/src/main.ts @@ -1,18 +1,5 @@ #!/usr/bin/env node -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 './env.js'; import './tracing.js'; import 'reflect-metadata'; import { NestFactory } from '@nestjs/core'; @@ -26,6 +13,7 @@ import { mountAuthHandler } from './auth/auth.controller.js'; import { mountMcpHandler } from './mcp/mcp.controller.js'; import { McpService } from './mcp/mcp.service.js'; import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage'; +import { resolveGatewayConfigPath } from './env.js'; async function bootstrap(): Promise { const logger = new Logger('Bootstrap'); @@ -37,7 +25,7 @@ async function bootstrap(): Promise { // Pre-flight: assert all external services required by the configured tier // are reachable. Runs before NestFactory.create() so failures are visible // immediately with actionable remediation hints. - const mosaicConfig = loadConfig(); + const mosaicConfig = loadConfig(resolveGatewayConfigPath()); try { await detectAndAssertTier(mosaicConfig); } catch (err) { diff --git a/apps/gateway/src/mcp-client/mcp-client.service.spec.ts b/apps/gateway/src/mcp-client/mcp-client.service.spec.ts new file mode 100644 index 00000000..531299c9 --- /dev/null +++ b/apps/gateway/src/mcp-client/mcp-client.service.spec.ts @@ -0,0 +1,44 @@ +import { Logger } from '@nestjs/common'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { McpClientService } from './mcp-client.service.js'; + +const MCP_LEAK_MARKER = 'MCP_LEAK_MARKER /srv/secret'; + +describe('McpClientService — failed connect error sanitization', () => { + const originalMcpServers = process.env['MCP_SERVERS']; + + beforeEach(() => { + process.env['MCP_SERVERS'] = JSON.stringify([ + { name: 'leaky-server', url: 'http://localhost:9999/mcp' }, + ]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (originalMcpServers === undefined) { + delete process.env['MCP_SERVERS']; + } else { + process.env['MCP_SERVERS'] = originalMcpServers; + } + }); + + it('stores a generic serverEntry.error while logging the raw exception server-side', async () => { + vi.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error(MCP_LEAK_MARKER)); + const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + + const service = new McpClientService(); + await service.onModuleInit(); + + const statuses = service.getServerStatuses(); + expect(statuses).toHaveLength(1); + expect(statuses[0]?.connected).toBe(false); + expect(statuses[0]?.error).toBe('Connection failed (see server logs).'); + expect(statuses[0]?.error).not.toContain(MCP_LEAK_MARKER); + + const loggedRawMarker = errorSpy.mock.calls.some((call) => + call.some((arg) => typeof arg === 'string' && arg.includes(MCP_LEAK_MARKER)), + ); + expect(loggedRawMarker).toBe(true); + }); +}); diff --git a/apps/gateway/src/mcp-client/mcp-client.service.ts b/apps/gateway/src/mcp-client/mcp-client.service.ts index 28910e0d..6c304f70 100644 --- a/apps/gateway/src/mcp-client/mcp-client.service.ts +++ b/apps/gateway/src/mcp-client/mcp-client.service.ts @@ -189,7 +189,7 @@ export class McpClientService implements OnModuleInit, OnModuleDestroy { ); } catch (err) { const message = err instanceof Error ? err.message : String(err); - serverEntry.error = message; + serverEntry.error = 'Connection failed (see server logs).'; serverEntry.connected = false; this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`); } diff --git a/apps/gateway/src/plugin/discord-ingress.security.spec.ts b/apps/gateway/src/plugin/discord-ingress.security.spec.ts index 3f12aa2a..eef7ceec 100644 --- a/apps/gateway/src/plugin/discord-ingress.security.spec.ts +++ b/apps/gateway/src/plugin/discord-ingress.security.spec.ts @@ -12,6 +12,10 @@ import { RuntimeProviderService } from '../agent/runtime-provider-registry.servi import { ChatGateway } from '../chat/chat.gateway.js'; import { CommandAuthorizationService } from '../commands/command-authorization.service.js'; import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js'; +import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js'; +import { EmbeddedChatRuntime } from '../chat/embedded-chat.runtime.js'; +import { HarnessChatRuntime } from '../chat/harness-chat.runtime.js'; +import { HarnessRegistry } from '../harness/harness.registry.js'; import { DiscordReplayProtector } from './discord-replay-protector.js'; const SERVICE_TOKEN = 'test-service-token'; @@ -25,6 +29,7 @@ const ENV_KEYS = [ 'DISCORD_ALLOWED_USER_IDS', 'MOSAIC_AGENT_NAME', 'MOSAIC_AGENT_CONFIG_ID', + 'CHAT_HARNESS_RUNTIME', ] as const; const savedEnv = new Map(); @@ -150,6 +155,57 @@ function createPayload(overrides: Partial = {}): DiscordI }; } +/** + * Task 5 fence (C): the Discord SEND path runs through the exclusive {@link ChatRuntimeRouter}, + * constructed here in `pi-rpc` mode with a fully-resolved runtime (`active` = harness). A verified + * Discord *service* turn must nonetheless execute on the {@link EmbeddedChatRuntime} — never the + * harness, never the routing engine — per the Q1/Q2 adjudication: the router owns a dedicated + * verified-ingress dispatch that delegates to embedded regardless of mode, with zero harness + * fallback. The gateway is given the router in the former direct-`AgentService` constructor slot. + * + * RED today: production still reads that slot as a bare `AgentService`, so `this.agentService` + * resolves to the router, `getSession(...)` is not a function, the send path throws and is caught + * (an `error` is emitted and the handler returns) BEFORE it ever reaches the embedded runtime. The + * failure is behavioural wiring — collection, DI, and `onModuleInit` all succeed. GREEN re-routes + * the verified Discord dispatch through the router into the embedded runtime, satisfying the + * preserved create/prompt assertions without weakening any control. `harnessConversations.append` + * proves the harness path is never touched even though the pi-rpc router resolved it as `active`. + * + * Correction #4 is proved behaviourally, not by naming an accessor: the verified-ingress dispatch + * is reachable only from the fully-verified `discordService` branch (the create/prompt tests below) + * and never from a browser-emittable socket event (the browser-forgery refusal test). + */ +function readyPiRpcRegistry(): HarnessRegistry { + const registry = new HarnessRegistry(); + // A registered 'pi' adapter + an available (non-sentinel) conversation service let the pi-rpc + // router resolve `active` = harness instead of failing closed at init, so these tests model the + // real hostile condition — the harness runtime IS live — rather than a degraded router. + registry.register({ id: 'pi' } as never); + return registry; +} + +function piRpcRouterFronting( + agentService: unknown, + harnessConversations: { append: ReturnType }, +): ChatRuntimeRouter { + const routerConversationServiceTripwire = { + append: () => { + throw new Error('router conversation service must not be resolved on the Discord path'); + }, + }; + const embedded = new EmbeddedChatRuntime(agentService as never); + const harness = new HarnessChatRuntime(harnessConversations as never); + const router = new ChatRuntimeRouter( + readyPiRpcRegistry(), + routerConversationServiceTripwire as never, + embedded, + harness, + 'pi-rpc', + ); + router.onModuleInit(); + return router; +} + describe('Discord ingress security', () => { it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => { const [binding] = parseDiscordInteractionBindings( @@ -433,6 +489,7 @@ describe('Discord ingress security', () => { it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => { configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002'; process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ { @@ -489,8 +546,9 @@ describe('Discord ingress security', () => { }, }; const routingEngine = { resolve: vi.fn() }; + const harnessConversations = { append: vi.fn() }; const gateway = new ChatGateway( - agentService as never, + piRpcRouterFronting(agentService, harnessConversations) as never, {} as never, brain as never, {} as never, @@ -531,6 +589,575 @@ describe('Discord ingress security', () => { expect.objectContaining({ agentConfigId: 'agent-config-orion' }), ); expect(routingEngine.resolve).not.toHaveBeenCalled(); + // Even though the pi-rpc router resolved the harness as `active`, verified Discord ingress must + // never touch it — the create path stays on the embedded runtime. + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('dispatches a verified Discord SEND once and drops a byte-identical replay with zero additional dispatch/persist/ack (Task 5 G4)', async () => { + configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + ]); + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + agentConfigId: 'agent-config-nova', + agentName: 'Nova', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const createSession = vi.fn().mockResolvedValue(session); + const prompt = vi.fn().mockResolvedValue(undefined); + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession, + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt, + }; + const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' }); + const brain = { + agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) }, + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const harnessConversations = { append: vi.fn() }; + const gateway = new ChatGateway( + piRpcRouterFronting(agentService, harnessConversations) as never, + {} as never, + brain as never, + {} as never, + {} as never, + { resolve: vi.fn() } as never, + ); + const client = { + id: 'discord-client-replay', + data: { discordService: true }, + emit: vi.fn(), + }; + const ackCount = (): number => + client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length; + + // One fully-valid signed envelope; the replay reuses the SAME object (same messageId). + const envelope = ingressEnvelope('verified once', 'discord-replay-001', { + conversationId: 'Nova:discord:channel-001', + }); + + // First delivery: the verified-Discord SEND runs the full embedded dispatch exactly once. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + + // Byte-identical replay: the messageId is already claimed, so resolveDiscordIngress returns + // null and the SEND handler bails before dispatch/persist/ack. Every effect stays at exactly one. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + // The harness runtime is never touched on either delivery. + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('a verified SEND that fails the configured service identity consumes no replay claim, so a corrected byte-identical retry dispatches/persists/acks exactly once and a later duplicate stays fail-closed (Task 5 item 4 — claim ordering)', async () => { + configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + ]); + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + agentConfigId: 'agent-config-nova', + agentName: 'Nova', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const createSession = vi.fn().mockResolvedValue(session); + const prompt = vi.fn().mockResolvedValue(undefined); + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession, + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt, + }; + const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' }); + const brain = { + agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) }, + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const harnessConversations = { append: vi.fn() }; + const gateway = new ChatGateway( + piRpcRouterFronting(agentService, harnessConversations) as never, + {} as never, + brain as never, + {} as never, + {} as never, + { resolve: vi.fn() } as never, + ); + const client = { + id: 'discord-client-claim-ordering', + data: { discordService: true }, + emit: vi.fn(), + }; + const ackCount = (): number => + client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length; + + // A single fully-valid signed envelope, reused byte-for-byte across all three deliveries. + const envelope = ingressEnvelope('verified once with late identity', 'discord-order-001', { + conversationId: 'Nova:discord:channel-001', + }); + + // (1) Configured service identity is MISSING. The envelope is validly signed and passes the + // binding + route checks, but the SEND must refuse at the identity gate BEFORE any claim + // or effect. If the claim fires ahead of that gate, this delivery silently burns the + // replay claim for `discord-order-001` even though nothing dispatched. + delete process.env['DISCORD_SERVICE_USER_ID']; + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(0); + expect(prompt).toHaveBeenCalledTimes(0); + expect(addMessage).toHaveBeenCalledTimes(0); + expect(ackCount()).toBe(0); + + // (2) Identity is now configured; the operator resends the SAME envelope byte-for-byte. Because + // step (1) consumed no claim, this corrected retry claims once and runs the full embedded + // dispatch exactly once. (Under the pre-fix ordering the claim was already spent in step (1), + // so this retry is dropped as a replay and never dispatches — the RED this test drives.) + process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service'; + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + + // (3) A genuine duplicate after a committed turn stays fail-closed: the claim taken in step (2) + // blocks it, so every effect remains at exactly one. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('a verified SEND whose configured agent record fails reconciliation consumes no replay claim, so a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3)', async () => { + configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + ]); + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + agentConfigId: 'agent-config-nova', + agentName: 'Nova', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const createSession = vi.fn().mockResolvedValue(session); + const prompt = vi.fn().mockResolvedValue(undefined); + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession, + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt, + }; + const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' }); + // The durable agent record does not reconcile on the first delivery (its name no longer matches + // the verified binding's instance id), then reconciles cleanly on the corrected retry. + const findAgent = vi + .fn() + .mockResolvedValueOnce({ id: 'agent-config-nova', name: 'Renamed-Away' }) + .mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' }); + const brain = { + agents: { findById: findAgent }, + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const harnessConversations = { append: vi.fn() }; + const gateway = new ChatGateway( + piRpcRouterFronting(agentService, harnessConversations) as never, + {} as never, + brain as never, + {} as never, + {} as never, + { resolve: vi.fn() } as never, + ); + const client = { + id: 'discord-client-reconcile', + data: { discordService: true }, + emit: vi.fn(), + }; + const ackCount = (): number => + client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length; + + const envelope = ingressEnvelope( + 'verified once with stale agent record', + 'discord-reconcile-001', + { + conversationId: 'Nova:discord:channel-001', + }, + ); + + // (1) The configured-agent reconcile runs BEFORE the replay claim. A mismatch refuses the turn + // and, crucially, consumes no claim for discord-reconcile-001 — nothing dispatches. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(0); + expect(prompt).toHaveBeenCalledTimes(0); + expect(addMessage).toHaveBeenCalledTimes(0); + expect(ackCount()).toBe(0); + + // (2) The record now reconciles; because step (1) took no claim, this byte-identical retry claims + // once and runs the full embedded dispatch exactly once. (Pre-fix, the claim was spent ahead + // of the reconcile in step (1), so this retry was dropped as a replay — the RED this drives.) + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + + // (3) A genuine duplicate after the committed turn stays fail-closed. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('a verified SEND refuses to reuse a same-scope embedded session minted under a different configured identity, with zero prompt/persist/ack (Task 5 finding 3)', async () => { + configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + ]); + // A live session already exists for this conversation/scope, but it was minted under a DIFFERENT + // configured agent (Orion). The verified binding reconciles to Nova, so reusing this session would + // execute one agent's turn under another agent's verified label — the reuse guard must refuse it. + const foreignIdentitySession = { + provider: 'configured-provider', + modelId: 'configured-model', + agentConfigId: 'agent-config-orion', + agentName: 'Orion', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const prompt = vi.fn().mockResolvedValue(undefined); + const createSession = vi.fn().mockResolvedValue(foreignIdentitySession); + const agentService = { + getSession: vi.fn().mockReturnValue(foreignIdentitySession), + createSession, + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt, + }; + const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' }); + const brain = { + agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) }, + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const harnessConversations = { append: vi.fn() }; + const gateway = new ChatGateway( + piRpcRouterFronting(agentService, harnessConversations) as never, + {} as never, + brain as never, + {} as never, + {} as never, + { resolve: vi.fn() } as never, + ); + const client = { + id: 'discord-client-identity-swap', + data: { discordService: true }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + ingressEnvelope('reuse under a different identity', 'discord-identity-swap-001', { + conversationId: 'Nova:discord:channel-001', + }), + ); + + // Refused at the embedded reuse guard: no prompt, no persist, no ack — only a typed refusal. + expect(prompt).not.toHaveBeenCalled(); + expect(addMessage).not.toHaveBeenCalled(); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(client.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }), + ); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('a verified SEND whose configured agent record resolves under a different id fails reconciliation, consumes no replay claim, and a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3 — id axis)', async () => { + configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + ]); + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + agentConfigId: 'agent-config-nova', + agentName: 'Nova', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const createSession = vi.fn().mockResolvedValue(session); + const prompt = vi.fn().mockResolvedValue(undefined); + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession, + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt, + }; + const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' }); + // The name matches the verified binding, but the record's own id is a DIFFERENT agent config — + // an aliased/substituted lookup. Exact-id reconciliation must refuse it on the first delivery, + // then admit the corrected record whose id matches the binding. + const findAgent = vi + .fn() + .mockResolvedValueOnce({ id: 'agent-config-elsewhere', name: 'Nova' }) + .mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' }); + const brain = { + agents: { findById: findAgent }, + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const harnessConversations = { append: vi.fn() }; + const gateway = new ChatGateway( + piRpcRouterFronting(agentService, harnessConversations) as never, + {} as never, + brain as never, + {} as never, + {} as never, + { resolve: vi.fn() } as never, + ); + const client = { + id: 'discord-client-reconcile-id', + data: { discordService: true }, + emit: vi.fn(), + }; + const ackCount = (): number => + client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length; + + const envelope = ingressEnvelope( + 'verified once with aliased agent id', + 'discord-reconcile-id-001', + { + conversationId: 'Nova:discord:channel-001', + }, + ); + + // (1) The record's id differs from the binding's agentConfigId. Exact-id reconcile refuses the + // turn BEFORE the replay claim, so nothing dispatches and the claim stays available. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(0); + expect(prompt).toHaveBeenCalledTimes(0); + expect(addMessage).toHaveBeenCalledTimes(0); + expect(ackCount()).toBe(0); + + // (2) The record now reconciles on both id and name; because step (1) took no claim, this + // byte-identical retry claims once and runs the full embedded dispatch exactly once. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + + // (3) A genuine duplicate after the committed turn stays fail-closed. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('a verified SEND refuses a freshly minted same-scope session whose identity differs from the reconciled configured agent, with zero prompt/persist/ack (Task 5 finding 3 — post-create)', async () => { + configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + ]); + // No live session exists for this scope, so the runtime MINTS one — but createSession returns a + // session carrying a DIFFERENT configured identity (Orion) than the reconciled binding (Nova). + // The post-create identity recheck must refuse it rather than dispatch one agent's turn under + // another agent's verified label. (The existing reuse test covers the getSession path; this + // covers the createSession path scrappy flagged as unvalidated.) + const mintedForeignSession = { + provider: 'configured-provider', + modelId: 'configured-model', + agentConfigId: 'agent-config-orion', + agentName: 'Orion', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const prompt = vi.fn().mockResolvedValue(undefined); + const createSession = vi.fn().mockResolvedValue(mintedForeignSession); + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession, + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt, + }; + const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' }); + const brain = { + agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) }, + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const harnessConversations = { append: vi.fn() }; + const gateway = new ChatGateway( + piRpcRouterFronting(agentService, harnessConversations) as never, + {} as never, + brain as never, + {} as never, + {} as never, + { resolve: vi.fn() } as never, + ); + const client = { + id: 'discord-client-postcreate-mismatch', + data: { discordService: true }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + ingressEnvelope('mint under a different identity', 'discord-postcreate-001', { + conversationId: 'Nova:discord:channel-001', + }), + ); + + // The freshly minted session failed the post-create identity recheck: refused with a typed + // error, no prompt, no persist, no ack. + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).not.toHaveBeenCalled(); + expect(addMessage).not.toHaveBeenCalled(); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(client.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }), + ); + expect(harnessConversations.append).not.toHaveBeenCalled(); }); it('retains validated persisted attachments in resumed conversation history', async () => { @@ -593,11 +1220,16 @@ describe('Discord ingress security', () => { it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => { configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; const prompt = vi.fn().mockResolvedValue(undefined); - const addMessage = vi.fn().mockResolvedValue(undefined); + const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' }); const session = { provider: 'test-provider', modelId: 'test-model', + // The reused embedded session carries the SAME reconciled identity as the verified binding, + // so the finding-3 session-reuse guard admits it rather than refusing an identity swap. + agentConfigId: 'agent-config-nova', + agentName: 'Nova', piSession: { thinkingLevel: 'medium', getAvailableThinkingLevels: (): string[] => ['medium'], @@ -611,6 +1243,7 @@ describe('Discord ingress security', () => { prompt, }; const brain = { + agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) }, conversations: { findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), create: vi.fn().mockResolvedValue(undefined), @@ -618,8 +1251,9 @@ describe('Discord ingress security', () => { addMessage, }, }; + const harnessConversations = { append: vi.fn() }; const gateway = new ChatGateway( - agentService as never, + piRpcRouterFronting(agentService, harnessConversations) as never, {} as never, brain as never, {} as never, @@ -667,6 +1301,66 @@ describe('Discord ingress security', () => { }), 'discord-service', ); + // The verified Discord prompt dispatch stays on the embedded runtime; the pi-rpc harness that + // the router resolved as `active` is never reached. + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('refuses a browser-forged Discord ingress envelope in pi-rpc with a fixed typed refusal and zero dispatch', async () => { + // Correction #2 + #4 (behavioural). A browser socket is never `discordService` (that flag is + // set only on a valid service-token handshake), so it cannot forge the trusted Discord path by + // emitting an envelope-shaped payload. In pi-rpc it must receive a FIXED TYPED refusal + // (`runtime_unsupported`, the same typed code the sibling harness-fence uses) and reach neither + // the forced Discord service scope, the verified Discord operation, the embedded runtime, nor + // the harness. There is no dedicated socket event for verified ingress — the only ingress + // surface is the generic `message` handler, and a non-service client is refused there. + // + // RED today: a non-service client emitting an envelope-shaped payload falls to the browser + // branch, fails the chat-message shape check, and is dropped SILENTLY (a warn + return) with no + // typed refusal emitted — so the refusal assertion fails. Collection and construction succeed; + // the gap is behavioural. GREEN emits the fixed typed refusal before any dispatch. + configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn(), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + const routingEngine = { resolve: vi.fn() }; + const gateway = new ChatGateway( + piRpcRouterFronting(agentService, harnessConversations) as never, + {} as never, + { conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never, + {} as never, + {} as never, + routingEngine as never, + ); + const client = { + id: 'browser-forging-discord', + data: { discordService: false }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + ingressEnvelope('forged from a browser', 'browser-forgery-001', { + conversationId: 'Nova:discord:channel-001', + }), + ); + + const refusal = client.emit.mock.calls.find( + ([, payload]) => (payload as { code?: string } | undefined)?.code === 'runtime_unsupported', + ); + expect(refusal).toBeDefined(); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(agentService.createSession).not.toHaveBeenCalled(); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(harnessConversations.append).not.toHaveBeenCalled(); + expect(routingEngine.resolve).not.toHaveBeenCalled(); }); it('accepts a thread message through its allowed bound parent channel', () => { diff --git a/apps/gateway/src/reload/reload.service.spec.ts b/apps/gateway/src/reload/reload.service.spec.ts index 25696941..63a89781 100644 --- a/apps/gateway/src/reload/reload.service.spec.ts +++ b/apps/gateway/src/reload/reload.service.spec.ts @@ -1,5 +1,8 @@ +import { Logger } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; +import type { SlashCommandPayload, SystemReloadPayload } from '@mosaicstack/types'; import { ReloadService } from './reload.service.js'; +import { CommandExecutorService } from '../commands/command-executor.service.js'; function createMockCommandRegistry() { return { @@ -104,3 +107,85 @@ describe('ReloadService', () => { expect(() => service.registerPlugin('my-plugin', {})).not.toThrow(); }); }); + +describe('ReloadService — /reload command sanitizes plugin errors', () => { + it('generic per-plugin errors reach the chat surface while raw markers stay server-side only', async () => { + const registry = { + getManifest: vi.fn().mockReturnValue({ + version: 1, + commands: [ + { name: 'reload', aliases: [], scope: 'core', execution: 'socket', available: true }, + ], + skills: [], + }), + }; + const reloadService = new ReloadService(registry as never); + + const RELOAD_LOAD_LEAK_MARKER = 'RELOAD_LOAD_LEAK_MARKER /srv/load-secret'; + const RELOAD_UNLOAD_LEAK_MARKER = 'RELOAD_UNLOAD_LEAK_MARKER /srv/unload-secret'; + + reloadService.registerPlugin('unload-fails', { + pluginName: 'unload-fails', + onLoad: vi.fn().mockResolvedValue(undefined), + onUnload: vi.fn().mockRejectedValue(new Error(RELOAD_UNLOAD_LEAK_MARKER)), + }); + reloadService.registerPlugin('load-fails', { + pluginName: 'load-fails', + onLoad: vi.fn().mockRejectedValue(new Error(RELOAD_LOAD_LEAK_MARKER)), + onUnload: vi.fn().mockResolvedValue(undefined), + }); + + const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + const broadcastReload = vi.fn(); + const mockChatGateway = { broadcastReload }; + const mockAgentService = { getSession: vi.fn(), applyAgentConfig: vi.fn() }; + const mockSystemOverride = { set: vi.fn(), get: vi.fn(), clear: vi.fn() }; + const mockSessionGC = { sweepOrphans: vi.fn() }; + const mockBrain = { agents: { findByName: vi.fn(), findById: vi.fn(), create: vi.fn() } }; + + const mockMcpClient = { + getServerStatuses: vi.fn(() => []), + getToolDefinitions: vi.fn(() => []), + reconnectServer: vi.fn().mockResolvedValue(undefined), + }; + + const executor = new CommandExecutorService( + registry as never, + mockAgentService as never, + mockSystemOverride as never, + mockSessionGC as never, + null, + mockBrain as never, + reloadService, + mockChatGateway as never, + mockMcpClient as never, + ); + + const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' }; + const result = await executor.execute(payload, { userId: 'user-1', tenantId: 'user-1' }); + + expect(result.success).toBe(true); + expect(result.message).toContain('unload-fails: unload failed (internal error)'); + expect(result.message).toContain('load-fails: load failed (internal error)'); + expect(result.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER); + expect(result.message).not.toContain(RELOAD_LOAD_LEAK_MARKER); + + expect(broadcastReload).toHaveBeenCalledOnce(); + const broadcastPayload = broadcastReload.mock.calls[0]?.[0] as SystemReloadPayload; + expect(broadcastPayload.message).toContain('unload-fails: unload failed (internal error)'); + expect(broadcastPayload.message).toContain('load-fails: load failed (internal error)'); + expect(broadcastPayload.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER); + expect(broadcastPayload.message).not.toContain(RELOAD_LOAD_LEAK_MARKER); + + const loggedUnloadMarker = errorSpy.mock.calls.some((call) => + call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_UNLOAD_LEAK_MARKER)), + ); + const loggedLoadMarker = errorSpy.mock.calls.some((call) => + call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_LOAD_LEAK_MARKER)), + ); + expect(loggedUnloadMarker).toBe(true); + expect(loggedLoadMarker).toBe(true); + + errorSpy.mockRestore(); + }); +}); diff --git a/apps/gateway/src/reload/reload.service.ts b/apps/gateway/src/reload/reload.service.ts index 1d84e27b..b6b1f269 100644 --- a/apps/gateway/src/reload/reload.service.ts +++ b/apps/gateway/src/reload/reload.service.ts @@ -58,7 +58,8 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd await plugin.onUnload(); reloaded.push(name); } catch (err) { - errors.push(`${name}: unload failed — ${err}`); + this.logger.error(`Plugin "${name}" failed during onUnload: ${err}`); + errors.push(`${name}: unload failed (internal error)`); } } } @@ -69,7 +70,8 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd try { await plugin.onLoad(); } catch (err) { - errors.push(`${name}: load failed — ${err}`); + this.logger.error(`Plugin "${name}" failed during onLoad: ${err}`); + errors.push(`${name}: load failed (internal error)`); } } } diff --git a/apps/gateway/src/workspace/workspace.controller.spec.ts b/apps/gateway/src/workspace/workspace.controller.spec.ts new file mode 100644 index 00000000..c1a49e86 --- /dev/null +++ b/apps/gateway/src/workspace/workspace.controller.spec.ts @@ -0,0 +1,104 @@ +import 'reflect-metadata'; +import { + type CanActivate, + type ExecutionContext, + type INestApplication, + ValidationPipe, +} from '@nestjs/common'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { ProjectBootstrapService } from './project-bootstrap.service.js'; +import { WorkspaceController } from './workspace.controller.js'; + +const bootstrapMock = vi.fn(() => + Promise.resolve({ + projectId: 'project-1', + workspacePath: '/opt/mosaic/.workspaces/users/user-1/project-1', + }), +); + +const authGuard: CanActivate = { + canActivate(context: ExecutionContext): boolean { + const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>(); + requestContext.user = { id: 'user-1' }; + return true; + }, +}; + +describe('POST /api/workspaces repoUrl validation', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [WorkspaceController], + providers: [ + { + provide: ProjectBootstrapService, + useValue: { bootstrap: bootstrapMock }, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue(authGuard) + .compile(); + + app = moduleRef.createNestApplication(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', 'git@example.com: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, + }); + }); +}); diff --git a/apps/gateway/src/workspace/workspace.controller.ts b/apps/gateway/src/workspace/workspace.controller.ts index 02bccdd2..769b0053 100644 --- a/apps/gateway/src/workspace/workspace.controller.ts +++ b/apps/gateway/src/workspace/workspace.controller.ts @@ -1,7 +1,11 @@ import { Body, Controller, Post, UseGuards } from '@nestjs/common'; import { AuthGuard } from '../auth/auth.guard.js'; import { CurrentUser } from '../auth/current-user.decorator.js'; -import { ProjectBootstrapService } from './project-bootstrap.service.js'; +import { + ProjectBootstrapService, + type BootstrapProjectResult, +} from './project-bootstrap.service.js'; +import { CreateWorkspaceDto } from './workspace.dto.js'; @Controller('api/workspaces') @UseGuards(AuthGuard) @@ -11,20 +15,14 @@ export class WorkspaceController { @Post() async create( @CurrentUser() user: { id: string }, - @Body() - body: { - name: string; - description?: string; - teamId?: string; - repoUrl?: string; - }, - ) { + @Body() dto: CreateWorkspaceDto, + ): Promise { return this.bootstrap.bootstrap({ - name: body.name, - description: body.description, + name: dto.name, + description: dto.description, userId: user.id, - teamId: body.teamId, - repoUrl: body.repoUrl, + teamId: dto.teamId, + repoUrl: dto.repoUrl, }); } } diff --git a/apps/gateway/src/workspace/workspace.dto.ts b/apps/gateway/src/workspace/workspace.dto.ts new file mode 100644 index 00000000..4ccce45e --- /dev/null +++ b/apps/gateway/src/workspace/workspace.dto.ts @@ -0,0 +1,33 @@ +import { IsOptional, IsString, IsUrl, Matches, MaxLength } from 'class-validator'; + +export class CreateWorkspaceDto { + @IsString() + @MaxLength(255) + name!: string; + + @IsOptional() + @IsString() + @MaxLength(10_000) + description?: string; + + @IsOptional() + @IsString() + teamId?: string; + + @IsOptional() + @IsString() + @Matches(/^(?:https|git):\/\//i, { + message: 'repoUrl must be a valid https:// or git:// URL', + }) + @IsUrl( + { + protocols: ['https', 'git'], + require_host: true, + require_protocol: true, + require_tld: false, + require_valid_protocol: true, + }, + { message: 'repoUrl must be a valid https:// or git:// URL' }, + ) + repoUrl?: string; +} diff --git a/apps/gateway/src/workspace/workspace.service.spec.ts b/apps/gateway/src/workspace/workspace.service.spec.ts index c7de3c1d..52575b20 100644 --- a/apps/gateway/src/workspace/workspace.service.spec.ts +++ b/apps/gateway/src/workspace/workspace.service.spec.ts @@ -1,11 +1,33 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { WorkspaceService } from './workspace.service.js'; +import { BadRequestException } from '@nestjs/common'; +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'; + +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(), +})); + +vi.mock('node:child_process', () => ({ + execFile: execFileMock, +})); describe('WorkspaceService', () => { let service: WorkspaceService; beforeEach(() => { + execFileMock.mockReset(); + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback(null, '', ''); + }); service = new WorkspaceService(); }); @@ -76,4 +98,69 @@ describe('WorkspaceService', () => { } }); }); + + describe('create', () => { + const project = { + id: 'project-1', + ownerType: 'user', + userId: 'user-1', + teamId: null, + } as const; + + let originalRoot: string | undefined; + let temporaryRoot: string; + + beforeEach(async () => { + originalRoot = process.env['MOSAIC_ROOT']; + temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mosaic-workspace-')); + process.env['MOSAIC_ROOT'] = temporaryRoot; + service = new WorkspaceService(); + }); + + afterEach(async () => { + if (originalRoot === undefined) { + delete process.env['MOSAIC_ROOT']; + } else { + process.env['MOSAIC_ROOT'] = originalRoot; + } + await fs.rm(temporaryRoot, { recursive: true, force: true }); + }); + + it.each([ + ['a leading-dash URL', '--upload-pack=sh -c id'], + ['an ext remote helper', 'ext::sh -c id'], + ['a file URL', 'file:///tmp/repository'], + ['an unparseable value', 'not a url'], + ['an SSH shorthand', 'git@example.com: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), + ); + }); + }); }); diff --git a/apps/gateway/src/workspace/workspace.service.ts b/apps/gateway/src/workspace/workspace.service.ts index a253ca26..01ac795f 100644 --- a/apps/gateway/src/workspace/workspace.service.ts +++ b/apps/gateway/src/workspace/workspace.service.ts @@ -1,10 +1,30 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import fs from 'node:fs/promises'; import path from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; 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 { id: string; @@ -39,14 +59,32 @@ export class WorkspaceService { * If repoUrl is provided, clone instead of init. */ async create(project: WorkspaceProject, repoUrl?: string): Promise { + if (repoUrl !== undefined) { + assertAllowedRepositoryUrl(repoUrl); + } + const workspacePath = this.resolvePath(project); // Create directory await fs.mkdir(workspacePath, { recursive: true }); - if (repoUrl) { - // Clone existing repo - await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath }); + if (repoUrl !== undefined) { + // Clone existing repo. Defense in depth keeps dangerous local helpers + // disabled and terminates option parsing before positional arguments. + await execFileAsync( + 'git', + [ + '-c', + 'protocol.ext.allow=never', + '-c', + 'protocol.file.allow=never', + 'clone', + '--', + repoUrl, + '.', + ], + { cwd: workspacePath }, + ); this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`); } else { // Init new git repo diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 00000000..0ace2aa8 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,30 @@ + + + + + + Mosaic + + + + + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json index 51ed3daa..93ab92a1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,21 +4,25 @@ "private": true, "scripts": { "build": "node ../../scripts/build-web.mjs", - "dev": "next dev", + "build:vite": "vite build", + "dev": "next dev -p 3101", + "dev:vite": "vite", "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", "test:e2e": "playwright test", - "start": "next start" + "start": "next start -p 3101" }, "dependencies": { "@mosaicstack/design-tokens": "workspace:^", + "@mosaicstack/types": "workspace:^", "better-auth": "^1.5.5", "clsx": "^2.1.0", "next": "^16.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", + "react-router-dom": "^7.18.2", "socket.io-client": "^4.8.0", "tailwind-merge": "^3.5.0" }, @@ -28,9 +32,11 @@ "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^6.0.5", "jsdom": "^29.0.0", "tailwindcss": "^4.0.0", "typescript": "^5.8.0", - "vitest": "^2.0.0" + "vite": "^8.2.1", + "vitest": "^3.2.7" } } diff --git a/apps/web/src/app/auth/provider/[provider]/page.tsx b/apps/web/src/app/auth/provider/[provider]/page.tsx index b4eebce4..d2771843 100644 --- a/apps/web/src/app/auth/provider/[provider]/page.tsx +++ b/apps/web/src/app/auth/provider/[provider]/page.tsx @@ -3,41 +3,56 @@ import Link from 'next/link'; import { useEffect, useState } from 'react'; import { useParams, useSearchParams } from 'next/navigation'; +import { api } from '@/lib/api'; +import { resolveAuthCallbackURL } from '@/lib/auth-redirect'; import { signIn } from '@/lib/auth-client'; -import { getSsoProvider } from '@/lib/sso-providers'; +import type { SsoProviderDiscovery } from '@/lib/sso'; export default function AuthProviderRedirectPage(): React.ReactElement { const params = useParams<{ provider: string }>(); const searchParams = useSearchParams(); const providerId = typeof params.provider === 'string' ? params.provider : ''; - const provider = getSsoProvider(providerId); - const callbackURL = searchParams.get('callbackURL') ?? '/chat'; + const requestedCallbackURL = searchParams.get('callbackURL'); + const [providerName, setProviderName] = useState(null); const [error, setError] = useState(null); useEffect(() => { - const currentProvider = provider; - - if (!currentProvider) { - setError('Unknown SSO provider.'); - return; - } - - if (!currentProvider.enabled) { - setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`); - return; - } - - const activeProvider = currentProvider; let cancelled = false; async function redirectToProvider(): Promise { - const result = await signIn.oauth2({ - providerId: activeProvider.id, - callbackURL, - }); + try { + const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin); + const providers = await api('/api/sso/providers'); + if (cancelled) return; - if (!cancelled && result?.error) { - setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`); + 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.'); + } } } @@ -46,19 +61,22 @@ export default function AuthProviderRedirectPage(): React.ReactElement { return () => { cancelled = true; }; - }, [callbackURL, provider]); + }, [providerId, requestedCallbackURL]); return (

Single sign-on

- {provider - ? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...` + {providerName + ? `Redirecting you to ${providerName}...` : 'Preparing your sign-in request...'}

{error ? ( -
+

{error}

void; + readonly retryLabel?: string; +} + +function RetryButton({ onRetry, retryLabel }: RetryableNoticeProps): ReactElement | null { + if (!onRetry) return null; + return ( + + ); +} + +export interface UnavailableDataNoticeProps extends RetryableNoticeProps { + /** What is unavailable, e.g. "Tasks". */ + readonly title: string; + /** Optional underlying failure detail (network message, invalidation reason). */ + readonly detail?: string | null; +} + +/** Explicit `unavailable` state. Never renders as an empty healthy collection. */ +export function UnavailableDataNotice({ + title, + detail, + onRetry, + retryLabel, +}: UnavailableDataNoticeProps): ReactElement { + return ( +
+

{title} are unavailable

+

+ This is not an empty result — the data could not be verified from the gateway. + {detail ? ` ${detail}` : ''} +

+ +
+ ); +} + +export interface StaleDataNoticeProps extends RetryableNoticeProps { + /** Provenance of the last-known snapshot being displayed. */ + readonly label: FreshnessLabel; +} + +/** + * Situational-awareness banner for `stale` data: last-known data may render, + * but visibly labeled with source identity, snapshot version, and age. + */ +export function StaleDataNotice({ + label, + onRetry, + retryLabel, +}: StaleDataNoticeProps): ReactElement { + return ( +
+

Showing last-known data — it may be out of date

+

+ Source {label.source} · snapshot v{label.version} · fetched{' '} + {formatAge(label.fetchedAt, Date.now())}. Verdicts derived from this data are unknown and + changes are disabled until it is revalidated. +

+ +
+ ); +} + +export interface PartialDataNoticeProps extends RetryableNoticeProps { + /** Display names of the sections whose collections are unavailable. */ + readonly missing: readonly string[]; +} + +/** `partial` surface banner: verified parts render, missing parts are explicit. */ +export function PartialDataNotice({ + missing, + onRetry, + retryLabel, +}: PartialDataNoticeProps): ReactElement { + return ( +
+

Some data could not be loaded

+

+ {missing.join(', ')} {missing.length === 1 ? 'is' : 'are'} unavailable — sections below show + an explicit unavailable state instead of an empty list. Derived verdicts remain unknown + until every collection is revalidated. +

+ +
+ ); +} diff --git a/apps/web/src/lib/api.spec.ts b/apps/web/src/lib/api.spec.ts new file mode 100644 index 00000000..51adecee --- /dev/null +++ b/apps/web/src/lib/api.spec.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { api } from './api'; + +describe('api', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('fetches the supplied relative path with credentials and a JSON body', async () => { + const fetchMock = vi.fn(); + 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(); + 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, + }); + }); +}); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c9d270f6..df092e26 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,5 +1,3 @@ -const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242'; - export interface ApiRequestInit extends Omit { body?: unknown; } @@ -25,7 +23,7 @@ export async function api(path: string, init?: ApiRequestInit): Promise { headers['Content-Type'] = 'application/json'; } - const res = await fetch(`${GATEWAY_URL}${path}`, { + const res = await fetch(path, { credentials: 'include', ...rest, headers, diff --git a/apps/web/src/lib/auth-client.spec.ts b/apps/web/src/lib/auth-client.spec.ts new file mode 100644 index 00000000..cbf87f29 --- /dev/null +++ b/apps/web/src/lib/auth-client.spec.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +describe('auth client origin contract', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it('uses the same-origin BetterAuth mount at /api/auth', async () => { + const fetchMock = vi.fn(); + 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'); + }); +}); diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index f04fe567..d9f94887 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -1,8 +1,9 @@ import { createAuthClient } from 'better-auth/react'; 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({ - baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242', plugins: [adminClient(), genericOAuthClient()], }); diff --git a/apps/web/src/lib/auth-redirect.spec.ts b/apps/web/src/lib/auth-redirect.spec.ts new file mode 100644 index 00000000..2dca7ff8 --- /dev/null +++ b/apps/web/src/lib/auth-redirect.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { resolveAuthCallbackURL } from './auth-redirect'; + +const CURRENT_ORIGIN = 'https://mosaic.example'; + +describe('resolveAuthCallbackURL', () => { + it('preserves a canonical same-origin path with search and hash', () => { + expect(resolveAuthCallbackURL('/projects?view=active#current', CURRENT_ORIGIN)).toBe( + '/projects?view=active#current', + ); + }); + + it.each([ + null, + 'chat', + '//evil.example', + '/..//evil.com', + '/..//evil.com/x', + '/./..//evil.com', + '/../..//evil.com', + '/foo/..//evil.com', + '/\\evil.example', + '/\n//evil.example', + '/\r//evil.example', + '/\t//evil.example', + 'https://evil.example/phish', + ])('falls back to chat for an unsafe callback target %#', (candidate) => { + expect(resolveAuthCallbackURL(candidate, CURRENT_ORIGIN)).toBe('/chat'); + }); +}); diff --git a/apps/web/src/lib/auth-redirect.ts b/apps/web/src/lib/auth-redirect.ts new file mode 100644 index 00000000..e9725ac0 --- /dev/null +++ b/apps/web/src/lib/auth-redirect.ts @@ -0,0 +1,23 @@ +const DEFAULT_AUTH_CALLBACK_URL = '/chat'; + +/** + * Return a canonical same-origin path for post-auth navigation. + * + * Parsing before comparing origins rejects protocol-relative URLs, backslash + * variants, and control characters that the WHATWG parser normalizes away. + */ +export function resolveAuthCallbackURL(candidate: string | null, currentOrigin: string): string { + if (!candidate?.startsWith('/')) return DEFAULT_AUTH_CALLBACK_URL; + + try { + const expectedOrigin = new URL(currentOrigin).origin; + const resolved = new URL(candidate, expectedOrigin); + if (resolved.origin !== expectedOrigin || resolved.pathname.startsWith('//')) { + return DEFAULT_AUTH_CALLBACK_URL; + } + + return `${resolved.pathname}${resolved.search}${resolved.hash}`; + } catch { + return DEFAULT_AUTH_CALLBACK_URL; + } +} diff --git a/apps/web/src/lib/chat-contract.ts b/apps/web/src/lib/chat-contract.ts new file mode 100644 index 00000000..e6763123 --- /dev/null +++ b/apps/web/src/lib/chat-contract.ts @@ -0,0 +1,71 @@ +// Centralizes the type-only import of the shared `/chat` Socket.IO contract from +// the public `@mosaicstack/types` package. `import type` is erased at compile +// time, so this introduces no runtime dependency — it only reuses the exact +// payload shapes instead of redeclaring them. +import type { Socket } from 'socket.io-client'; +import type { + AbortPayload, + AgentEndPayload, + AgentStartPayload, + AgentTextPayload, + AgentThinkingPayload, + ChatMessagePayload, + ChatSendCapabilityPayload, + ChatSendProtocol, + ClientToServerEvents, + CommandDef, + CommandManifest, + CommandManifestPayload, + ErrorPayload, + HarnessSelection, + HarnessTurnAckPayload, + HarnessTurnSendPayload, + MessageAckPayload, + RoutingDecisionInfo, + ServerToClientEvents, + SessionInfoPayload, + SessionUsagePayload, + SetThinkingPayload, + SkillCommandDef, + SlashCommandApprovalResultPayload, + SlashCommandPayload, + SlashCommandResultPayload, + SystemReloadPayload, + ToolEndPayload, + ToolStartPayload, +} from '@mosaicstack/types'; + +export type { + AbortPayload, + AgentEndPayload, + AgentStartPayload, + AgentTextPayload, + AgentThinkingPayload, + ChatMessagePayload, + ChatSendCapabilityPayload, + ChatSendProtocol, + ClientToServerEvents, + CommandDef, + CommandManifest, + CommandManifestPayload, + ErrorPayload, + HarnessSelection, + HarnessTurnAckPayload, + HarnessTurnSendPayload, + MessageAckPayload, + RoutingDecisionInfo, + ServerToClientEvents, + SessionInfoPayload, + SessionUsagePayload, + SetThinkingPayload, + SkillCommandDef, + SlashCommandApprovalResultPayload, + SlashCommandPayload, + SlashCommandResultPayload, + SystemReloadPayload, + ToolEndPayload, + ToolStartPayload, +}; + +/** The `/chat` namespace socket, narrowed to the exact typed event contract. */ +export type ChatSocket = Socket; diff --git a/apps/web/src/lib/freshness/model.spec.ts b/apps/web/src/lib/freshness/model.spec.ts new file mode 100644 index 00000000..9ccf361c --- /dev/null +++ b/apps/web/src/lib/freshness/model.spec.ts @@ -0,0 +1,324 @@ +import { describe, expect, it } from 'vitest'; +import type { Task } from '@/lib/types'; +import { + acceptSnapshot, + assertMutable, + canMutate, + combineFreshness, + computeDigest, + computeFreshness, + DEFAULT_FRESHNESS_POLICY, + formatAge, + type FreshSnapshot, + invalidationReasonLabels, + StaleMutationError, + UNKNOWN_VERDICT, + verdictValue, +} from './model'; +import { validateProjectCollection, validateTaskCollection } from './validators'; + +const NOW = 1_800_000_000_000; + +const policy = { ...DEFAULT_FRESHNESS_POLICY, staleAfterMs: 60_000 }; + +const taskPayload: Task[] = [ + { + id: 'task-1', + title: 'T1', + description: null, + status: 'not-started', + priority: 'high', + projectId: 'project-1', + missionId: null, + assignee: null, + tags: null, + dueDate: null, + metadata: null, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + }, +]; + +function acceptedTaskSnapshot( + overrides: Partial> = {}, +): FreshSnapshot { + const result = acceptSnapshot({ + value: taskPayload, + validate: validateTaskCollection, + previous: null, + policy, + source: 'gateway:/api/tasks', + now: NOW, + }); + if (result.outcome !== 'accepted') { + throw new Error(`fixture setup failed: ${result.reason}`); + } + return { ...result.snapshot, ...overrides }; +} + +describe('computeFreshness', () => { + it('treats a missing snapshot as unavailable, never as an empty healthy collection', () => { + expect(computeFreshness({ snapshot: null, policy, now: NOW })).toBe('unavailable'); + }); + + it('returns current for a fresh verified snapshot regardless of data emptiness', () => { + const empty = acceptSnapshot({ + value: [], + validate: validateTaskCollection, + previous: null, + policy, + source: 'gateway:/api/tasks', + now: NOW, + }); + if (empty.outcome !== 'accepted') throw new Error('expected acceptance'); + expect(computeFreshness({ snapshot: empty.snapshot, policy, now: NOW })).toBe('current'); + }); + + it('degrades to stale once the snapshot ages past staleAfterMs', () => { + const snapshot = acceptedTaskSnapshot(); + expect(computeFreshness({ snapshot, policy, now: NOW + 60_001 })).toBe('stale'); + expect(computeFreshness({ snapshot, policy, now: NOW + 59_999 })).toBe('current'); + }); + + it('degrades to stale when the latest revalidation failed', () => { + const snapshot = acceptedTaskSnapshot(); + expect(computeFreshness({ snapshot, policy, now: NOW, degraded: true })).toBe('stale'); + }); +}); + +describe('mutation guard', () => { + it('permits mutations only on current data', () => { + expect(canMutate('current')).toBe(true); + for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) { + expect(canMutate(state)).toBe(false); + } + }); + + it('refuses mutations on non-current data via assertMutable', () => { + expect(() => assertMutable('current')).not.toThrow(); + for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) { + let thrown: unknown; + try { + assertMutable(state); + } catch (caught) { + thrown = caught; + } + expect(thrown).toBeInstanceOf(StaleMutationError); + expect(thrown).toBeInstanceOf(Error); + if (thrown instanceof StaleMutationError) { + expect(thrown.name).toBe('StaleMutationError'); + expect(thrown.freshness).toBe(state); + expect(thrown.message).toContain(state); + expect(thrown.message).toContain('revalidat'); + } + } + }); +}); + +describe('acceptSnapshot', () => { + it('accepts a valid payload with provenance', () => { + const result = acceptSnapshot({ + value: taskPayload, + validate: validateTaskCollection, + previous: null, + policy, + source: 'gateway:/api/tasks', + now: NOW, + }); + expect(result.outcome).toBe('accepted'); + if (result.outcome !== 'accepted') return; + expect(result.snapshot.source).toBe('gateway:/api/tasks'); + expect(result.snapshot.version).toBe(1); + expect(result.snapshot.fetchedAt).toBe(NOW); + expect(result.snapshot.data).toEqual(taskPayload); + }); + + it('invalidates a schema-mismatched payload instead of rendering it', () => { + const result = acceptSnapshot({ + value: { not: 'an array' }, + validate: validateTaskCollection, + previous: acceptedTaskSnapshot(), + policy, + source: 'gateway:/api/tasks', + now: NOW, + }); + expect(result).toEqual({ outcome: 'invalidated', reason: 'schema-mismatch' }); + expect(invalidationReasonLabels['schema-mismatch']).toContain('schema'); + }); + + it('invalidates cross-workspace payloads', () => { + const userOne = acceptSnapshot({ + value: [ + { + id: 'p1', + name: 'P1', + description: null, + status: 'active', + userId: 'user-1', + metadata: null, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + }, + ], + validate: validateProjectCollection, + previous: null, + policy, + source: 'gateway:/api/projects', + now: NOW, + }); + if (userOne.outcome !== 'accepted') throw new Error('expected acceptance'); + + const switched = acceptSnapshot({ + value: [ + { + id: 'p9', + name: 'P9', + description: null, + status: 'active', + userId: 'user-2', + metadata: null, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + }, + ], + validate: validateProjectCollection, + previous: userOne.snapshot, + policy, + source: 'gateway:/api/projects', + now: NOW, + }); + expect(switched).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' }); + }); + + it('keeps the previous workspace for collections with no intrinsic identity', () => { + const userOne = acceptSnapshot({ + value: [ + { + id: 'p1', + name: 'P1', + description: null, + status: 'active', + userId: 'user-1', + metadata: null, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + }, + ], + validate: validateProjectCollection, + previous: null, + policy, + source: 'gateway:/api/projects', + now: NOW, + }); + if (userOne.outcome !== 'accepted') throw new Error('expected acceptance'); + + // Empty list after the user deleted every project: no identity to check, + // so the verified scope is retained and the empty state stays healthy. + const emptied = acceptSnapshot({ + value: [], + validate: validateProjectCollection, + previous: userOne.snapshot, + policy, + source: 'gateway:/api/projects', + now: NOW, + }); + expect(emptied.outcome).toBe('accepted'); + if (emptied.outcome === 'accepted') { + expect(emptied.snapshot.data).toEqual([]); + expect(emptied.snapshot.workspace).toBe('user-1'); + } + }); + + it('invalidates version regressions', () => { + const previous = acceptedTaskSnapshot({ version: 7 }); + const regressed = acceptSnapshot({ + value: taskPayload, + validate: validateTaskCollection, + previous, + policy, + source: 'gateway:/api/tasks', + now: NOW, + incomingVersion: 3, + }); + expect(regressed).toEqual({ outcome: 'invalidated', reason: 'version-regression' }); + + const newerSchema = acceptedTaskSnapshot({ schemaVersion: 4 }); + const downgradedClient = acceptSnapshot({ + value: taskPayload, + validate: validateTaskCollection, + previous: newerSchema, + policy: { ...policy, schemaVersion: 2 }, + source: 'gateway:/api/tasks', + now: NOW, + }); + expect(downgradedClient).toEqual({ outcome: 'invalidated', reason: 'version-regression' }); + }); + + it('increments the version monotonically across accepted snapshots', () => { + const first = acceptedTaskSnapshot(); + const second = acceptSnapshot({ + value: taskPayload, + validate: validateTaskCollection, + previous: first, + policy, + source: 'gateway:/api/tasks', + now: NOW, + }); + expect(second.outcome).toBe('accepted'); + if (second.outcome === 'accepted') { + expect(second.snapshot.version).toBe(first.version + 1); + } + }); +}); + +describe('combineFreshness', () => { + it('gates the surface on the primary collection', () => { + expect(combineFreshness('unavailable', ['current'])).toBe('unavailable'); + expect(combineFreshness('unknown', ['current'])).toBe('unknown'); + expect(combineFreshness('current', [])).toBe('current'); + }); + + it('degrades to partial when a secondary is unavailable', () => { + expect(combineFreshness('current', ['current', 'unavailable'])).toBe('partial'); + }); + + it('degrades to unknown while a secondary is still loading', () => { + expect(combineFreshness('current', ['unknown'])).toBe('unknown'); + }); + + it('degrades to stale when any collection is stale', () => { + expect(combineFreshness('current', ['stale'])).toBe('stale'); + expect(combineFreshness('stale', ['current'])).toBe('stale'); + }); + + it('propagates partial secondaries', () => { + expect(combineFreshness('current', ['partial'])).toBe('partial'); + }); +}); + +describe('computeDigest', () => { + it('is stable across key order and changes with data', () => { + const a = computeDigest({ x: 1, y: [1, 2] }); + const b = computeDigest({ y: [1, 2], x: 1 }); + expect(a).toBe(b); + expect(computeDigest({ x: 1, y: [1, 3] })).not.toBe(a); + }); +}); + +describe('verdictValue', () => { + it('returns the value only for verified inputs', () => { + expect(verdictValue(true, '5')).toBe('5'); + expect(verdictValue(false, '5')).toBe(UNKNOWN_VERDICT); + expect(verdictValue(false, '5')).not.toBe('5'); + }); +}); + +describe('formatAge', () => { + it('labels age in human terms', () => { + expect(formatAge(NOW, NOW)).toBe('just now'); + expect(formatAge(NOW, NOW + 15_000)).toBe('under a minute ago'); + expect(formatAge(NOW, NOW + 120_000)).toBe('2m ago'); + expect(formatAge(NOW, NOW + 3 * 3_600_000)).toBe('3h ago'); + expect(formatAge(NOW, NOW + 2 * 86_400_000)).toBe('2d ago'); + }); +}); diff --git a/apps/web/src/lib/freshness/model.ts b/apps/web/src/lib/freshness/model.ts new file mode 100644 index 00000000..a83090f3 --- /dev/null +++ b/apps/web/src/lib/freshness/model.ts @@ -0,0 +1,261 @@ +/** + * Typed freshness model for gateway-fetched collections (RI-5-001). + * + * A failed or stale fetch must never be indistinguishable from an empty + * healthy collection. Every fetched surface carries an explicit freshness + * state, a verified snapshot identity (source, workspace, version, age), and + * a mutation guard that refuses state-changing operations unless the data is + * verified current. + */ + +/** Freshness states for fetched data. Never inferred from emptiness. */ +export type FreshnessState = 'current' | 'stale' | 'partial' | 'unknown' | 'unavailable'; + +/** + * Reasons a snapshot is invalidated. An invalidated snapshot is treated as + * unavailable and is never rendered as current. + */ +export type InvalidationReason = + | 'cache-corruption' + | 'cross-workspace' + | 'schema-mismatch' + | 'version-regression'; + +/** Human-readable labels for invalidation reasons (UI + error messages). */ +export const invalidationReasonLabels: Record = { + 'cache-corruption': 'cached snapshot failed integrity checks', + 'cross-workspace': 'data belongs to a different workspace', + 'schema-mismatch': 'response did not match the expected schema', + 'version-regression': 'snapshot version regressed below the accepted version', +}; + +/** A verified snapshot of fetched data with full provenance. */ +export interface FreshSnapshot { + readonly data: T; + /** Source identity of the fetch, e.g. `gateway:/api/tasks`. */ + readonly source: string; + /** Workspace scope the data belongs to. */ + readonly workspace: string; + /** Monotonic snapshot sequence number for this surface. */ + readonly version: number; + /** Schema version of the validator that accepted this snapshot. */ + readonly schemaVersion: number; + /** Epoch ms at which the data was verified. */ + readonly fetchedAt: number; + /** Integrity digest of `data`, used to detect cache corruption. */ + readonly digest: string; +} + +/** Provenance label rendered next to last-known data. */ +export interface FreshnessLabel { + readonly source: string; + readonly version: number; + readonly fetchedAt: number; +} + +/** Policy governing freshness for a surface. */ +export interface FreshnessPolicy { + /** Active workspace scope. Snapshots from other scopes are invalidated. */ + readonly workspace: string; + /** Schema version of the current validator. */ + readonly schemaVersion: number; + /** Age after which a verified snapshot degrades from current to stale. */ + readonly staleAfterMs: number; +} + +export const DEFAULT_FRESHNESS_POLICY: FreshnessPolicy = { + workspace: 'default', + schemaVersion: 1, + staleAfterMs: 60_000, +}; + +/** Payload returned by a successful schema validation. */ +export interface FreshPayload { + readonly data: T; + /** + * Workspace identity extracted from the payload itself when the collection + * carries one (e.g. a uniform `userId` on projects). `null` when the + * collection has no intrinsic workspace identity. + */ + readonly workspace: string | null; +} + +/** Error thrown when a mutation is attempted on non-current data. */ +export class StaleMutationError extends Error { + readonly freshness: FreshnessState; + + constructor(freshness: FreshnessState) { + super(`Refused mutation on ${freshness} data: revalidation is required before mutating.`); + this.name = 'StaleMutationError'; + this.freshness = freshness; + } +} + +/** Stable JSON digest used for snapshot integrity checks. */ +export function computeDigest(value: unknown): string { + // FNV-1a 32-bit over the stable JSON serialization. This is an integrity + // check against corruption, not a cryptographic guarantee. + let hash = 0x811c9dc5; + for (const byte of stableStringify(value)) { + hash ^= byte.charCodeAt(0); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, '0'); +} + +function stableStringify(value: unknown): string { + return serialize(value); +} + +function serialize(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'; + if (Array.isArray(value)) return `[${value.map(serialize).join(',')}]`; + const entries = Object.entries(value as Record) + .filter(([, item]) => item !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, item]) => `${JSON.stringify(key)}:${serialize(item)}`); + return `{${entries.join(',')}}`; +} + +export type AcceptSnapshotResult = + | { readonly outcome: 'accepted'; readonly snapshot: FreshSnapshot } + | { readonly outcome: 'invalidated'; readonly reason: InvalidationReason }; + +export interface AcceptSnapshotOptions { + /** Raw fetched value (untrusted JSON). */ + readonly value: unknown; + /** Schema validator; returns `null` when the value does not match. */ + readonly validate: (value: unknown) => FreshPayload | null; + /** Previously accepted snapshot for this surface, if any. */ + readonly previous: FreshSnapshot | null; + readonly policy: FreshnessPolicy; + readonly source: string; + /** + * Version carried by the incoming payload when the transport exposes one. + * Must not regress below the accepted snapshot's version. + */ + readonly incomingVersion?: number; + readonly now: number; +} + +/** + * Validate and accept a fetched value as a snapshot, or invalidate it. + * + * Invalidation rules (each treated as unavailable, never rendered current): + * - schema mismatch: the payload fails validation + * - cross-workspace: the payload's workspace differs from the verified one + * - version regression: payload/schema version is below the accepted one + */ +export function acceptSnapshot(options: AcceptSnapshotOptions): AcceptSnapshotResult { + const payload = options.validate(options.value); + if (payload === null) { + return { outcome: 'invalidated', reason: 'schema-mismatch' }; + } + + // Workspace identity: the payload's own scope wins; a collection with no + // intrinsic identity (e.g. an empty list after every project was deleted) + // keeps the previously verified scope rather than resetting to the policy + // default, so a legitimately empty response is not mistaken for a scope + // change. + const workspace = payload.workspace ?? options.previous?.workspace ?? options.policy.workspace; + if (options.previous !== null && options.previous.workspace !== workspace) { + return { outcome: 'invalidated', reason: 'cross-workspace' }; + } + if (options.previous !== null && options.policy.schemaVersion < options.previous.schemaVersion) { + return { outcome: 'invalidated', reason: 'version-regression' }; + } + if ( + options.incomingVersion !== undefined && + options.previous !== null && + options.incomingVersion < options.previous.version + ) { + return { outcome: 'invalidated', reason: 'version-regression' }; + } + + const snapshot: FreshSnapshot = { + data: payload.data, + source: options.source, + workspace, + version: options.incomingVersion ?? (options.previous?.version ?? 0) + 1, + schemaVersion: options.policy.schemaVersion, + fetchedAt: options.now, + digest: computeDigest(payload.data), + }; + return { outcome: 'accepted', snapshot }; +} + +export interface ComputeFreshnessOptions { + readonly snapshot: FreshSnapshot | null; + readonly policy: FreshnessPolicy; + readonly now: number; + /** + * True when the snapshot cannot be trusted as current regardless of age: + * the latest revalidation failed, or the snapshot was restored from cache + * and has not been verified by a fetch in this session. + */ + readonly degraded?: boolean; +} + +/** + * Compute the freshness state of a snapshot. A missing snapshot is + * `unavailable` (never "empty and healthy"); a degraded or aged snapshot is + * `stale` (situational awareness only). + */ +export function computeFreshness(options: ComputeFreshnessOptions): FreshnessState { + const { snapshot, policy, now, degraded = false } = options; + if (snapshot === null) return 'unavailable'; + if (degraded) return 'stale'; + if (now - snapshot.fetchedAt > policy.staleAfterMs) return 'stale'; + return 'current'; +} + +/** Only verified-current data may back a state-changing action. */ +export function canMutate(state: FreshnessState): boolean { + return state === 'current'; +} + +/** Defense in depth: reject the mutation call itself on non-current data. */ +export function assertMutable(state: FreshnessState): void { + if (!canMutate(state)) { + throw new StaleMutationError(state); + } +} + +/** + * Combine freshness across a multi-collection surface (primary + secondaries). + * The primary collection gates the surface: unknown while it loads, + * unavailable when it fails. Missing secondaries degrade the surface to + * `partial`; aged collections degrade it to `stale`. + */ +export function combineFreshness( + primary: FreshnessState, + secondaries: readonly FreshnessState[], +): FreshnessState { + if (primary === 'unavailable') return 'unavailable'; + if (primary === 'unknown') return 'unknown'; + if (secondaries.includes('unavailable')) return 'partial'; + if (secondaries.includes('unknown')) return 'unknown'; + if (secondaries.includes('stale') || primary === 'stale') return 'stale'; + if (secondaries.includes('partial')) return 'partial'; + return 'current'; +} + +/** Render-safe age label for snapshot provenance. */ +export function formatAge(fetchedAt: number, now: number): string { + const ageMs = Math.max(0, now - fetchedAt); + if (ageMs < 10_000) return 'just now'; + const minutes = Math.floor(ageMs / 60_000); + if (minutes < 1) return 'under a minute ago'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +/** Derived verdict placeholder for non-current inputs — never a green value. */ +export const UNKNOWN_VERDICT = '?'; + +export function verdictValue(verified: boolean, value: string): string { + return verified ? value : UNKNOWN_VERDICT; +} diff --git a/apps/web/src/lib/freshness/snapshot-cache.spec.ts b/apps/web/src/lib/freshness/snapshot-cache.spec.ts new file mode 100644 index 00000000..551905b2 --- /dev/null +++ b/apps/web/src/lib/freshness/snapshot-cache.spec.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { acceptSnapshot, DEFAULT_FRESHNESS_POLICY } from './model'; +import { clearSnapshotCache, readSnapshotCache, writeSnapshotCache } from './snapshot-cache'; +import { validateProjectCollection, validateTaskCollection } from './validators'; +import { projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures'; +import type { Project, Task } from '@/lib/types'; + +const KEY = 'test:tasks'; +const NOW = 1_800_000_000_000; +const policy = { ...DEFAULT_FRESHNESS_POLICY, staleAfterMs: 60_000 }; + +function storedTaskSnapshot() { + const result = acceptSnapshot({ + value: taskFixtures, + validate: validateTaskCollection, + previous: null, + policy, + source: 'gateway:/api/tasks', + now: NOW, + }); + if (result.outcome !== 'accepted') throw new Error('fixture setup failed'); + return result.snapshot; +} + +function storedProjectSnapshot() { + const result = acceptSnapshot({ + value: projectFixtures, + validate: validateProjectCollection, + previous: null, + policy, + source: 'gateway:/api/projects', + now: NOW, + }); + if (result.outcome !== 'accepted') throw new Error('fixture setup failed'); + return result.snapshot; +} + +function readTasks() { + return readSnapshotCache({ + key: KEY, + workspace: policy.workspace, + policy, + validate: validateTaskCollection, + }); +} + +/** Write an arbitrary value directly at the raw cache slot. */ +function writeRaw(key: string, value: unknown): void { + sessionStorage.setItem(`mosaic:freshness:v1:${key}`, JSON.stringify(value)); +} + +/** Parse and re-write the stored entry (for tampering with internals). */ +function tamperStored(key: string, mutate: (stored: T) => void): void { + const parsed = JSON.parse(sessionStorage.getItem(`mosaic:freshness:v1:${key}`) ?? '{}') as T; + mutate(parsed); + writeRaw(key, parsed); +} + +beforeEach(() => { + sessionStorage.clear(); +}); + +afterEach(() => { + sessionStorage.clear(); +}); + +describe('readSnapshotCache', () => { + it('misses when nothing is stored', () => { + expect(readTasks()).toEqual({ outcome: 'miss' }); + }); + + it('hits for a well-formed entry and preserves provenance', () => { + const snapshot = storedTaskSnapshot(); + writeSnapshotCache(KEY, snapshot); + + const result = readTasks(); + expect(result.outcome).toBe('hit'); + if (result.outcome === 'hit') { + expect(result.snapshot.data).toEqual(taskFixtures); + expect(result.snapshot.source).toBe('gateway:/api/tasks'); + expect(result.snapshot.version).toBe(snapshot.version); + expect(result.snapshot.fetchedAt).toBe(snapshot.fetchedAt); + expect(result.snapshot.workspace).toBe(snapshot.workspace); + } + }); + + it('invalidates unparsable entries as cache corruption', () => { + sessionStorage.setItem(`mosaic:freshness:v1:${KEY}`, '{not json'); + expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' }); + }); + + it('invalidates structurally wrong entries as cache corruption', () => { + const malformed: unknown[] = [ + 'nested but not a snapshot', + { data: taskFixtures }, // missing provenance fields + { + data: taskFixtures, + source: 1, + workspace: 'w', + version: 1, + schemaVersion: 1, + fetchedAt: 1, + digest: 'x', + }, + null, + 17, + ]; + for (const entry of malformed) { + writeRaw(KEY, entry); + expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' }); + } + }); + + it('invalidates digest mismatches as cache corruption (tampered data)', () => { + writeSnapshotCache(KEY, storedTaskSnapshot()); + tamperStored<{ data: Task[] }>(KEY, (stored) => { + stored.data = [...stored.data, { ...stored.data[0]!, id: 'injected-task' }]; + }); + expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' }); + }); + + it('invalidates entries scoped to another workspace', () => { + const snapshot = storedTaskSnapshot(); + writeSnapshotCache(KEY, { ...snapshot, workspace: 'someone-else' }); + expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' }); + }); + + it('invalidates entries written by a newer schema as a version regression', () => { + const snapshot = storedTaskSnapshot(); + writeSnapshotCache(KEY, { ...snapshot, schemaVersion: policy.schemaVersion + 1 }); + expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'version-regression' }); + }); + + it('invalidates entries whose data no longer validates (schema mismatch)', () => { + writeSnapshotCache(KEY, storedTaskSnapshot()); + tamperStored<{ data: unknown }>(KEY, (stored) => { + stored.data = { malformed: true }; + }); + expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'schema-mismatch' }); + }); + + it('never reports a corrupted raw entry as a hit (negative control)', () => { + for (const raw of ['{oops', 'null', '"string"', '[]', '12']) { + sessionStorage.setItem(`mosaic:freshness:v1:${KEY}`, raw); + const result = readTasks(); + expect(result.outcome).not.toBe('hit'); + expect(result.outcome).toBe('invalidated'); + } + }); + + it('scopes project collections by their workspace identity', () => { + const snapshot = storedProjectSnapshot(); + writeSnapshotCache('test:projects', snapshot); + + const sameScope = readSnapshotCache({ + key: 'test:projects', + workspace: 'user-1', + policy, + validate: validateProjectCollection, + }); + expect(sameScope.outcome).toBe('hit'); + + const foreignScope = readSnapshotCache({ + key: 'test:projects', + workspace: 'user-2', + policy, + validate: validateProjectCollection, + }); + expect(foreignScope).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' }); + }); +}); + +describe('writeSnapshotCache round-trip', () => { + it('round-trips an accepted project snapshot', () => { + const snapshot = storedProjectSnapshot(); + writeSnapshotCache('test:projects', snapshot); + const result = readSnapshotCache({ + key: 'test:projects', + workspace: snapshot.workspace, + policy, + validate: validateProjectCollection, + }); + expect(result.outcome).toBe('hit'); + if (result.outcome === 'hit') { + expect(result.snapshot.data).toEqual(projectFixtures as Project[]); + } + }); +}); + +describe('clearSnapshotCache', () => { + it('drops the entry so the next read misses', () => { + writeSnapshotCache(KEY, storedTaskSnapshot()); + expect(readTasks().outcome).toBe('hit'); + clearSnapshotCache(KEY); + expect(readTasks()).toEqual({ outcome: 'miss' }); + }); +}); diff --git a/apps/web/src/lib/freshness/snapshot-cache.ts b/apps/web/src/lib/freshness/snapshot-cache.ts new file mode 100644 index 00000000..63dbd756 --- /dev/null +++ b/apps/web/src/lib/freshness/snapshot-cache.ts @@ -0,0 +1,154 @@ +import { + computeDigest, + type FreshPayload, + type FreshSnapshot, + type FreshnessPolicy, + type InvalidationReason, +} from './model'; + +/** + * Session-scoped last-known snapshot cache (RI-5-001). + * + * Restored snapshots are situational awareness only: they surface as `stale` + * until a fetch re-verifies them. A cache entry that is corrupted, belongs to + * another workspace, was written by a newer schema, or no longer validates is + * invalidated (treated as unavailable, never rendered as current). + */ + +const CACHE_PREFIX = 'mosaic:freshness:v1'; + +interface StoredSnapshot { + data: unknown; + source: string; + workspace: string; + version: number; + schemaVersion: number; + fetchedAt: number; + digest: string; +} + +export type SnapshotCacheRead = + | { readonly outcome: 'hit'; readonly snapshot: FreshSnapshot } + | { readonly outcome: 'miss' } + | { readonly outcome: 'invalidated'; readonly reason: InvalidationReason }; + +export interface ReadSnapshotCacheOptions { + readonly key: string; + readonly workspace: string; + readonly policy: FreshnessPolicy; + readonly validate: (value: unknown) => FreshPayload | null; +} + +function cacheKey(key: string): string { + return `${CACHE_PREFIX}:${key}`; +} + +function isStoredSnapshot(value: unknown): value is StoredSnapshot { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Record; + return ( + typeof candidate['data'] === 'object' && + candidate['data'] !== null && + typeof candidate['source'] === 'string' && + typeof candidate['workspace'] === 'string' && + typeof candidate['version'] === 'number' && + typeof candidate['schemaVersion'] === 'number' && + typeof candidate['fetchedAt'] === 'number' && + typeof candidate['digest'] === 'string' + ); +} + +function getStorage(): Storage | null { + try { + return globalThis.sessionStorage ?? null; + } catch { + return null; + } +} + +/** + * Restore a cached snapshot under the active workspace scope. Every failure + * mode maps to an explicit invalidation reason or a miss — never to data + * that renders as current. + */ +export function readSnapshotCache(options: ReadSnapshotCacheOptions): SnapshotCacheRead { + const storage = getStorage(); + if (storage === null) return { outcome: 'miss' }; + + let raw: string | null; + try { + raw = storage.getItem(cacheKey(options.key)); + } catch { + return { outcome: 'miss' }; + } + if (raw === null) return { outcome: 'miss' }; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { outcome: 'invalidated', reason: 'cache-corruption' }; + } + if (!isStoredSnapshot(parsed)) { + return { outcome: 'invalidated', reason: 'cache-corruption' }; + } + if (parsed.workspace !== options.workspace) { + return { outcome: 'invalidated', reason: 'cross-workspace' }; + } + if (parsed.schemaVersion > options.policy.schemaVersion) { + // Written by a newer build than the running client: version regression. + return { outcome: 'invalidated', reason: 'version-regression' }; + } + + const payload = options.validate(parsed.data); + if (payload === null) { + return { outcome: 'invalidated', reason: 'schema-mismatch' }; + } + if (computeDigest(payload.data) !== parsed.digest) { + return { outcome: 'invalidated', reason: 'cache-corruption' }; + } + + return { + outcome: 'hit', + snapshot: { + data: payload.data, + source: parsed.source, + workspace: parsed.workspace, + version: parsed.version, + schemaVersion: parsed.schemaVersion, + fetchedAt: parsed.fetchedAt, + digest: parsed.digest, + }, + }; +} + +/** Persist a verified snapshot. Failures are non-fatal (cache is best-effort). */ +export function writeSnapshotCache(key: string, snapshot: FreshSnapshot): void { + const storage = getStorage(); + if (storage === null) return; + const stored: StoredSnapshot = { + data: snapshot.data, + source: snapshot.source, + workspace: snapshot.workspace, + version: snapshot.version, + schemaVersion: snapshot.schemaVersion, + fetchedAt: snapshot.fetchedAt, + digest: snapshot.digest, + }; + try { + storage.setItem(cacheKey(key), JSON.stringify(stored)); + } catch { + // Quota or serialization failures simply skip caching. + } +} + +/** Drop a cached snapshot (used when a surface invalidates its cache entry). */ +export function clearSnapshotCache(key: string): void { + const storage = getStorage(); + if (storage === null) return; + try { + storage.removeItem(cacheKey(key)); + } catch { + // Ignorable: a wedged storage entry is detected as corruption on read. + } +} diff --git a/apps/web/src/lib/freshness/use-fresh-collection.spec.tsx b/apps/web/src/lib/freshness/use-fresh-collection.spec.tsx new file mode 100644 index 00000000..bd218c94 --- /dev/null +++ b/apps/web/src/lib/freshness/use-fresh-collection.spec.tsx @@ -0,0 +1,372 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Task } from '@/lib/types'; +import { acceptSnapshot, StaleMutationError, DEFAULT_FRESHNESS_POLICY } from './model'; +import type { FreshnessFailure } from './use-fresh-collection'; +import { + describeFailure, + useFreshCollection, + type FreshCollection, + type UseFreshCollectionOptions, +} from './use-fresh-collection'; +import { validateProjectCollection, validateTaskCollection } from './validators'; +import { projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures'; + +/** + * Failure-matrix coverage for the freshness seam (RI-5-001): network failure, + * auth failure, malformed response, cache corruption, stale age, schema + * mismatch, cross-workspace, recovery, and stale-action rejection — with + * negative controls proving no case yields current data or an enabled + * mutation. + */ + +const NOW = 1_800_000_000_000; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +let root: Root | null = null; +let container: HTMLDivElement; +let latest: FreshCollection | null = null; + +function Probe({ + options, +}: { + options: UseFreshCollectionOptions; +}): React.ReactElement | null { + latest = useFreshCollection(options); + return null; +} + +beforeAll(() => { + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + configurable: true, + value: true, + }); +}); + +beforeEach(() => { + sessionStorage.clear(); +}); + +afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + document.body.replaceChildren(); + root = null; + latest = null; + sessionStorage.clear(); + vi.restoreAllMocks(); +}); + +async function renderCollection( + options: UseFreshCollectionOptions, +): Promise> { + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + if (latest === null) throw new Error('hook did not run'); + return latest; +} + +function taskOptions( + overrides: Partial> = {}, +): UseFreshCollectionOptions { + return { + source: 'gateway:/api/tasks', + fetcher: () => Promise.resolve(taskFixtures), + validate: validateTaskCollection, + cacheKey: 'tasks', + clock: () => NOW, + ...overrides, + }; +} + +function authError(statusCode: number): Error & { statusCode: number } { + return Object.assign(new Error(`Request failed with ${statusCode}`), { statusCode }); +} + +function seedCache(key: string): number { + const result = acceptSnapshot({ + value: taskFixtures, + validate: validateTaskCollection, + previous: null, + policy: DEFAULT_FRESHNESS_POLICY, + source: 'gateway:/api/tasks', + now: NOW, + }); + if (result.outcome !== 'accepted') throw new Error('fixture setup failed'); + sessionStorage.setItem(`mosaic:freshness:v1:${key}`, JSON.stringify({ ...result.snapshot })); + return result.snapshot.version; +} + +describe('useFreshCollection failure matrix', () => { + it('is unknown (not empty) while the first validation is in flight', async () => { + const deferred = createDeferred(); + const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise })); + + expect(collection.freshness).toBe('unknown'); + expect(collection.validating).toBe(true); + expect(collection.data).toBeNull(); + expect(collection.canMutate).toBe(false); + + await act(async () => { + deferred.resolve(taskFixtures); + await deferred.promise; + }); + }); + + it('becomes current with provenance after a verified fetch', async () => { + const collection = await renderCollection(taskOptions()); + + expect(collection.freshness).toBe('current'); + expect(collection.data).toEqual(taskFixtures); + expect(collection.snapshot?.source).toBe('gateway:/api/tasks'); + expect(collection.snapshot?.version).toBe(1); + expect(collection.failure).toBeNull(); + expect(collection.canMutate).toBe(true); + // Verified snapshot is persisted for last-known restore. + expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeTruthy(); + }); + + it('treats a network failure as unavailable — never an empty healthy collection', async () => { + const collection = await renderCollection( + taskOptions({ fetcher: () => Promise.reject(new Error('network down')) }), + ); + + expect(collection.freshness).toBe('unavailable'); + expect(collection.data).toBeNull(); + expect(collection.failure).toEqual({ kind: 'fetch', message: 'network down' }); + expect(collection.canMutate).toBe(false); + expect(describeFailure(collection.failure)).toBe('network down'); + }); + + it('treats an auth failure as unavailable and drops the last-known snapshot', async () => { + let call = 0; + const collection = await renderCollection( + taskOptions({ + fetcher: () => { + call += 1; + return call === 1 ? Promise.resolve(taskFixtures) : Promise.reject(authError(401)); + }, + }), + ); + expect(collection.freshness).toBe('current'); + + await act(async () => { + await collection.revalidate(); + }); + + expect(latest?.freshness).toBe('unavailable'); + expect(latest?.data).toBeNull(); + expect(latest?.failure?.kind).toBe('fetch'); + // The previous user's data must not linger in the session cache. + expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull(); + }); + + it('invalidates a malformed response as a schema mismatch', async () => { + const collection = await renderCollection( + taskOptions({ fetcher: () => Promise.resolve({ malformed: true }) }), + ); + + expect(collection.freshness).toBe('unavailable'); + expect(collection.data).toBeNull(); + expect(collection.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' }); + expect(collection.canMutate).toBe(false); + }); + + it('keeps the previous snapshot as labeled stale when a later payload mismatches', async () => { + let call = 0; + const collection = await renderCollection( + taskOptions({ + fetcher: () => { + call += 1; + return call === 1 ? Promise.resolve(taskFixtures) : Promise.resolve('garbage'); + }, + }), + ); + expect(collection.freshness).toBe('current'); + + await act(async () => { + await collection.revalidate(); + }); + + expect(latest?.freshness).toBe('stale'); + expect(latest?.data).toEqual(taskFixtures); + expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' }); + expect(latest?.canMutate).toBe(false); + }); + + it('drops the snapshot when the workspace changes under it (cross-workspace)', async () => { + let call = 0; + const collection = await renderCollection( + taskOptions({ + fetcher: () => { + call += 1; + return Promise.resolve( + call === 1 ? projectFixtures : [{ ...projectFixtures[0], userId: 'user-2' }], + ); + }, + validate: validateProjectCollection as unknown as (value: unknown) => { + data: Task[]; + workspace: string | null; + }, + source: 'gateway:/api/projects', + }), + ); + expect(collection.freshness).toBe('current'); + + await act(async () => { + await collection.revalidate(); + }); + + expect(latest?.freshness).toBe('unavailable'); + expect(latest?.data).toBeNull(); + expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'cross-workspace' }); + }); + + it('ages from current to stale and refuses mutations on stale data', async () => { + let fakeNow = NOW; + const collection = await renderCollection( + taskOptions({ + clock: () => fakeNow, + policy: { staleAfterMs: 40 }, + tickMs: 10, + }), + ); + expect(collection.freshness).toBe('current'); + + // Age the snapshot past the policy and let the tick recompute. + fakeNow = NOW + 60; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 25)); + }); + + expect(latest?.freshness).toBe('stale'); + expect(latest?.data).toEqual(taskFixtures); + expect(latest?.canMutate).toBe(false); + + const operation = vi.fn(async () => 'result'); + await expect(latest?.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError); + expect(operation).not.toHaveBeenCalled(); + }); + + it('recovers to current after a successful revalidation', async () => { + let call = 0; + const collection = await renderCollection( + taskOptions({ + fetcher: () => { + call += 1; + return call === 1 + ? Promise.reject(new Error('first attempt failed')) + : Promise.resolve(taskFixtures); + }, + }), + ); + expect(collection.freshness).toBe('unavailable'); + + await act(async () => { + await collection.revalidate(); + }); + + expect(latest?.freshness).toBe('current'); + expect(latest?.failure).toBeNull(); + + const operation = vi.fn(async (data: Task[]) => data.length); + await expect(latest?.mutate(operation)).resolves.toBe(taskFixtures.length); + expect(operation).toHaveBeenCalledOnce(); + }); + + it('restores a cached snapshot as unverified stale data, then verifies it', async () => { + const seededVersion = seedCache('tasks'); + const deferred = createDeferred(); + const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise })); + + // Restored data is situational awareness only: labeled stale, never + // current, and mutations are refused before verification. + expect(collection.freshness).toBe('stale'); + expect(collection.data).toEqual(taskFixtures); + expect(collection.canMutate).toBe(false); + await expect(collection.mutate(vi.fn())).rejects.toBeInstanceOf(StaleMutationError); + + await act(async () => { + deferred.resolve(taskFixtures); + await deferred.promise; + }); + + expect(latest?.freshness).toBe('current'); + expect(latest?.snapshot?.version).toBe(seededVersion + 1); + }); + + it('never promotes corrupted cache data to current (cache corruption)', async () => { + sessionStorage.setItem('mosaic:freshness:v1:tasks', '{"data":'); + const collection = await renderCollection( + taskOptions({ fetcher: () => Promise.reject(new Error('still down')) }), + ); + + expect(collection.freshness).toBe('unavailable'); + expect(collection.data).toBeNull(); + expect(collection.canMutate).toBe(false); + // The corrupted entry is dropped so it cannot come back. + expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull(); + }); + + it('refuses mutations while unknown or unavailable — the call itself, not just the button', async () => { + const deferred = createDeferred(); + const unknown = await renderCollection(taskOptions({ fetcher: () => deferred.promise })); + const operation = vi.fn(async () => 'result'); + await expect(unknown.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError); + expect(operation).not.toHaveBeenCalled(); + await act(async () => { + deferred.reject(new Error('failed')); + await deferred.promise.catch(() => undefined); + }); + + const unavailable = latest!; + await expect(unavailable.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError); + expect(operation).not.toHaveBeenCalled(); + expect(unavailable.canMutate).toBe(false); + }); + + it('degrades to stale with last-known data when a revalidation fails after success', async () => { + let call = 0; + const collection = await renderCollection( + taskOptions({ + fetcher: () => { + call += 1; + return call === 1 + ? Promise.resolve(taskFixtures) + : Promise.reject(new Error('connection lost')); + }, + }), + ); + expect(collection.freshness).toBe('current'); + + await act(async () => { + await collection.revalidate(); + }); + + expect(latest?.freshness).toBe('stale'); + expect(latest?.data).toEqual(taskFixtures); + const failure: FreshnessFailure | null = latest?.failure ?? null; + expect(failure).toEqual({ kind: 'fetch', message: 'connection lost' }); + }); +}); diff --git a/apps/web/src/lib/freshness/use-fresh-collection.ts b/apps/web/src/lib/freshness/use-fresh-collection.ts new file mode 100644 index 00000000..4819c07e --- /dev/null +++ b/apps/web/src/lib/freshness/use-fresh-collection.ts @@ -0,0 +1,281 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + acceptSnapshot, + assertMutable, + computeFreshness, + DEFAULT_FRESHNESS_POLICY, + invalidationReasonLabels, + type FreshPayload, + type FreshSnapshot, + type FreshnessPolicy, + type FreshnessState, + type InvalidationReason, + StaleMutationError, +} from './model'; +import { clearSnapshotCache, readSnapshotCache, writeSnapshotCache } from './snapshot-cache'; + +/** + * Freshness-aware collection fetch hook (RI-5-001). + * + * One hook owns one gateway collection end to end: fetch, schema validation, + * snapshot acceptance with provenance, session-scoped last-known caching, + * aging, and the mutation guard. Pages consume `freshness` and never infer + * health from emptiness. + */ + +/** Why the latest validation did not produce a current snapshot. */ +export type FreshnessFailure = + | { readonly kind: 'fetch'; readonly message: string } + | { readonly kind: 'invalidated'; readonly reason: InvalidationReason }; + +export interface UseFreshCollectionOptions { + /** Source identity for provenance labels, e.g. `gateway:/api/tasks`. */ + readonly source: string; + /** Performs the unvalidated fetch. The hook owns abort and verification. */ + readonly fetcher: (signal: AbortSignal) => Promise; + /** + * Runtime schema validator. Returning `null` invalidates the payload + * (`schema-mismatch`) instead of letting malformed JSON flow into render. + */ + readonly validate: (value: unknown) => FreshPayload | null; + /** Overrides of the default freshness policy. */ + readonly policy?: Partial; + /** + * Session cache key for last-known snapshots. `null`/omitted disables + * restore. Restored snapshots are unverified: they render only as + * labeled `stale` data until a fetch re-verifies them. + */ + readonly cacheKey?: string | null; + /** Injectable clock for deterministic age transitions in tests. */ + readonly clock?: () => number; + /** Aging tick interval override (default derived from `staleAfterMs`). */ + readonly tickMs?: number; + /** When false, no fetch runs (surfaces stay `unavailable`/`unknown`). */ + readonly enabled?: boolean; +} + +export interface FreshCollection { + /** Last verified (or restored-unverified) snapshot, or `null`. */ + readonly snapshot: FreshSnapshot | null; + /** Snapshot data or `null` — never a fabricated empty collection. */ + readonly data: T | null; + readonly freshness: FreshnessState; + /** True while a validation request is in flight. */ + readonly validating: boolean; + /** Outcome of the latest failed validation, `null` when healthy. */ + readonly failure: FreshnessFailure | null; + /** False unless freshness is `current`; drives disabled UI affordances. */ + readonly canMutate: boolean; + /** Re-run the fetch and re-verify. Always allowed (it is a read). */ + readonly revalidate: () => Promise; + /** + * Run a state-changing operation against verified-current data only. + * Rejects with `StaleMutationError` on any other state — the guard fires + * even if a disabled button was bypassed (defense in depth). + */ + readonly mutate: (operation: (data: T) => Promise) => Promise; +} + +const defaultClock = (): number => Date.now(); + +function resolveTickMs(policy: FreshnessPolicy, override?: number): number { + if (override !== undefined && override > 0) return override; + return Math.min(5_000, Math.max(250, Math.floor(policy.staleAfterMs / 4))); +} + +function isAuthFailure(caught: unknown): boolean { + return ( + typeof caught === 'object' && + caught !== null && + 'statusCode' in caught && + ((caught as { statusCode?: unknown }).statusCode === 401 || + (caught as { statusCode?: unknown }).statusCode === 403) + ); +} + +function fetchFailureMessage(caught: unknown): string { + if (caught instanceof Error && caught.message.trim().length > 0) return caught.message; + return 'The request failed.'; +} + +/** Human-readable summary of a failure for unavailable/stale notices. */ +export function describeFailure(failure: FreshnessFailure | null): string | null { + if (failure === null) return null; + if (failure.kind === 'fetch') return failure.message; + return `The snapshot was invalidated: ${invalidationReasonLabels[failure.reason]}.`; +} + +export function useFreshCollection(options: UseFreshCollectionOptions): FreshCollection { + const optionsRef = useRef(options); + optionsRef.current = options; + + const policy = useMemo( + () => ({ ...DEFAULT_FRESHNESS_POLICY, ...options.policy }), + [options.policy], + ); + const policyRef = useRef(policy); + policyRef.current = policy; + + const clockRef = useRef(options.clock ?? defaultClock); + clockRef.current = options.clock ?? defaultClock; + + const [snapshot, setSnapshot] = useState | null>(null); + const [failure, setFailure] = useState(null); + const [unverified, setUnverified] = useState(false); + const [validating, setValidating] = useState(options.enabled !== false); + const [now, setNow] = useState(() => (options.clock ?? defaultClock)()); + + const snapshotRef = useRef(snapshot); + snapshotRef.current = snapshot; + const failureRef = useRef(failure); + failureRef.current = failure; + const unverifiedRef = useRef(unverified); + unverifiedRef.current = unverified; + + const runRef = useRef(0); + const abortRef = useRef(null); + + const revalidate = useCallback(async (): Promise => { + const current = optionsRef.current; + if (current.enabled === false) { + setValidating(false); + return; + } + + const runId = ++runRef.current; + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setValidating(true); + + let value: unknown; + try { + value = await current.fetcher(controller.signal); + } catch (caught) { + if (runRef.current !== runId || controller.signal.aborted) return; + if (isAuthFailure(caught)) { + // An unauthenticated viewer must not keep (or be served) the + // previous user's last-known data. + setSnapshot(null); + setUnverified(false); + if (current.cacheKey) clearSnapshotCache(current.cacheKey); + } + setFailure({ kind: 'fetch', message: fetchFailureMessage(caught) }); + setValidating(false); + return; + } + + if (runRef.current !== runId) return; + + const result = acceptSnapshot({ + value, + validate: current.validate, + previous: snapshotRef.current, + policy: policyRef.current, + source: current.source, + now: clockRef.current(), + }); + + if (result.outcome === 'accepted') { + setSnapshot(result.snapshot); + setUnverified(false); + setFailure(null); + if (current.cacheKey) writeSnapshotCache(current.cacheKey, result.snapshot); + } else { + if (result.reason === 'cross-workspace') { + // Data verified for a different workspace must not linger as + // last-known situational awareness either. + setSnapshot(null); + setUnverified(false); + } + if (current.cacheKey) clearSnapshotCache(current.cacheKey); + setFailure({ kind: 'invalidated', reason: result.reason }); + } + setValidating(false); + }, []); + + // Restore the last-known snapshot (unverified) and run the first fetch. + useEffect(() => { + if (optionsRef.current.enabled === false) { + setValidating(false); + return; + } + + const cacheKey = optionsRef.current.cacheKey; + if (cacheKey) { + const restored = readSnapshotCache({ + key: cacheKey, + workspace: policyRef.current.workspace, + policy: policyRef.current, + validate: optionsRef.current.validate, + }); + if (restored.outcome === 'hit') { + setSnapshot(restored.snapshot); + setUnverified(true); + } else if (restored.outcome === 'invalidated') { + // A corrupted/foreign/regressed entry is dropped immediately; it must + // never surface as data. The fetch decides the visible state. + clearSnapshotCache(cacheKey); + } + } + + void revalidate(); + + return () => { + abortRef.current?.abort(); + }; + // Mount-once by design: `revalidate` is stable and reads live options + // through refs, so it never needs to re-run when options change. + // Route-param pages remount this hook via an identity `key` instead. + }, [revalidate]); + + // Aging tick: recomputes freshness as the snapshot ages past the policy. + useEffect(() => { + const interval = setInterval( + () => { + setNow(clockRef.current()); + }, + resolveTickMs(policyRef.current, optionsRef.current.tickMs), + ); + return () => clearInterval(interval); + }, []); + + const freshness = useMemo(() => { + if (snapshot === null) return validating ? 'unknown' : 'unavailable'; + return computeFreshness({ + snapshot, + policy, + now, + degraded: failure !== null || unverified, + }); + // `now` from state covers age; refs inside computeFreshness are pure. + }, [snapshot, validating, failure, unverified, now, policy]); + + const canMutate = freshness === 'current'; + + const mutate = useCallback(async (operation: (data: T) => Promise): Promise => { + const currentSnapshot = snapshotRef.current; + // No verified snapshot at all: with nothing verified there is nothing + // current to mutate, regardless of the recorded failure. + if (currentSnapshot === null) throw new StaleMutationError('unavailable'); + const state = computeFreshness({ + snapshot: currentSnapshot, + policy: policyRef.current, + now: clockRef.current(), + degraded: failureRef.current !== null || unverifiedRef.current, + }); + assertMutable(state); + return operation(currentSnapshot.data); + }, []); + + return { + snapshot, + data: snapshot === null ? null : snapshot.data, + freshness, + validating, + failure, + canMutate, + revalidate, + mutate, + }; +} diff --git a/apps/web/src/lib/freshness/validators.spec.ts b/apps/web/src/lib/freshness/validators.spec.ts new file mode 100644 index 00000000..7f6d3ec2 --- /dev/null +++ b/apps/web/src/lib/freshness/validators.spec.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import type { Mission, Project, Task } from '@/lib/types'; +import { + validateMissionCollection, + validateProjectCollection, + validateProjectEntity, + validateTaskCollection, +} from './validators'; +import { missionFixtures, projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures'; + +describe('validateTaskCollection', () => { + it('accepts a well-formed task collection', () => { + expect(validateTaskCollection(taskFixtures)).toEqual({ + data: taskFixtures, + workspace: null, + }); + }); + + it('accepts an empty collection (a healthy empty state is a valid payload)', () => { + expect(validateTaskCollection([])).toEqual({ data: [], workspace: null }); + }); + + it.each([ + ['not an array', { items: [] }], + ['item is not an object', ['nope']], + ['missing id', [{ ...(taskFixtures[0] as Task), id: undefined }]], + ['missing title', [{ ...(taskFixtures[0] as Task), title: undefined }]], + ['unknown status enum', [{ ...(taskFixtures[0] as Task), status: 'finished' }]], + ['unknown priority enum', [{ ...(taskFixtures[0] as Task), priority: 'urgent' }]], + ['tags of the wrong type', [{ ...(taskFixtures[0] as Task), tags: 'spa' }]], + ['metadata of the wrong type', [{ ...(taskFixtures[0] as Task), metadata: 'notes' }]], + ['createdAt of the wrong type', [{ ...(taskFixtures[0] as Task), createdAt: 1234 }]], + ['null sneaks past a required string', [{ ...(taskFixtures[0] as Task), title: null }]], + ])('rejects a malformed payload: %s', (_label, value) => { + expect(validateTaskCollection(value)).toBeNull(); + }); +}); + +describe('validateMissionCollection', () => { + it('accepts a well-formed mission collection', () => { + expect(validateMissionCollection(missionFixtures)).toEqual({ + data: missionFixtures, + workspace: null, + }); + }); + + it.each([ + ['not an array', null], + ['item missing name', [{ ...(missionFixtures[0] as Mission), name: 42 }]], + ['unknown status enum', [{ ...(missionFixtures[0] as Mission), status: 'canceled' }]], + ['projectId of the wrong type', [{ ...(missionFixtures[0] as Mission), projectId: 7 }]], + ])('rejects a malformed payload: %s', (_label, value) => { + expect(validateMissionCollection(value)).toBeNull(); + }); +}); + +describe('validateProjectCollection', () => { + it('accepts a uniform workspace-scoped collection and reports its workspace', () => { + expect(validateProjectCollection(projectFixtures)).toEqual({ + data: projectFixtures, + workspace: 'user-1', + }); + }); + + it('accepts an empty collection with no workspace identity', () => { + expect(validateProjectCollection([])).toEqual({ data: [], workspace: null }); + }); + + it.each([ + ['not an array', 42], + ['item missing userId', [{ ...(projectFixtures[0] as Project), userId: undefined }]], + ['unknown status enum', [{ ...(projectFixtures[0] as Project), status: 'live' }]], + ['description of the wrong type', [{ ...(projectFixtures[0] as Project), description: 1 }]], + ])('rejects a malformed payload: %s', (_label, value) => { + expect(validateProjectCollection(value)).toBeNull(); + }); + + it('rejects a collection mixing workspace identities (cross-workspace leak)', () => { + const mixed = [ + projectFixtures[0] as Project, + { ...(projectFixtures[1] as Project), userId: 'user-2' }, + ]; + expect(validateProjectCollection(mixed)).toBeNull(); + }); +}); + +describe('validateProjectEntity', () => { + it('accepts a well-formed project and reports its workspace', () => { + expect(validateProjectEntity(projectFixtures[0])).toEqual({ + data: projectFixtures[0], + workspace: 'user-1', + }); + }); + + it.each([ + ['not an object', 'project-1'], + ['null', null], + ['array', [projectFixtures[0]]], + ['missing userId', [{ ...(projectFixtures[0] as Project), userId: null }]], + ])('rejects a malformed entity: %s', (_label, value) => { + expect(validateProjectEntity(value)).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/freshness/validators.ts b/apps/web/src/lib/freshness/validators.ts new file mode 100644 index 00000000..6c51c8a1 --- /dev/null +++ b/apps/web/src/lib/freshness/validators.ts @@ -0,0 +1,135 @@ +import type { Mission, Project, Task, MissionStatus, TaskPriority, TaskStatus } from '@/lib/types'; +import type { FreshPayload } from './model'; + +/** + * Runtime schema validators for gateway collections (RI-5-001). + * + * `api()` returns untrusted JSON cast to `T`; these validators are the + * seam where a malformed response becomes an explicit schema mismatch + * instead of flowing into the render path as if it were healthy data. + */ + +const taskStatuses: readonly TaskStatus[] = [ + 'not-started', + 'in-progress', + 'blocked', + 'done', + 'cancelled', +]; +const taskPriorities: readonly TaskPriority[] = ['critical', 'high', 'medium', 'low']; +const missionStatuses: readonly MissionStatus[] = [ + 'planning', + 'active', + 'paused', + 'completed', + 'failed', +]; +const projectStatuses: readonly Project['status'][] = ['active', 'paused', 'completed', 'archived']; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === 'string'; +} + +function isOneOf(value: unknown, allowed: readonly T[]): value is T { + return typeof value === 'string' && (allowed as readonly string[]).includes(value); +} + +function isNullableRecord(value: unknown): value is Record | null { + return value === null || isRecord(value); +} + +function isNullableStringArray(value: unknown): value is string[] | null { + if (value === null) return true; + if (!Array.isArray(value)) return false; + return value.every((item) => typeof item === 'string'); +} + +function isIsoLike(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function isTask(value: unknown): value is Task { + if (!isRecord(value)) return false; + return ( + isString(value['id']) && + isString(value['title']) && + isOneOf(value['status'], taskStatuses) && + isOneOf(value['priority'], taskPriorities) && + isNullableString(value['projectId']) && + isNullableString(value['missionId']) && + isNullableString(value['assignee']) && + isNullableStringArray(value['tags']) && + isNullableRecord(value['metadata']) && + isNullableString(value['dueDate']) && + isIsoLike(value['createdAt']) && + isIsoLike(value['updatedAt']) + ); +} + +/** Tasks carry no workspace identity; scope falls back to the policy. */ +export function validateTaskCollection(value: unknown): FreshPayload | null { + if (!Array.isArray(value) || !value.every(isTask)) return null; + return { data: value as Task[], workspace: null }; +} + +function isMission(value: unknown): value is Mission { + if (!isRecord(value)) return false; + return ( + isString(value['id']) && + isString(value['name']) && + isOneOf(value['status'], missionStatuses) && + isNullableString(value['projectId']) && + isNullableString(value['description']) && + isNullableRecord(value['metadata']) && + isIsoLike(value['createdAt']) && + isIsoLike(value['updatedAt']) + ); +} + +/** Missions carry no workspace identity; scope falls back to the policy. */ +export function validateMissionCollection(value: unknown): FreshPayload | null { + if (!Array.isArray(value) || !value.every(isMission)) return null; + return { data: value as Mission[], workspace: null }; +} + +function isProject(value: unknown): value is Project { + if (!isRecord(value)) return false; + return ( + isString(value['id']) && + isString(value['name']) && + isOneOf(value['status'], projectStatuses) && + isString(value['userId']) && + isNullableString(value['description']) && + isNullableRecord(value['metadata']) && + isIsoLike(value['createdAt']) && + isIsoLike(value['updatedAt']) + ); +} + +/** + * Projects are workspace-scoped: every item must carry the same `userId`. + * A collection mixing identities (cross-workspace leak) is a schema + * mismatch; the uniform `userId` becomes the snapshot workspace. + */ +export function validateProjectCollection(value: unknown): FreshPayload | null { + if (!Array.isArray(value) || !value.every(isProject)) return null; + const projects = value as Project[]; + const workspaces = new Set(projects.map((project) => project.userId)); + if (workspaces.size > 1) return null; + return { data: projects, workspace: projects.length > 0 ? projects[0]!.userId : null }; +} + +/** Single project entity (project detail primary collection). */ +export function validateProjectEntity(value: unknown): FreshPayload | null { + if (!isProject(value)) return null; + const project = value as Project; + return { data: project, workspace: project.userId }; +} diff --git a/apps/web/src/lib/socket.spec.ts b/apps/web/src/lib/socket.spec.ts new file mode 100644 index 00000000..6d572df4 --- /dev/null +++ b/apps/web/src/lib/socket.spec.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { ioMock } = vi.hoisted(() => ({ + ioMock: vi.fn(), +})); + +vi.mock('socket.io-client', () => ({ + io: ioMock, +})); + +import { destroySocket, getSocket } from './socket'; + +interface MockChatSocket { + on: ReturnType; + offAny: ReturnType; + disconnect: ReturnType; + /** 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 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); + }); +}); diff --git a/apps/web/src/lib/socket.ts b/apps/web/src/lib/socket.ts index 6d966c9c..62722076 100644 --- a/apps/web/src/lib/socket.ts +++ b/apps/web/src/lib/socket.ts @@ -1,23 +1,27 @@ -import { io, type Socket } from 'socket.io-client'; +import { io } from 'socket.io-client'; +import type { ChatSocket } from './chat-contract'; -const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242'; +let socket: ChatSocket | null = null; -let socket: Socket | null = null; - -export function getSocket(): Socket { +export function getSocket(): ChatSocket { if (!socket) { - socket = io(`${GATEWAY_URL}/chat`, { + // socket.io-client 4.8.3's `io()` factory declaration always returns the + // default unparameterized Socket (it accepts no + // 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, autoConnect: false, transports: ['websocket', 'polling'], - }); + }) as unknown as ChatSocket; - // Reset singleton reference when socket is fully closed so the next - // getSocket() call creates a fresh instance instead of returning a - // closed/dead socket. - socket.on('disconnect', () => { - socket = null; - }); + // A transient `disconnect` (network blip, server restart) must NOT null + // the singleton: socket.io-client auto-reconnects this same instance, + // and its listeners stay registered across that reconnect. Nulling here + // previously orphaned those listeners on the next getSocket() call by + // handing back a brand-new, unconnected instance. Only destroySocket() + // (an explicit, intentional teardown) may reset the singleton. } return socket; } diff --git a/apps/web/src/lib/sso-providers.test.ts b/apps/web/src/lib/sso-providers.test.ts deleted file mode 100644 index 076d32be..00000000 --- a/apps/web/src/lib/sso-providers.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getEnabledSsoProviders, getSsoProvider } from './sso-providers'; - -describe('sso-providers', () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('returns the enabled providers in login button order', () => { - vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true'); - vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true'); - - expect(getEnabledSsoProviders()).toEqual([ - { - id: 'workos', - buttonLabel: 'Continue with WorkOS', - description: 'Enterprise SSO via WorkOS', - enabled: true, - href: '/auth/provider/workos', - }, - { - id: 'keycloak', - buttonLabel: 'Continue with Keycloak', - description: 'Enterprise SSO via Keycloak', - enabled: true, - href: '/auth/provider/keycloak', - }, - ]); - }); - - it('marks disabled providers without exposing them in the enabled list', () => { - vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true'); - vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false'); - - expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']); - expect(getSsoProvider('keycloak')).toEqual({ - id: 'keycloak', - buttonLabel: 'Continue with Keycloak', - description: 'Enterprise SSO via Keycloak', - enabled: false, - href: '/auth/provider/keycloak', - }); - }); - - it('returns null for unknown providers', () => { - expect(getSsoProvider('authentik')).toBeNull(); - }); -}); diff --git a/apps/web/src/lib/sso-providers.ts b/apps/web/src/lib/sso-providers.ts deleted file mode 100644 index a9f42026..00000000 --- a/apps/web/src/lib/sso-providers.ts +++ /dev/null @@ -1,53 +0,0 @@ -export type SsoProviderId = 'workos' | 'keycloak'; - -export interface SsoProvider { - id: SsoProviderId; - buttonLabel: string; - description: string; - enabled: boolean; - href: string; -} - -const PROVIDER_METADATA: Record> = { - 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'; - } -} diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 0f46823b..7b48176e 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -1,3 +1,42 @@ +import type { + HarnessAuthState, + HarnessModelAvailability, + HarnessSelection, +} from '@mosaicstack/types'; + +// The exact harness/provider/model tuple and its closed enum companions are the +// shared domain types — re-exported here so web consumers (and the runtime +// guards) import one shape, never a divergent local redefinition. +export type { HarnessSelection, HarnessAuthState, HarnessModelAvailability }; + +/** Harness summary row from `GET /api/harnesses` (the `HarnessSummaryDto`). The + * harness id is kept distinct from any provider id — they are never merged. */ +export interface HarnessSummary { + id: string; + displayName: string; + capabilities: string[]; +} + +/** One selectable model in a harness catalog. Extends the `{harnessId, + * providerId, modelId}` tuple with the display/availability metadata the UI + * needs; `inputTypes` is kept as a plain `string[]` on the client boundary + * because it arrives from untrusted JSON and is only ever displayed. */ +export interface HarnessCatalogEntry extends HarnessSelection { + displayName: string; + reasoningCapability: boolean; + inputTypes: string[]; + authState: HarnessAuthState; + availability: HarnessModelAvailability; +} + +/** Harness-scoped catalog from `GET /api/harnesses/:harnessId/catalog`. */ +export interface HarnessCatalog { + harnessId: string; + version: string; + fingerprint: string; + models: HarnessCatalogEntry[]; +} + /** Conversation returned by the gateway API. */ export interface Conversation { id: string; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 00000000..8fdcf7b3 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,19 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { RouterProvider } from 'react-router-dom'; +import { ThemeProvider } from '@/providers/theme-provider'; +import { createAppRouter } from '@/routes'; +import '@/app/globals.css'; + +const container = document.getElementById('root'); +if (!container) { + throw new Error('missing #root element'); +} + +createRoot(container).render( + + + + + , +); diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx new file mode 100644 index 00000000..0dd525ec --- /dev/null +++ b/apps/web/src/routes.tsx @@ -0,0 +1,67 @@ +import type { ReactElement } from 'react'; +import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-router-dom'; +import { LoginPage } from '@/spa/pages/login'; +import { RegisterPage } from '@/spa/pages/register'; +import { SsoCallbackPage } from '@/spa/pages/sso-callback'; +import { ChatPage } from '@/spa/pages/chat'; +import { ChatRouteErrorBoundary } from '@/spa/pages/chat-error-boundary'; +import { ProjectDetailPage } from '@/spa/pages/project-detail'; +import { ProjectsPage } from '@/spa/pages/projects'; +import { + ProjectDetailRouteErrorBoundary, + ProjectsRouteErrorBoundary, + TasksRouteErrorBoundary, +} from '@/spa/pages/resource-route-error-boundaries'; +import { TasksPage } from '@/spa/pages/tasks'; +import { AuthGuard, GuestGuard } from '@/spa/guards'; +import { Placeholder } from '@/spa/placeholder'; + +function GuestLayout(): ReactElement { + return ( +
+
+ +
+
+ ); +} + +export const routes: RouteObject[] = [ + { + element: , + children: [ + { + element: , + children: [ + { path: '/login', element: }, + { path: '/register', element: }, + { path: '/auth/provider/:provider', element: }, + ], + }, + ], + }, + { + element: , + children: [ + { path: '/', element: }, + { path: '/chat', element: , errorElement: }, + { + path: '/projects', + element: , + errorElement: , + }, + { + path: '/projects/:id', + element: , + errorElement: , + }, + { path: '/tasks', element: , errorElement: }, + { path: '/settings', element: }, + { path: '/admin', element: }, + ], + }, +]; + +export function createAppRouter(): ReturnType { + return createBrowserRouter(routes); +} diff --git a/apps/web/src/spa/chat/chat-api.spec.ts b/apps/web/src/spa/chat/chat-api.spec.ts new file mode 100644 index 00000000..3d02c01a --- /dev/null +++ b/apps/web/src/spa/chat/chat-api.spec.ts @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + fetchCatalog, + fetchHarnesses, + fetchPersistedSelection, + persistSelection, +} from './chat-api'; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function stubFetch(): ReturnType { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +/** Every URL the client actually requested, across all calls. */ +function requestedUrls(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls.map((call) => String(call[0])); +} + +describe('chat-api', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('fetchHarnesses GETs /api/harnesses and returns typed summaries (harness id separate from provider)', async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValue( + json([ + { id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] }, + { id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] }, + ]), + ); + + const harnesses = await fetchHarnesses(); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses'); + expect(harnesses).toEqual([ + { id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] }, + { id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] }, + ]); + }); + + it('fetchCatalog GETs the harness-scoped catalog and returns only its model entries', async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValue( + json({ + harnessId: 'pi', + version: '2026-08-11', + fingerprint: 'abc123', + models: [ + { + harnessId: 'pi', + providerId: 'openai', + modelId: 'gpt-5', + displayName: 'GPT-5', + reasoningCapability: true, + inputTypes: ['text'], + authState: 'ready', + availability: 'available', + }, + ], + }), + ); + + const result = await fetchCatalog('pi'); + + expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses/pi/catalog'); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('expected ok catalog'); + expect(result.catalog.harnessId).toBe('pi'); + expect(result.catalog.models).toHaveLength(1); + expect(result.catalog.models[0]).toMatchObject({ + harnessId: 'pi', + providerId: 'openai', + modelId: 'gpt-5', + availability: 'available', + }); + }); + + it('normalizes a catalog 404 into a typed catalog_unavailable result without surfacing the raw body', async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValue( + json( + { + code: 'adapter_unavailable', + message: 'raw gateway detail that must not leak verbatim', + harnessId: 'attacker-echo', + extra: { hostile: 'blob' }, + }, + 404, + ), + ); + + const result = await fetchCatalog('ghost'); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected unavailable result'); + expect(result.code).toBe('catalog_unavailable'); + // harnessId comes from the request, never the (untrusted) response body. + expect(result.harnessId).toBe('ghost'); + expect(typeof result.message).toBe('string'); + // The raw response body is never rendered/returned verbatim. + expect(JSON.stringify(result)).not.toContain('hostile'); + expect(JSON.stringify(result)).not.toContain('attacker-echo'); + }); + + it('fetchPersistedSelection returns the stored tuple, or null when unset', async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce( + json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }), + ); + await expect(fetchPersistedSelection()).resolves.toEqual({ + harnessId: 'pi', + providerId: 'openai', + modelId: 'gpt-5', + }); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/chat/preferences/selection'); + + fetchMock.mockResolvedValueOnce(json({ selection: null })); + await expect(fetchPersistedSelection()).resolves.toBeNull(); + }); + + it('persistSelection PUTs the structured tuple (not free text) and returns the confirmed selection', async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValue( + json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }), + ); + + const result = await persistSelection({ + harnessId: 'pi', + providerId: 'openai', + modelId: 'gpt-5', + }); + + expect(result.ok).toBe(true); + const call = fetchMock.mock.calls[0]; + expect(String(call?.[0])).toBe('/api/chat/preferences/selection'); + const init = call?.[1] as RequestInit; + expect(String(init.method).toUpperCase()).toBe('PUT'); + // The body is exactly the structured tuple — harness/provider/model kept distinct. + expect(JSON.parse(String(init.body))).toEqual({ + harnessId: 'pi', + providerId: 'openai', + modelId: 'gpt-5', + }); + }); + + it('normalizes a selection 422 into a typed error preserving the requested tuple exactly', async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValue( + json( + { + code: 'model_unavailable', + message: 'raw detail that must not leak', + selection: { harnessId: 'x', providerId: 'y', modelId: 'z' }, + }, + 422, + ), + ); + + const requested = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' }; + const result = await persistSelection(requested); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected failed persist'); + expect(['selection_invalid', 'model_unavailable']).toContain(result.code); + // The requested tuple is preserved unchanged — not replaced by the body's echo. + expect(result.requested).toEqual(requested); + expect(JSON.stringify(result)).not.toContain('raw detail'); + }); + + it('never requests any /api/providers* endpoint', async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValue(json([])); + await fetchHarnesses(); + fetchMock.mockResolvedValue( + json({ harnessId: 'pi', version: '1', fingerprint: 'f', models: [] }), + ); + await fetchCatalog('pi'); + fetchMock.mockResolvedValue(json({ selection: null })); + await fetchPersistedSelection(); + + for (const url of requestedUrls(fetchMock)) { + expect(url).not.toContain('/api/providers'); + } + }); +}); diff --git a/apps/web/src/spa/chat/chat-api.ts b/apps/web/src/spa/chat/chat-api.ts new file mode 100644 index 00000000..ba8e20fd --- /dev/null +++ b/apps/web/src/spa/chat/chat-api.ts @@ -0,0 +1,132 @@ +/** + * Typed fetch wrappers for the Task-3 harness HTTP contract the chat selection + * UI depends on. Every response body is untrusted and is normalized through the + * runtime guards before it reaches state — a 404 (catalog) and a 422 (selection) + * are mapped to typed, body-free error results so a raw gateway body is never + * rendered, and the caller's requested tuple is preserved verbatim on failure. + * + * This module talks ONLY to the harness/chat-preferences endpoints. It never + * calls `/api/providers*` — provider identity lives inside the harness catalog. + */ +import { asHarnessCatalog, asHarnessSelection, asHarnessSummaries } from './runtime-guards'; +import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types'; + +/** A catalog fetch either yields the typed catalog or a typed unavailability — + * never a thrown raw body. */ +export type CatalogResult = + | { ok: true; catalog: HarnessCatalog } + | { ok: false; code: 'catalog_unavailable'; harnessId: string; message: string }; + +export type SelectionErrorCode = 'selection_invalid' | 'model_unavailable'; + +/** A persist either confirms the stored tuple or reports a typed domain failure + * that echoes back the exact tuple the caller requested. */ +export type SelectionPersistResult = + | { ok: true; selection: HarnessSelection } + | { ok: false; code: SelectionErrorCode; message: string; requested: HarnessSelection }; + +/** A safe, generic message for an unavailable catalog — the raw 404 body is + * never surfaced. */ +const CATALOG_UNAVAILABLE_MESSAGE = 'This harness catalog is currently unavailable.'; + +/** A safe, generic message for a rejected selection. The untrusted 422 body's + * own `message` is deliberately NEVER surfaced — only this fixed copy — so a + * raw gateway detail can never leak into the UI. Only the closed `code` enum is + * read from the body. */ +const SELECTION_REJECTED_MESSAGE = 'This selection was rejected.'; + +async function readJson(response: Response): Promise { + return response.json().catch(() => null); +} + +function safeSelectionCode(body: unknown): SelectionErrorCode { + if (typeof body === 'object' && body !== null && 'code' in body) { + const code = (body as { code: unknown }).code; + if (code === 'selection_invalid' || code === 'model_unavailable') return code; + } + // Default to the more conservative "invalid" classification for anything + // unrecognized rather than guessing "model_unavailable". + return 'selection_invalid'; +} + +/** `GET /api/harnesses` → the list of harness summaries. A non-OK response + * normalizes to an empty list (the UI then has no harness to select). */ +export async function fetchHarnesses(): Promise { + const response = await fetch('/api/harnesses', { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) return []; + return asHarnessSummaries(await readJson(response)); +} + +/** `GET /api/harnesses/:harnessId/catalog` → the harness-scoped catalog. A 404 + * (or any non-OK) becomes a typed `catalog_unavailable` result rather than a + * fallback catalog or a rendered raw body. */ +export async function fetchCatalog(harnessId: string): Promise { + const response = await fetch(`/api/harnesses/${encodeURIComponent(harnessId)}/catalog`, { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + return { + ok: false, + code: 'catalog_unavailable', + // Scoped to the requested harness id, never the untrusted body's echo. + harnessId, + message: CATALOG_UNAVAILABLE_MESSAGE, + }; + } + return { ok: true, catalog: asHarnessCatalog(await readJson(response), harnessId) }; +} + +/** `GET /api/chat/preferences/selection` → the persisted tuple, or null when + * unset or malformed. */ +export async function fetchPersistedSelection(): Promise { + const response = await fetch('/api/chat/preferences/selection', { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) return null; + const body = await readJson(response); + if (typeof body !== 'object' || body === null) return null; + return asHarnessSelection((body as { selection?: unknown }).selection); +} + +/** `PUT /api/chat/preferences/selection` with the structured tuple as the body. + * On success returns the confirmed selection; on a typed domain failure (422) + * or validation error, returns a typed result carrying the EXACT requested + * tuple — never the body's echo — and never the raw body text. */ +export async function persistSelection( + selection: HarnessSelection, +): Promise { + const requested: HarnessSelection = { + harnessId: selection.harnessId, + providerId: selection.providerId, + modelId: selection.modelId, + }; + const response = await fetch('/api/chat/preferences/selection', { + method: 'PUT', + credentials: 'include', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify(requested), + }); + if (!response.ok) { + const body = await readJson(response); + return { + ok: false, + code: safeSelectionCode(body), + // Fixed copy only — the untrusted body's message is never surfaced. + message: SELECTION_REJECTED_MESSAGE, + requested, + }; + } + const body = await readJson(response); + const confirmed = + typeof body === 'object' && body !== null + ? asHarnessSelection((body as { selection?: unknown }).selection) + : null; + // A malformed 2xx body is treated as a confirmation of exactly what we sent — + // the server accepted the tuple, so the requested tuple is the source of truth. + return { ok: true, selection: confirmed ?? requested }; +} diff --git a/apps/web/src/spa/chat/commands-panel.spec.tsx b/apps/web/src/spa/chat/commands-panel.spec.tsx new file mode 100644 index 00000000..fccc3a08 --- /dev/null +++ b/apps/web/src/spa/chat/commands-panel.spec.tsx @@ -0,0 +1,280 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { CommandsPanel } from './commands-panel'; + +beforeAll(() => { + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + configurable: true, + value: true, + }); +}); + +afterAll(() => { + Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); +}); + +let root: Root | null; +let container: HTMLElement | null; + +async function render(node: Parameters[0]): Promise { + 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( + , + ); + + // 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[0]['manifest']; + + await expect( + render( + , + ), + ).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( + , + ); + + expect(container?.textContent?.toLowerCase()).toContain('no args'); + }); + + it('renders skills from a skills-only manifest', async () => { + await render( + , + ); + + 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[0]['approval']; + + await render( + , + ); + + 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( + , + ); + + expect(container?.textContent).toContain('Not authorized'); + }); + + it('falls back to a stable "Denied." copy when a denial has no usable message', async () => { + await render( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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[0]['manifest']; + + await expect( + render( + , + ), + ).resolves.not.toThrow(); + }); +}); diff --git a/apps/web/src/spa/chat/commands-panel.tsx b/apps/web/src/spa/chat/commands-panel.tsx new file mode 100644 index 00000000..827f17e1 --- /dev/null +++ b/apps/web/src/spa/chat/commands-panel.tsx @@ -0,0 +1,164 @@ +import { useState, type ReactElement } from 'react'; +import type { PendingApproval } from './use-chat-connection'; +import { MAX_COMMAND_MESSAGE_CHARS } from './limits'; +import { asNonEmptyString, asString } from './runtime-guards'; +import type { + CommandManifest, + SlashCommandApprovalResultPayload, + SlashCommandResultPayload, +} from '@/lib/chat-contract'; + +/** Stable fallback copy shown for a failed command only when the server's + * own guarded, non-empty `message` (e.g. "Unknown model") is absent or + * malformed — the structured contract reason itself is otherwise shown + * directly, never a raw thrown exception, stack trace, or object value. */ +const COMMAND_FAILURE_COPY = 'Command failed.'; + +/** Render-site defense-in-depth: `use-chat-connection.ts` already bounds a + * stored command:result message at ingestion, but this component must never + * assume every caller went through that path — bounding again here means a + * hostile/oversized message can never force an unbounded render. */ +function boundMessage(value: string): string { + return value.length > MAX_COMMAND_MESSAGE_CHARS + ? value.slice(0, MAX_COMMAND_MESSAGE_CHARS) + : value; +} + +interface CommandsPanelProps { + manifest: CommandManifest | null; + results: SlashCommandResultPayload[]; + approval: SlashCommandApprovalResultPayload | null; + pendingApproval: PendingApproval | null; + hasConversation: boolean; + onExecute: (input: { command: string; args?: string }) => void; + onApprove: (input: { command: string; args?: string }) => void; + onRunApproved: () => void; +} + +export function CommandsPanel({ + manifest, + results, + approval, + pendingApproval, + hasConversation, + onExecute, + onApprove, + onRunApproved, +}: CommandsPanelProps): ReactElement { + const [command, setCommand] = useState(''); + const [args, setArgs] = useState(''); + + // Defense-in-depth: the reducer already normalizes success/approvalId + // before storing `approval`, but a matching command string alone must + // never be trusted here either — require the literal boolean `true` and a + // non-empty string approvalId, not merely truthy values. + const canRunApproved = + approval?.success === true && + typeof approval.approvalId === 'string' && + approval.approvalId.length > 0 && + !!pendingApproval && + pendingApproval.command === approval.command; + + // A manifest arrives from the server as untyped JSON at runtime — guard + // both collections before mapping so a malformed manifest cannot throw. + const commands = Array.isArray(manifest?.commands) ? manifest.commands : []; + const skills = Array.isArray(manifest?.skills) ? manifest.skills : []; + + return ( +
+ {commands.length > 0 ? ( +
    + {commands.map((cmd, index) => ( +
  • + /{asString(cmd?.name)} — {asString(cmd?.description)} +
  • + ))} +
+ ) : null} + + {skills.length > 0 ? ( +
    + {skills.map((skill, index) => ( +
  • + /skill:{asString(skill?.name)} — {asString(skill?.description)} +
  • + ))} +
+ ) : null} + +
+ setCommand(event.target.value)} + placeholder="command" + /> + setArgs(event.target.value)} + placeholder="args (optional)" + /> + + +
+ + {approval ? ( +
+ {/* 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. */} + + {approval.success ? 'Approved.' : asNonEmptyString(approval.message, 'Denied.')} + + {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. */} + + Will run: /{pendingApproval.command}{' '} + {pendingApproval.args ? pendingApproval.args : '(no args)'} + + + + ) : null} +
+ ) : null} + + {results.length > 0 ? ( +
    + {results.map((result, index) => ( +
  • + /{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))}`} +
  • + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/spa/chat/composer.tsx b/apps/web/src/spa/chat/composer.tsx new file mode 100644 index 00000000..07ae631d --- /dev/null +++ b/apps/web/src/spa/chat/composer.tsx @@ -0,0 +1,181 @@ +import { useState, type KeyboardEvent, type ReactElement } from 'react'; +import type { HarnessSelection } from '@/lib/types'; +import type { HarnessSelectionValue } from './use-harness-selection'; + +interface ComposerProps { + onSend: (input: { content: string; selection: HarnessSelection }) => boolean; + onStop: () => void; + streaming: boolean; + /** True from local send time through server turn startup/ack and + * throughout streaming — a superset of `streaming` that also covers the + * pre-ack window where a second send could otherwise slip through. */ + sending: boolean; + hasConversation: boolean; + /** Structured harness/provider/model selection state. The composer never + * accepts free-text provider/model — every sendable tuple is a validated, + * persisted catalog entry, and the send projection is derived from it. */ + harness: HarnessSelectionValue; +} + +/** The distinct provider ids present in the current catalog, in first-seen + * order — the provider select is catalog-derived, never a hardcoded list. */ +function providerOptions(harness: HarnessSelectionValue): string[] { + const seen = new Set(); + const out: string[] = []; + for (const model of harness.catalog?.models ?? []) { + if (seen.has(model.providerId)) continue; + seen.add(model.providerId); + out.push(model.providerId); + } + return out; +} + +export function Composer({ + onSend, + onStop, + streaming, + sending, + hasConversation, + harness, +}: ComposerProps): ReactElement { + const [content, setContent] = useState(''); + const busy = streaming || sending; + + function submit(): void { + if (busy) return; + // Send is gated on a validated, persisted catalog tuple — a draft or unset + // selection can never emit, so provider/model never travel as free text. + if (!harness.canSend || harness.persistedSelection === null) return; + const trimmed = content.trim(); + if (!trimmed) return; + // Pass the validated, persisted selection tuple only. The hook derives the + // wire projection (legacy `message` provider/model, or `turn:send`) from the + // negotiated `chat:send-capability` protocol — never from flat caller input. + const selection = harness.persistedSelection; + const ok = onSend({ content: trimmed, selection }); + // Clear the input only when the send was accepted — a refused turn (e.g. a + // failed idempotency mint) must retain the user's text so it is not lost. + if (ok) setContent(''); + } + + function handleKeyDown(event: KeyboardEvent): void { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + submit(); + } + } + + // Scope the model options to the intentionally selected provider. With no + // provider chosen (`providerId === ''`) nothing matches, so the model select + // offers only the placeholder — never a cross-provider row. + const models = (harness.catalog?.models ?? []).filter( + (model) => model.providerId === harness.providerId, + ); + // A collision-safe composite option identity covering the full provider+model + // tuple. The controlled select mirrors the same identity so the exact catalog + // row highlights (a bare modelId would collide across providers). + const modelOptionValue = (model: { providerId: string; modelId: string }): string => + `${model.providerId}:${model.modelId}`; + const selectedModelValue = harness.modelId ? `${harness.providerId}:${harness.modelId}` : ''; + + return ( +
{ + event.preventDefault(); + submit(); + }} + className="flex flex-col gap-2 border-t p-4" + > +
+ + + +
+ {harness.catalogUnavailable ? ( +

+ This harness catalog is currently unavailable. +

+ ) : null} + {harness.isStale ? ( +

+ The saved model is no longer available — pick another to continue. +

+ ) : null} + {harness.persistError ? ( +

+ {harness.persistError.message} +

+ ) : null} +
+