Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39312fa455 | ||
|
|
ed102bec80 | ||
|
|
449e80df18 | ||
|
|
ebf518215a | ||
|
|
aa0a7b5fa2 | ||
|
|
42ac19af48 | ||
|
|
f744f32214 | ||
|
|
8ff7aac0ca | ||
|
|
80a45b1e1c | ||
|
|
85d2108e4e | ||
|
|
16f91157a1 |
@@ -46,6 +46,10 @@ steps:
|
|||||||
# [0] of the pnpm chain, so severing that chain would silence it together
|
# [0] of the pnpm chain, so severing that chain would silence it together
|
||||||
# with everything it guards; this direct line keeps one instrument running.
|
# with everything it guards; this direct line keeps one instrument running.
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
||||||
|
# Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH
|
||||||
|
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
||||||
|
# joins CI directly rather than the exclusions file.
|
||||||
|
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
||||||
|
|
||||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||||
|
|||||||
+5
-104
@@ -1,5 +1,5 @@
|
|||||||
# Build, publish npm packages, and push Docker images
|
# Build, publish npm packages, and push Docker images
|
||||||
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
# Runs only on main branch push/tag
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||||
@@ -23,21 +23,9 @@ variables:
|
|||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- '.woodpecker/**'
|
- '.woodpecker/**'
|
||||||
- event: [push, manual]
|
|
||||||
branch: next
|
|
||||||
- &main_image_build_when
|
|
||||||
- event: tag
|
|
||||||
- event: [push, manual]
|
|
||||||
branch: main
|
|
||||||
path:
|
|
||||||
exclude:
|
|
||||||
- 'packages/mosaic/**'
|
|
||||||
- 'docs/**'
|
|
||||||
- '**/*.md'
|
|
||||||
- '.woodpecker/**'
|
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- branch: [main, next]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -115,84 +103,6 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
|
|
||||||
publish-next-npm:
|
|
||||||
image: *node_image
|
|
||||||
# Durable @next integration-line publish. Runs only on next; never writes
|
|
||||||
# the latest dist-tag and never commits the computed prerelease versions.
|
|
||||||
when:
|
|
||||||
- event: [push, manual]
|
|
||||||
branch: next
|
|
||||||
environment:
|
|
||||||
NPM_TOKEN:
|
|
||||||
from_secret: gitea_token
|
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
|
||||||
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
|
|
||||||
commands:
|
|
||||||
- *enable_pnpm
|
|
||||||
- |
|
|
||||||
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
|
|
||||||
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "$CI_PIPELINE_NUMBER" ]; then
|
|
||||||
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
|
||||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
|
||||||
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
|
|
||||||
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
|
|
||||||
node <<'NODE'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
|
||||||
const roots = ['apps', 'packages', 'plugins'];
|
|
||||||
const updated = [];
|
|
||||||
|
|
||||||
function walk(dir) {
|
|
||||||
if (!fs.existsSync(dir)) return;
|
|
||||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
const packagePath = path.join(fullPath, 'package.json');
|
|
||||||
if (fs.existsSync(packagePath)) updatePackage(packagePath);
|
|
||||||
walk(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePackage(packagePath) {
|
|
||||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
||||||
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
|
||||||
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
|
||||||
if (!stableMatch) {
|
|
||||||
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
|
||||||
}
|
|
||||||
const [, major, minor, patch] = stableMatch;
|
|
||||||
const oldVersion = manifest.version;
|
|
||||||
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
|
||||||
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
|
||||||
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const root of roots) walk(root);
|
|
||||||
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
|
||||||
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
|
||||||
for (const line of updated) console.log('[publish-next] ' + line);
|
|
||||||
NODE
|
|
||||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
|
||||||
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
|
||||||
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
|
|
||||||
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
|
|
||||||
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
|
||||||
depends_on:
|
|
||||||
- build
|
|
||||||
|
|
||||||
# TODO: Uncomment when ready to publish to npmjs.org
|
# TODO: Uncomment when ready to publish to npmjs.org
|
||||||
# publish-npmjs:
|
# publish-npmjs:
|
||||||
# image: *node_image
|
# image: *node_image
|
||||||
@@ -224,17 +134,8 @@ steps:
|
|||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||||
- |
|
- |
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[publish] next gateway publish is sha-only"
|
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
||||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
|
||||||
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||||
@@ -245,7 +146,7 @@ steps:
|
|||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *main_image_build_when
|
when: *image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -271,7 +172,7 @@ steps:
|
|||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *main_image_build_when
|
when: *image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
|
|||||||
@@ -30,16 +30,6 @@ This installs both components:
|
|||||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||||
|
|
||||||
### Install lanes
|
|
||||||
|
|
||||||
| Lane | Command | Use when | Source |
|
|
||||||
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
|
|
||||||
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
|
|
||||||
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
|
|
||||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
|
|
||||||
|
|
||||||
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
|
|
||||||
|
|
||||||
After install, the wizard runs automatically or you can invoke it manually:
|
After install, the wizard runs automatically or you can invoke it manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -371,9 +361,7 @@ The CLI also performs a background update check on every invocation (cached for
|
|||||||
bash tools/install.sh --check # Version check only
|
bash tools/install.sh --check # Version check only
|
||||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||||
bash tools/install.sh --next # Prerelease lane: source build from next
|
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||||
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
|
||||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
|
||||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,519 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation M3 single-gateway integration tests (FED-M3-10).
|
|
||||||
*
|
|
||||||
* Covers MILESTONES.md M3 acceptance:
|
|
||||||
* - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
|
|
||||||
* - #7: max_rows_per_query caps list results.
|
|
||||||
*
|
|
||||||
* Strategy:
|
|
||||||
* - Real PostgreSQL via @mosaicstack/db.
|
|
||||||
* - Mocked TLS context/Fastify request shim for FederationAuthGuard.
|
|
||||||
* - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
|
|
||||||
*
|
|
||||||
* Run:
|
|
||||||
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
|
|
||||||
* src/__tests__/integration/federation-m3-list.integration.test.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import 'reflect-metadata';
|
|
||||||
import * as crypto from 'node:crypto';
|
|
||||||
import type { ExecutionContext } from '@nestjs/common';
|
|
||||||
import { Test, type TestingModule } from '@nestjs/testing';
|
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
||||||
import {
|
|
||||||
and,
|
|
||||||
createDb,
|
|
||||||
eq,
|
|
||||||
federationGrants,
|
|
||||||
federationPeers,
|
|
||||||
inArray,
|
|
||||||
missionTasks,
|
|
||||||
missions,
|
|
||||||
projects,
|
|
||||||
tasks,
|
|
||||||
teamMembers,
|
|
||||||
teams,
|
|
||||||
type Db,
|
|
||||||
type DbHandle,
|
|
||||||
users,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import { DB } from '../../database/database.module.js';
|
|
||||||
import { GrantsService } from '../../federation/grants.service.js';
|
|
||||||
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
|
|
||||||
import { FederationScopeService } from '../../federation/server/scope.service.js';
|
|
||||||
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
|
|
||||||
import { ListController } from '../../federation/server/verbs/list.controller.js';
|
|
||||||
import {
|
|
||||||
makeMosaicIssuedCert,
|
|
||||||
makeSelfSignedCert,
|
|
||||||
} from '../../federation/__tests__/helpers/test-cert.js';
|
|
||||||
|
|
||||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
|
||||||
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
|
||||||
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
|
|
||||||
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();
|
|
||||||
|
|
||||||
interface TestIds {
|
|
||||||
readonly subjectUserId: string;
|
|
||||||
readonly otherUserId: string;
|
|
||||||
readonly peerId: string;
|
|
||||||
readonly revokedPeerId: string;
|
|
||||||
readonly activeGrantId: string;
|
|
||||||
readonly revokedGrantId: string;
|
|
||||||
readonly subjectProjectId: string;
|
|
||||||
readonly subjectMissionId: string;
|
|
||||||
readonly otherProjectId: string;
|
|
||||||
readonly teamId: string;
|
|
||||||
readonly unauthorizedTeamId: string;
|
|
||||||
readonly teamProjectId: string;
|
|
||||||
readonly taskIds: readonly string[];
|
|
||||||
readonly excludedTaskIds: readonly string[];
|
|
||||||
readonly subjectNoteId: string;
|
|
||||||
readonly otherUserNoteId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pemToDer(pem: string): Buffer {
|
|
||||||
return Buffer.from(
|
|
||||||
pem
|
|
||||||
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
|
||||||
.replace(/-----END CERTIFICATE-----/, '')
|
|
||||||
.replace(/\s+/g, ''),
|
|
||||||
'base64',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeFederationRequest(certPem: string): FastifyRequest {
|
|
||||||
return {
|
|
||||||
raw: {
|
|
||||||
socket: {
|
|
||||||
getPeerCertificate: () => ({
|
|
||||||
raw: pemToDer(certPem),
|
|
||||||
serialNumber: CERT_SERIAL_HEX,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as FastifyRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeGuardContext(request: FastifyRequest): {
|
|
||||||
readonly context: ExecutionContext;
|
|
||||||
readonly sent: { statusCode?: number; payload?: unknown };
|
|
||||||
} {
|
|
||||||
const sent: { statusCode?: number; payload?: unknown } = {};
|
|
||||||
const reply = {
|
|
||||||
status: (statusCode: number) => {
|
|
||||||
sent.statusCode = statusCode;
|
|
||||||
return {
|
|
||||||
header: () => ({
|
|
||||||
send: (payload: unknown) => {
|
|
||||||
sent.payload = payload;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
} as unknown as FastifyReply;
|
|
||||||
|
|
||||||
const context = {
|
|
||||||
switchToHttp: () => ({
|
|
||||||
getRequest: () => request,
|
|
||||||
getResponse: () => reply,
|
|
||||||
}),
|
|
||||||
} as unknown as ExecutionContext;
|
|
||||||
|
|
||||||
return { context, sent };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function insertUser(db: Db, id: string, label: string): Promise<void> {
|
|
||||||
await db.insert(users).values({
|
|
||||||
id,
|
|
||||||
name: `${RUN_ID}-${label}`,
|
|
||||||
email: `${RUN_ID}-${label}@federation-test.invalid`,
|
|
||||||
emailVerified: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedFixtures(db: Db): Promise<TestIds> {
|
|
||||||
const subjectUserId = `${RUN_ID}-subject`;
|
|
||||||
const otherUserId = `${RUN_ID}-other`;
|
|
||||||
const peerId = crypto.randomUUID();
|
|
||||||
const revokedPeerId = crypto.randomUUID();
|
|
||||||
const activeGrantId = crypto.randomUUID();
|
|
||||||
const revokedGrantId = crypto.randomUUID();
|
|
||||||
const subjectProjectId = crypto.randomUUID();
|
|
||||||
const subjectMissionId = crypto.randomUUID();
|
|
||||||
const otherProjectId = crypto.randomUUID();
|
|
||||||
const teamId = crypto.randomUUID();
|
|
||||||
const unauthorizedTeamId = crypto.randomUUID();
|
|
||||||
const teamProjectId = crypto.randomUUID();
|
|
||||||
const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
|
|
||||||
const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
|
|
||||||
const subjectNoteId = crypto.randomUUID();
|
|
||||||
const otherUserNoteId = crypto.randomUUID();
|
|
||||||
|
|
||||||
await insertUser(db, subjectUserId, 'subject');
|
|
||||||
await insertUser(db, otherUserId, 'other');
|
|
||||||
|
|
||||||
await db.insert(teams).values([
|
|
||||||
{
|
|
||||||
id: teamId,
|
|
||||||
name: `${RUN_ID} allowed team`,
|
|
||||||
slug: `${RUN_ID}-allowed-team`,
|
|
||||||
ownerId: subjectUserId,
|
|
||||||
managerId: subjectUserId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: unauthorizedTeamId,
|
|
||||||
name: `${RUN_ID} unauthorized team`,
|
|
||||||
slug: `${RUN_ID}-unauthorized-team`,
|
|
||||||
ownerId: otherUserId,
|
|
||||||
managerId: otherUserId,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await db.insert(teamMembers).values([
|
|
||||||
{ teamId, userId: subjectUserId, role: 'member' },
|
|
||||||
{ teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
await db.insert(projects).values([
|
|
||||||
{
|
|
||||||
id: subjectProjectId,
|
|
||||||
name: `${RUN_ID} subject personal project`,
|
|
||||||
ownerType: 'user',
|
|
||||||
ownerId: subjectUserId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: otherProjectId,
|
|
||||||
name: `${RUN_ID} other personal project`,
|
|
||||||
ownerType: 'user',
|
|
||||||
ownerId: otherUserId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: teamProjectId,
|
|
||||||
name: `${RUN_ID} unauthorized team project`,
|
|
||||||
ownerType: 'team',
|
|
||||||
teamId: unauthorizedTeamId,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await db.insert(missions).values({
|
|
||||||
id: subjectMissionId,
|
|
||||||
name: `${RUN_ID} subject mission`,
|
|
||||||
projectId: subjectProjectId,
|
|
||||||
userId: subjectUserId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.insert(tasks).values([
|
|
||||||
{
|
|
||||||
id: taskIds[0],
|
|
||||||
title: `${RUN_ID} visible task 1`,
|
|
||||||
missionId: subjectMissionId,
|
|
||||||
createdAt: new Date('2026-06-25T03:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-25T03:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: taskIds[1],
|
|
||||||
title: `${RUN_ID} visible task 2`,
|
|
||||||
projectId: subjectProjectId,
|
|
||||||
createdAt: new Date('2026-06-25T02:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-25T02:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: taskIds[2],
|
|
||||||
title: `${RUN_ID} visible task 3`,
|
|
||||||
projectId: subjectProjectId,
|
|
||||||
createdAt: new Date('2026-06-25T01:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-25T01:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: excludedTaskIds[0],
|
|
||||||
title: `${RUN_ID} other user task`,
|
|
||||||
projectId: otherProjectId,
|
|
||||||
createdAt: new Date('2026-06-25T04:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-25T04:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: excludedTaskIds[1],
|
|
||||||
title: `${RUN_ID} unauthorized team task`,
|
|
||||||
projectId: teamProjectId,
|
|
||||||
createdAt: new Date('2026-06-25T05:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-25T05:00:00.000Z'),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await db.insert(missionTasks).values([
|
|
||||||
{
|
|
||||||
id: subjectNoteId,
|
|
||||||
missionId: subjectMissionId,
|
|
||||||
userId: subjectUserId,
|
|
||||||
notes: `${RUN_ID} subject visible note`,
|
|
||||||
createdAt: new Date('2026-06-25T03:30:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-25T03:30:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: otherUserNoteId,
|
|
||||||
missionId: subjectMissionId,
|
|
||||||
userId: otherUserId,
|
|
||||||
notes: `${RUN_ID} other user note on subject mission`,
|
|
||||||
createdAt: new Date('2026-06-25T04:30:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-25T04:30:00.000Z'),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await db.insert(federationPeers).values([
|
|
||||||
{
|
|
||||||
id: peerId,
|
|
||||||
commonName: `${RUN_ID}-active-peer`,
|
|
||||||
displayName: `${RUN_ID} Active Peer`,
|
|
||||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
|
||||||
certSerial: CERT_SERIAL_HEX,
|
|
||||||
certNotAfter: new Date(Date.now() + 86_400_000),
|
|
||||||
state: 'active',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: revokedPeerId,
|
|
||||||
commonName: `${RUN_ID}-revoked-peer`,
|
|
||||||
displayName: `${RUN_ID} Revoked Peer`,
|
|
||||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
|
||||||
certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
|
|
||||||
certNotAfter: new Date(Date.now() + 86_400_000),
|
|
||||||
state: 'active',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await db.insert(federationGrants).values([
|
|
||||||
{
|
|
||||||
id: activeGrantId,
|
|
||||||
peerId,
|
|
||||||
subjectUserId,
|
|
||||||
status: 'active',
|
|
||||||
scope: {
|
|
||||||
resources: ['tasks', 'notes'],
|
|
||||||
excluded_resources: [],
|
|
||||||
filters: {
|
|
||||||
tasks: { include_personal: true, include_teams: [] },
|
|
||||||
notes: { include_personal: true, include_teams: [] },
|
|
||||||
},
|
|
||||||
max_rows_per_query: 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: revokedGrantId,
|
|
||||||
peerId,
|
|
||||||
subjectUserId,
|
|
||||||
status: 'revoked',
|
|
||||||
revokedAt: new Date(),
|
|
||||||
revokedReason: `${RUN_ID} revoked grant fixture`,
|
|
||||||
scope: {
|
|
||||||
resources: ['tasks'],
|
|
||||||
excluded_resources: [],
|
|
||||||
max_rows_per_query: 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
subjectUserId,
|
|
||||||
otherUserId,
|
|
||||||
peerId,
|
|
||||||
revokedPeerId,
|
|
||||||
activeGrantId,
|
|
||||||
revokedGrantId,
|
|
||||||
subjectProjectId,
|
|
||||||
subjectMissionId,
|
|
||||||
otherProjectId,
|
|
||||||
teamId,
|
|
||||||
unauthorizedTeamId,
|
|
||||||
teamProjectId,
|
|
||||||
taskIds,
|
|
||||||
excludedTaskIds,
|
|
||||||
subjectNoteId,
|
|
||||||
otherUserNoteId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
|
|
||||||
if (!ids) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(missionTasks)
|
|
||||||
.where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.delete(tasks)
|
|
||||||
.where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.delete(missions)
|
|
||||||
.where(eq(missions.id, ids.subjectMissionId))
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.delete(projects)
|
|
||||||
.where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.delete(teamMembers)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(teamMembers.userId, ids.subjectUserId),
|
|
||||||
inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.delete(teams)
|
|
||||||
.where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.delete(federationGrants)
|
|
||||||
.where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.delete(federationPeers)
|
|
||||||
.where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.delete(users)
|
|
||||||
.where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
|
|
||||||
let handle: DbHandle;
|
|
||||||
let db: Db;
|
|
||||||
let moduleRef: TestingModule;
|
|
||||||
let guard: FederationAuthGuard;
|
|
||||||
let listController: ListController;
|
|
||||||
let ids: TestIds | undefined;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
handle = createDb(PG_URL);
|
|
||||||
db = handle.db;
|
|
||||||
ids = await seedFixtures(db);
|
|
||||||
|
|
||||||
moduleRef = await Test.createTestingModule({
|
|
||||||
controllers: [ListController],
|
|
||||||
providers: [
|
|
||||||
{ provide: DB, useValue: db },
|
|
||||||
GrantsService,
|
|
||||||
FederationAuthGuard,
|
|
||||||
FederationScopeService,
|
|
||||||
FederationListQueryService,
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
guard = moduleRef.get(FederationAuthGuard);
|
|
||||||
listController = moduleRef.get(ListController);
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
|
||||||
await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
|
||||||
await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
|
|
||||||
const malformedOidCert = await makeSelfSignedCert();
|
|
||||||
const request = makeFederationRequest(malformedOidCert);
|
|
||||||
const { context, sent } = makeGuardContext(request);
|
|
||||||
|
|
||||||
await expect(guard.canActivate(context)).resolves.toBe(false);
|
|
||||||
expect(sent.statusCode).toBe(401);
|
|
||||||
expect(sent.payload).toMatchObject({
|
|
||||||
error: {
|
|
||||||
code: 'unauthorized',
|
|
||||||
message: expect.stringContaining('missing required OID'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(request.federationContext).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
|
|
||||||
expect(ids).toBeDefined();
|
|
||||||
const revokedCert = await makeMosaicIssuedCert({
|
|
||||||
grantId: ids!.revokedGrantId,
|
|
||||||
subjectUserId: ids!.subjectUserId,
|
|
||||||
});
|
|
||||||
const request = makeFederationRequest(revokedCert);
|
|
||||||
const { context, sent } = makeGuardContext(request);
|
|
||||||
|
|
||||||
await expect(guard.canActivate(context)).resolves.toBe(false);
|
|
||||||
expect(sent.statusCode).toBe(403);
|
|
||||||
expect(sent.payload).toMatchObject({
|
|
||||||
error: {
|
|
||||||
code: 'forbidden',
|
|
||||||
message: 'Federation access denied',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(request.federationContext).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
|
|
||||||
expect(ids).toBeDefined();
|
|
||||||
const activeCert = await makeMosaicIssuedCert({
|
|
||||||
grantId: ids!.activeGrantId,
|
|
||||||
subjectUserId: ids!.subjectUserId,
|
|
||||||
});
|
|
||||||
const request = makeFederationRequest(activeCert);
|
|
||||||
const { context } = makeGuardContext(request);
|
|
||||||
|
|
||||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
|
||||||
|
|
||||||
const response = await listController.list('tasks', request, { limit: 100 });
|
|
||||||
const returnedIds = response.items.map((item) => item['id']);
|
|
||||||
|
|
||||||
expect(response.items).toHaveLength(2);
|
|
||||||
expect(response._truncated).toBe(true);
|
|
||||||
expect(response.nextCursor).toEqual(expect.any(String));
|
|
||||||
expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
|
|
||||||
expect(returnedIds).not.toContain(ids!.taskIds[2]);
|
|
||||||
for (const excludedId of ids!.excludedTaskIds) {
|
|
||||||
expect(returnedIds).not.toContain(excludedId);
|
|
||||||
}
|
|
||||||
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('excludes another user mission task notes on the same authorized mission', async () => {
|
|
||||||
expect(ids).toBeDefined();
|
|
||||||
const activeCert = await makeMosaicIssuedCert({
|
|
||||||
grantId: ids!.activeGrantId,
|
|
||||||
subjectUserId: ids!.subjectUserId,
|
|
||||||
});
|
|
||||||
const request = makeFederationRequest(activeCert);
|
|
||||||
const { context } = makeGuardContext(request);
|
|
||||||
|
|
||||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
|
||||||
|
|
||||||
const response = await listController.list('notes', request, { limit: 10 });
|
|
||||||
const returnedIds = response.items.map((item) => item['id']);
|
|
||||||
|
|
||||||
expect(returnedIds).toEqual([ids!.subjectNoteId]);
|
|
||||||
expect(returnedIds).not.toContain(ids!.otherUserNoteId);
|
|
||||||
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed for unsupported list resources', async () => {
|
|
||||||
expect(ids).toBeDefined();
|
|
||||||
const activeCert = await makeMosaicIssuedCert({
|
|
||||||
grantId: ids!.activeGrantId,
|
|
||||||
subjectUserId: ids!.subjectUserId,
|
|
||||||
});
|
|
||||||
const request = makeFederationRequest(activeCert);
|
|
||||||
const { context } = makeGuardContext(request);
|
|
||||||
|
|
||||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
|
||||||
|
|
||||||
await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
|
|
||||||
response: {
|
|
||||||
error: {
|
|
||||||
code: 'scope_violation',
|
|
||||||
message: 'Requested federation resource is not supported',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||||
import { sql, type Db } from '@mosaicstack/db';
|
import { sql, type Db } from '@mosaicstack/db';
|
||||||
import { createQueue } from '@mosaicstack/queue';
|
import { createQueue } from '@mosaicstack/queue';
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
import { ProviderService } from '../agent/provider.service.js';
|
import { ProviderService } from '../agent/provider.service.js';
|
||||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
|
||||||
import { AdminGuard } from './admin.guard.js';
|
import { AdminGuard } from './admin.guard.js';
|
||||||
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
||||||
|
|
||||||
@@ -16,9 +14,6 @@ export class AdminHealthController {
|
|||||||
@Inject(DB) private readonly db: Db,
|
@Inject(DB) private readonly db: Db,
|
||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||||
@Optional()
|
|
||||||
@Inject(MOSAIC_CONFIG)
|
|
||||||
private readonly mosaicConfig: MosaicConfig | null,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -60,14 +55,6 @@ export class AdminHealthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async checkCache(): Promise<ServiceStatusDto> {
|
private async checkCache(): Promise<ServiceStatusDto> {
|
||||||
// On Local tier there is no Redis. The cache is intentionally absent, which
|
|
||||||
// is a healthy state for this tier — report 'ok' rather than opening a new
|
|
||||||
// ioredis connection on every admin health check (which would spam
|
|
||||||
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
|
|
||||||
// signals "no cache backend to measure" for this tier.
|
|
||||||
if (this.mosaicConfig?.queue?.type === 'local') {
|
|
||||||
return { status: 'ok', latencyMs: 0 };
|
|
||||||
}
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const handle = createQueue();
|
const handle = createQueue();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||||
@@ -13,7 +12,6 @@ const mockRegistry = {
|
|||||||
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
||||||
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
{ name: 'mcp', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
|
||||||
],
|
],
|
||||||
skills: [],
|
skills: [],
|
||||||
})),
|
})),
|
||||||
@@ -74,24 +72,17 @@ const mockChatGateway = {
|
|||||||
broadcastSessionInfo: vi.fn(),
|
broadcastSessionInfo: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildService(
|
function buildService(): CommandExecutorService {
|
||||||
redis: typeof mockRedis | null = mockRedis,
|
|
||||||
mcpClient: {
|
|
||||||
reconnectServer: ReturnType<typeof vi.fn>;
|
|
||||||
getServerStatuses: ReturnType<typeof vi.fn>;
|
|
||||||
getToolDefinitions: ReturnType<typeof vi.fn>;
|
|
||||||
} | null = null,
|
|
||||||
): CommandExecutorService {
|
|
||||||
return new CommandExecutorService(
|
return new CommandExecutorService(
|
||||||
mockRegistry as never,
|
mockRegistry as never,
|
||||||
mockAgentService as never,
|
mockAgentService as never,
|
||||||
mockSystemOverride as never,
|
mockSystemOverride as never,
|
||||||
mockSessionGC as never,
|
mockSessionGC as never,
|
||||||
redis as never,
|
mockRedis as never,
|
||||||
mockBrain as never,
|
mockBrain as never,
|
||||||
null,
|
null,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
mcpClient as never,
|
null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,22 +131,6 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
expect(ttl).toBe(300);
|
expect(ttl).toBe(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/provider login remains available without Redis on the local tier', async () => {
|
|
||||||
const localService = buildService(null);
|
|
||||||
const payload: SlashCommandPayload = {
|
|
||||||
command: 'provider',
|
|
||||||
args: 'login anthropic',
|
|
||||||
conversationId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await localService.execute(payload, userScope);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.message).not.toContain('token=');
|
|
||||||
expect(result.data).toEqual({ provider: 'anthropic' });
|
|
||||||
expect(mockRedis.set).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
// /provider with no args — returns usage
|
// /provider with no args — returns usage
|
||||||
it('/provider with no args returns usage message', async () => {
|
it('/provider with no args returns usage message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||||
@@ -267,124 +242,4 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
expect(result.command).toBe('tools');
|
expect(result.command).toBe('tools');
|
||||||
expect(result.message).toContain('tools');
|
expect(result.message).toContain('tools');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Top-level catch sanitization (P3-4 re-review finding #1): a rejected
|
|
||||||
// Redis `set` inside /provider login is the only reachable path into the
|
|
||||||
// top-level catch in `execute()`. The raw exception must be logged
|
|
||||||
// server-side but never handed back to the socket client.
|
|
||||||
it('sanitizes the top-level command catch, logging the raw exception but never returning it to the client', async () => {
|
|
||||||
const distinctiveRawFailure = 'ECONNREFUSED distinctive-raw-redis-failure-token-9f31';
|
|
||||||
const rawError = new Error(distinctiveRawFailure);
|
|
||||||
const failingRedis = {
|
|
||||||
set: vi.fn().mockRejectedValue(rawError),
|
|
||||||
get: vi.fn(),
|
|
||||||
del: vi.fn(),
|
|
||||||
};
|
|
||||||
const failingService = buildService(failingRedis as unknown as typeof mockRedis);
|
|
||||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
const payload: SlashCommandPayload = {
|
|
||||||
command: 'provider',
|
|
||||||
args: 'login anthropic',
|
|
||||||
conversationId,
|
|
||||||
};
|
|
||||||
const result = await failingService.execute(payload, userScope);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.command).toBe('provider');
|
|
||||||
expect(result.message).toBe('Command failed due to an internal error.');
|
|
||||||
expect(result.message).not.toContain(distinctiveRawFailure);
|
|
||||||
expect(result.message).not.toContain('ECONNREFUSED');
|
|
||||||
|
|
||||||
// The real exception is still logged server-side, as the raw Error
|
|
||||||
// object itself (not stringified/interpolated into the log message).
|
|
||||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
|
||||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(rawError));
|
|
||||||
expect(loggedRawError).toBe(true);
|
|
||||||
|
|
||||||
loggerErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inner catch sanitization (P3-5 operator ruling): every catch in
|
|
||||||
// command-executor.service.ts that returns a SlashCommandResultPayload
|
|
||||||
// must sanitize the client-facing message the same way the top-level
|
|
||||||
// catch does, while still logging the raw exception server-side.
|
|
||||||
it('/agent new sanitizes agent-creation failures, logging the raw exception but never returning it to the client', async () => {
|
|
||||||
const marker = new Error('distinctive-agent-create-failure-token-A17f');
|
|
||||||
mockBrain.agents.create.mockRejectedValueOnce(marker);
|
|
||||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
const payload: SlashCommandPayload = {
|
|
||||||
command: 'agent',
|
|
||||||
args: 'new my-new-agent',
|
|
||||||
conversationId,
|
|
||||||
};
|
|
||||||
const result = await service.execute(payload, userScope);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.command).toBe('agent');
|
|
||||||
expect(result.message).toBe('Failed to create agent due to an internal error.');
|
|
||||||
expect(result.message).not.toContain('distinctive-agent-create-failure-token-A17f');
|
|
||||||
|
|
||||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
|
||||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
|
||||||
expect(loggedRawError).toBe(true);
|
|
||||||
|
|
||||||
loggerErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('/agent <name> switch sanitizes agent-lookup failures, logging the raw exception but never returning it to the client', async () => {
|
|
||||||
const marker = new Error('distinctive-agent-switch-failure-token-B29c');
|
|
||||||
mockBrain.agents.findByName.mockRejectedValueOnce(marker);
|
|
||||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
const payload: SlashCommandPayload = {
|
|
||||||
command: 'agent',
|
|
||||||
args: 'some-other-agent',
|
|
||||||
conversationId,
|
|
||||||
};
|
|
||||||
const result = await service.execute(payload, userScope);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.command).toBe('agent');
|
|
||||||
expect(result.message).toBe('Failed to switch agent due to an internal error.');
|
|
||||||
expect(result.message).not.toContain('distinctive-agent-switch-failure-token-B29c');
|
|
||||||
|
|
||||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
|
||||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
|
||||||
expect(loggedRawError).toBe(true);
|
|
||||||
|
|
||||||
loggerErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('/mcp reconnect sanitizes MCP client failures, logging the raw exception but never returning it to the client', async () => {
|
|
||||||
const marker = new Error('distinctive-mcp-reconnect-failure-token-C33e');
|
|
||||||
const mockMcpClient = {
|
|
||||||
reconnectServer: vi.fn().mockRejectedValue(marker),
|
|
||||||
getServerStatuses: vi.fn(() => []),
|
|
||||||
getToolDefinitions: vi.fn(() => []),
|
|
||||||
};
|
|
||||||
const mcpService = buildService(mockRedis, mockMcpClient);
|
|
||||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
const payload: SlashCommandPayload = {
|
|
||||||
command: 'mcp',
|
|
||||||
args: 'reconnect my-server',
|
|
||||||
conversationId,
|
|
||||||
};
|
|
||||||
const result = await mcpService.execute(payload, userScope);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.command).toBe('mcp');
|
|
||||||
expect(result.message).toBe(
|
|
||||||
'Failed to reconnect MCP server "my-server" due to an internal error.',
|
|
||||||
);
|
|
||||||
expect(result.message).not.toContain('distinctive-mcp-reconnect-failure-token-C33e');
|
|
||||||
|
|
||||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
|
||||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
|
||||||
expect(loggedRawError).toBe(true);
|
|
||||||
|
|
||||||
loggerErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ export class CommandExecutorService {
|
|||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||||
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
|
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
||||||
@Optional()
|
|
||||||
@Inject(COMMANDS_REDIS)
|
|
||||||
private readonly redis: QueueHandle['redis'] | null,
|
|
||||||
@Inject(BRAIN) private readonly brain: Brain,
|
@Inject(BRAIN) private readonly brain: Brain,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(forwardRef(() => ReloadService))
|
@Inject(forwardRef(() => ReloadService))
|
||||||
@@ -159,13 +156,8 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Command /${command} failed`, err);
|
this.logger.error(`Command /${command} failed: ${err}`);
|
||||||
return {
|
return { command, conversationId, success: false, message: String(err) };
|
||||||
command,
|
|
||||||
conversationId,
|
|
||||||
success: false,
|
|
||||||
message: 'Command failed due to an internal error.',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,11 +333,11 @@ export class CommandExecutorService {
|
|||||||
data: { agentId: newAgent.id, agentName: newAgent.name },
|
data: { agentId: newAgent.id, agentName: newAgent.name },
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to create agent "${namePart}" for user ${userId}`, err);
|
this.logger.error(`Failed to create agent: ${err}`);
|
||||||
return {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to create agent due to an internal error.',
|
message: `Failed to create agent: ${String(err)}`,
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -396,11 +388,11 @@ export class CommandExecutorService {
|
|||||||
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
|
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to switch agent "${agentName}"`, err);
|
this.logger.error(`Failed to switch agent "${agentName}": ${err}`);
|
||||||
return {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to switch agent due to an internal error.',
|
message: `Failed to switch agent: ${String(err)}`,
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -451,16 +443,14 @@ export class CommandExecutorService {
|
|||||||
byte.toString(16).padStart(2, '0'),
|
byte.toString(16).padStart(2, '0'),
|
||||||
).join('');
|
).join('');
|
||||||
const key = `mosaic:auth:poll:${tokenHash}`;
|
const key = `mosaic:auth:poll:${tokenHash}`;
|
||||||
if (this.redis) {
|
// Persist only a short-lived token digest. The raw token is delivered only by
|
||||||
// Persist only a short-lived token digest. The raw token is delivered only by
|
// the authenticated dashboard flow, never in chat output or command metadata.
|
||||||
// the authenticated dashboard flow, never in chat output or command metadata.
|
await this.redis.set(
|
||||||
await this.redis.set(
|
key,
|
||||||
key,
|
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
'EX',
|
||||||
'EX',
|
300,
|
||||||
300,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
success: true,
|
success: true,
|
||||||
@@ -613,12 +603,11 @@ export class CommandExecutorService {
|
|||||||
message: `MCP server "${serverName}" reconnected successfully.`,
|
message: `MCP server "${serverName}" reconnected successfully.`,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to reconnect MCP server "${serverName}"`, err);
|
|
||||||
return {
|
return {
|
||||||
command: 'mcp',
|
command: 'mcp',
|
||||||
conversationId,
|
conversationId,
|
||||||
success: false,
|
success: false,
|
||||||
message: `Failed to reconnect MCP server "${serverName}" due to an internal error.`,
|
message: `Failed to reconnect MCP server "${serverName}": ${err instanceof Error ? err.message : String(err)}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
|
||||||
import { ChatModule } from '../chat/chat.module.js';
|
import { ChatModule } from '../chat/chat.module.js';
|
||||||
import { GCModule } from '../gc/gc.module.js';
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
import { ReloadModule } from '../reload/reload.module.js';
|
import { ReloadModule } from '../reload/reload.module.js';
|
||||||
@@ -18,17 +16,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: COMMANDS_QUEUE_HANDLE,
|
provide: COMMANDS_QUEUE_HANDLE,
|
||||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
useFactory: (): QueueHandle => {
|
||||||
// On Local tier there is no Redis — skip the ioredis connection.
|
|
||||||
// CommandExecutorService falls back to no-cache for /provider login on local.
|
|
||||||
if (config?.queue?.type === 'local') return null;
|
|
||||||
return createQueue();
|
return createQueue();
|
||||||
},
|
},
|
||||||
inject: [MOSAIC_CONFIG],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: COMMANDS_REDIS,
|
provide: COMMANDS_REDIS,
|
||||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
useFactory: (handle: QueueHandle) => handle.redis,
|
||||||
inject: [COMMANDS_QUEUE_HANDLE],
|
inject: [COMMANDS_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
CommandRegistryService,
|
CommandRegistryService,
|
||||||
@@ -44,13 +38,9 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CommandsModule implements OnApplicationShutdown {
|
export class CommandsModule implements OnApplicationShutdown {
|
||||||
constructor(
|
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||||
@Optional()
|
|
||||||
@Inject(COMMANDS_QUEUE_HANDLE)
|
|
||||||
private readonly handle: QueueHandle | null,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async onApplicationShutdown(): Promise<void> {
|
async onApplicationShutdown(): Promise<void> {
|
||||||
await this.handle?.close().catch(() => {});
|
await this.handle.close().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,9 +245,21 @@ describe('EnrollmentService.createToken', () => {
|
|||||||
const after = Date.now();
|
const after = Date.now();
|
||||||
|
|
||||||
const expiresMs = new Date(result.expiresAt).getTime();
|
const expiresMs = new Date(result.expiresAt).getTime();
|
||||||
// Should be at most 900s from now
|
|
||||||
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
|
// The property under test is CLAMPING: a 9999s request must come back as 900s.
|
||||||
|
// The gap between clamped and unclamped is 9_099_000 ms, so the tolerance below
|
||||||
|
// only has to exceed CI scheduling jitter — it does not need to be tight to keep
|
||||||
|
// the assertion discriminating. A 5s allowance consumes 0.05% of that margin and
|
||||||
|
// an unclamped result still misses by three orders of magnitude.
|
||||||
|
//
|
||||||
|
// It was 100ms and failed on a loaded agent at 900_106 — 6ms over (#1090). A
|
||||||
|
// wall-clock budget sized to a fast machine is a flake, not a tighter test.
|
||||||
|
const CI_JITTER_MS = 5_000;
|
||||||
|
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + CI_JITTER_MS);
|
||||||
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
|
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
|
||||||
|
// Explicitly pin the clamp itself, independent of any timing allowance:
|
||||||
|
// unclamped (9999s) would exceed this by ~9_099_000 ms.
|
||||||
|
expect(expiresMs - before).toBeLessThan(1_000_000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { EnrollmentController } from './enrollment.controller.js';
|
|||||||
import { EnrollmentService } from './enrollment.service.js';
|
import { EnrollmentService } from './enrollment.service.js';
|
||||||
import { FederationController } from './federation.controller.js';
|
import { FederationController } from './federation.controller.js';
|
||||||
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
||||||
import { GetController } from './server/verbs/get.controller.js';
|
|
||||||
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
|
|
||||||
import { GrantsService } from './grants.service.js';
|
import { GrantsService } from './grants.service.js';
|
||||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
||||||
@@ -14,13 +12,7 @@ import { ListController } from './server/verbs/list.controller.js';
|
|||||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [
|
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
||||||
EnrollmentController,
|
|
||||||
FederationController,
|
|
||||||
CapabilitiesController,
|
|
||||||
ListController,
|
|
||||||
GetController,
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
AdminGuard,
|
AdminGuard,
|
||||||
CaService,
|
CaService,
|
||||||
@@ -31,7 +23,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
FederationGetQueryService,
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CaService,
|
CaService,
|
||||||
@@ -42,7 +33,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
FederationGetQueryService,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
@@ -1,348 +0,0 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
createPgliteDb,
|
|
||||||
missionTasks,
|
|
||||||
missions,
|
|
||||||
projects,
|
|
||||||
runPgliteMigrations,
|
|
||||||
teams,
|
|
||||||
users,
|
|
||||||
type Db,
|
|
||||||
type DbHandle,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import type { FederationScopeQueryFilter } from '../../scope.service.js';
|
|
||||||
import { FederationGetQueryService } from '../get-query.service.js';
|
|
||||||
|
|
||||||
const CREDENTIAL_FILTER: FederationScopeQueryFilter = {
|
|
||||||
resource: 'credentials',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: [],
|
|
||||||
limit: 1,
|
|
||||||
maxRowsPerQuery: 25,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SUBJECT_USER_ID = 'fed-m3-06-subject';
|
|
||||||
const OTHER_USER_ID = 'fed-m3-06-other';
|
|
||||||
const TEAM_ID = '06000000-0000-4000-8000-000000000001';
|
|
||||||
const UNAUTHORIZED_TEAM_ID = '06000000-0000-4000-8000-000000000002';
|
|
||||||
const PERSONAL_PROJECT_ID = '06000000-0000-4000-8000-000000000101';
|
|
||||||
const TEAM_PROJECT_ID = '06000000-0000-4000-8000-000000000102';
|
|
||||||
const UNAUTHORIZED_PROJECT_ID = '06000000-0000-4000-8000-000000000103';
|
|
||||||
const PERSONAL_MISSION_ID = '06000000-0000-4000-8000-000000000201';
|
|
||||||
const TEAM_MISSION_ID = '06000000-0000-4000-8000-000000000202';
|
|
||||||
const UNAUTHORIZED_MISSION_ID = '06000000-0000-4000-8000-000000000203';
|
|
||||||
const SUBJECT_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000301';
|
|
||||||
const OTHER_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000302';
|
|
||||||
const SUBJECT_PERSONAL_NOTE_ID = '06000000-0000-4000-8000-000000000303';
|
|
||||||
const SUBJECT_UNAUTHORIZED_NOTE_ID = '06000000-0000-4000-8000-000000000304';
|
|
||||||
|
|
||||||
let dbHandle: DbHandle | undefined;
|
|
||||||
|
|
||||||
function makeService() {
|
|
||||||
return new FederationGetQueryService({} as Db);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDbService() {
|
|
||||||
if (!dbHandle) {
|
|
||||||
throw new Error('test DB not initialized');
|
|
||||||
}
|
|
||||||
return new FederationGetQueryService(dbHandle.db);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedNotesFixture() {
|
|
||||||
if (!dbHandle) {
|
|
||||||
throw new Error('test DB not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
await dbHandle.db.insert(users).values([
|
|
||||||
{
|
|
||||||
id: SUBJECT_USER_ID,
|
|
||||||
name: 'Federation Subject',
|
|
||||||
email: `${SUBJECT_USER_ID}@example.test`,
|
|
||||||
emailVerified: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: OTHER_USER_ID,
|
|
||||||
name: 'Federation Other',
|
|
||||||
email: `${OTHER_USER_ID}@example.test`,
|
|
||||||
emailVerified: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(teams).values([
|
|
||||||
{
|
|
||||||
id: TEAM_ID,
|
|
||||||
name: 'FED-M3-06 Team',
|
|
||||||
slug: 'fed-m3-06-team',
|
|
||||||
ownerId: SUBJECT_USER_ID,
|
|
||||||
managerId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: UNAUTHORIZED_TEAM_ID,
|
|
||||||
name: 'FED-M3-06 Unauthorized Team',
|
|
||||||
slug: 'fed-m3-06-unauthorized-team',
|
|
||||||
ownerId: OTHER_USER_ID,
|
|
||||||
managerId: OTHER_USER_ID,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(projects).values([
|
|
||||||
{
|
|
||||||
id: PERSONAL_PROJECT_ID,
|
|
||||||
name: 'FED-M3-06 Personal Project',
|
|
||||||
ownerId: SUBJECT_USER_ID,
|
|
||||||
ownerType: 'user',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: TEAM_PROJECT_ID,
|
|
||||||
name: 'FED-M3-06 Team Project',
|
|
||||||
teamId: TEAM_ID,
|
|
||||||
ownerType: 'team',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: UNAUTHORIZED_PROJECT_ID,
|
|
||||||
name: 'FED-M3-06 Unauthorized Project',
|
|
||||||
teamId: UNAUTHORIZED_TEAM_ID,
|
|
||||||
ownerType: 'team',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(missions).values([
|
|
||||||
{
|
|
||||||
id: PERSONAL_MISSION_ID,
|
|
||||||
name: 'FED-M3-06 Personal Mission',
|
|
||||||
projectId: PERSONAL_PROJECT_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: TEAM_MISSION_ID,
|
|
||||||
name: 'FED-M3-06 Team Mission',
|
|
||||||
projectId: TEAM_PROJECT_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: UNAUTHORIZED_MISSION_ID,
|
|
||||||
name: 'FED-M3-06 Unauthorized Mission',
|
|
||||||
projectId: UNAUTHORIZED_PROJECT_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(missionTasks).values([
|
|
||||||
{
|
|
||||||
id: SUBJECT_TEAM_NOTE_ID,
|
|
||||||
missionId: TEAM_MISSION_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
notes: 'subject note on team mission',
|
|
||||||
createdAt: new Date('2026-06-24T03:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: OTHER_TEAM_NOTE_ID,
|
|
||||||
missionId: TEAM_MISSION_ID,
|
|
||||||
userId: OTHER_USER_ID,
|
|
||||||
notes: 'other user note on team mission',
|
|
||||||
createdAt: new Date('2026-06-24T02:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
|
||||||
missionId: PERSONAL_MISSION_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
notes: 'subject note on personal mission',
|
|
||||||
createdAt: new Date('2026-06-24T01:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
|
||||||
missionId: UNAUTHORIZED_MISSION_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
notes: 'subject note outside grant-visible missions',
|
|
||||||
createdAt: new Date('2026-06-24T04:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('FederationGetQueryService', () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
dbHandle = createPgliteDb(`memory://fed-m3-06-get-${Date.now()}`);
|
|
||||||
await runPgliteMigrations(dbHandle);
|
|
||||||
await seedNotesFixture();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await dbHandle?.close();
|
|
||||||
dbHandle = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies sensitive resources in native RBAC for M3 get reads', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.evaluateReadAccess({
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
resource: 'credentials',
|
|
||||||
}),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
reason: 'credentials federation get access is not implemented in M3',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows personal memory reads without requiring team lookup', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.evaluateReadAccess({
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
resource: 'memory',
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses subject team membership as the native RBAC upper bound for task and note reads', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
const listSubjectTeamIds = vi.fn().mockResolvedValue(['team-1', 'team-2']);
|
|
||||||
(
|
|
||||||
service as unknown as {
|
|
||||||
listSubjectTeamIds: (subjectUserId: string) => Promise<string[]>;
|
|
||||||
}
|
|
||||||
).listSubjectTeamIds = listSubjectTeamIds;
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.evaluateReadAccess({
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
resource: 'tasks',
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
|
||||||
});
|
|
||||||
expect(listSubjectTeamIds).toHaveBeenCalledWith('user-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not query storage for sensitive get resources even if scope allowed them', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
|
|
||||||
await expect(service.get({ filter: CREDENTIAL_FILTER, id: 'cred-1' })).resolves.toEqual({
|
|
||||||
status: 'denied',
|
|
||||||
reason: 'credentials federation get is not implemented',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed for unsupported resources instead of returning undefined', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.get({
|
|
||||||
filter: {
|
|
||||||
...CREDENTIAL_FILTER,
|
|
||||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
|
||||||
},
|
|
||||||
id: 'row-1',
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
status: 'denied',
|
|
||||||
reason: 'Unsupported federation get resource: unknown-resource',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not leak another user mission task note through team-scoped get reads', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.get({
|
|
||||||
filter: {
|
|
||||||
resource: 'notes',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [TEAM_ID],
|
|
||||||
limit: 1,
|
|
||||||
maxRowsPerQuery: 10,
|
|
||||||
},
|
|
||||||
id: OTHER_TEAM_NOTE_ID,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
status: 'denied',
|
|
||||||
reason: 'Note is outside the federated scope',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not return subject notes from missions outside the grant-visible project set', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.get({
|
|
||||||
filter: {
|
|
||||||
resource: 'notes',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: [TEAM_ID],
|
|
||||||
limit: 1,
|
|
||||||
maxRowsPerQuery: 10,
|
|
||||||
},
|
|
||||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
status: 'denied',
|
|
||||||
reason: 'Note is outside the federated scope',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a subject note only when subject ownership and authorized mission intersect', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.get({
|
|
||||||
filter: {
|
|
||||||
resource: 'notes',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [TEAM_ID],
|
|
||||||
limit: 1,
|
|
||||||
maxRowsPerQuery: 10,
|
|
||||||
},
|
|
||||||
id: SUBJECT_TEAM_NOTE_ID,
|
|
||||||
}),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
status: 'found',
|
|
||||||
item: {
|
|
||||||
id: SUBJECT_TEAM_NOTE_ID,
|
|
||||||
missionId: TEAM_MISSION_ID,
|
|
||||||
content: 'subject note on team mission',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not return subject personal notes when includePersonal is false', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.get({
|
|
||||||
filter: {
|
|
||||||
resource: 'notes',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [TEAM_ID],
|
|
||||||
limit: 1,
|
|
||||||
maxRowsPerQuery: 10,
|
|
||||||
},
|
|
||||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
status: 'denied',
|
|
||||||
reason: 'Note is outside the federated scope',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { RequestMethod } from '@nestjs/common';
|
|
||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
|
||||||
import type {
|
|
||||||
FederationScopeEvaluationResult,
|
|
||||||
FederationScopeQueryFilter,
|
|
||||||
} from '../../scope.service.js';
|
|
||||||
import { GetController } from '../get.controller.js';
|
|
||||||
import type { FederationGetQueryResult } from '../get-query.service.js';
|
|
||||||
|
|
||||||
const FEDERATION_CONTEXT = {
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
scope: { resources: ['tasks'], max_rows_per_query: 25 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
|
||||||
resource: 'tasks',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: ['team-1'],
|
|
||||||
limit: 1,
|
|
||||||
maxRowsPerQuery: 25,
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeRequest(): FastifyRequest {
|
|
||||||
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function allowedScope(
|
|
||||||
filter: FederationScopeQueryFilter = TASK_FILTER,
|
|
||||||
): FederationScopeEvaluationResult {
|
|
||||||
return { allowed: true, filter };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeController(opts?: {
|
|
||||||
scopeResult?: FederationScopeEvaluationResult;
|
|
||||||
queryResult?: FederationGetQueryResult;
|
|
||||||
}) {
|
|
||||||
const scope = {
|
|
||||||
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
|
|
||||||
};
|
|
||||||
const query = {
|
|
||||||
evaluateReadAccess: vi.fn(),
|
|
||||||
get: vi.fn().mockResolvedValue(
|
|
||||||
opts?.queryResult ?? {
|
|
||||||
status: 'found',
|
|
||||||
item: {
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Federated task',
|
|
||||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
controller: new GetController(scope as never, query as never),
|
|
||||||
scope,
|
|
||||||
query,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('GetController', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('declares POST /api/federation/v1/get/:resource/:id protected only by FederationAuthGuard', () => {
|
|
||||||
expect(Reflect.getMetadata('path', GetController)).toBe('api/federation/v1/get');
|
|
||||||
expect(Reflect.getMetadata('path', GetController.prototype.get)).toBe(':resource/:id');
|
|
||||||
expect(Reflect.getMetadata('method', GetController.prototype.get)).toBe(RequestMethod.POST);
|
|
||||||
expect(Reflect.getMetadata('__guards__', GetController)).toEqual([FederationAuthGuard]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs AuthGuard context through ScopeService and returns one local-source tagged row', async () => {
|
|
||||||
const { controller, scope, query } = makeController();
|
|
||||||
|
|
||||||
const response = await controller.get('tasks', 'task-1', makeRequest());
|
|
||||||
|
|
||||||
expect(scope.evaluateAccess).toHaveBeenCalledWith({
|
|
||||||
context: FEDERATION_CONTEXT,
|
|
||||||
resource: 'tasks',
|
|
||||||
requestedLimit: 1,
|
|
||||||
nativeRbac: query,
|
|
||||||
});
|
|
||||||
expect(query.get).toHaveBeenCalledWith({ filter: TASK_FILTER, id: 'task-1' });
|
|
||||||
expect(response).toEqual({
|
|
||||||
item: {
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Federated task',
|
|
||||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
|
||||||
_source: 'local',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a federation error envelope when auth guard context is missing', async () => {
|
|
||||||
const { controller, scope, query } = makeController();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.get('tasks', 'task-1', {} as unknown as FastifyRequest),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
response: {
|
|
||||||
error: {
|
|
||||||
code: 'unauthorized',
|
|
||||||
message: 'Federation context missing',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 401,
|
|
||||||
});
|
|
||||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
|
||||||
expect(query.get).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a federation error envelope when scope evaluation denies access', async () => {
|
|
||||||
const { controller, query } = makeController({
|
|
||||||
scopeResult: {
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'resource_excluded',
|
|
||||||
stage: 'resource_exclusion',
|
|
||||||
statusCode: 403,
|
|
||||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
resource: 'credentials',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(controller.get('credentials', 'cred-1', makeRequest())).rejects.toMatchObject({
|
|
||||||
response: {
|
|
||||||
error: {
|
|
||||||
code: 'scope_violation',
|
|
||||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
expect(query.get).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 404 when the scoped query layer cannot find the resource id', async () => {
|
|
||||||
const { controller } = makeController({ queryResult: { status: 'not_found' } });
|
|
||||||
|
|
||||||
await expect(controller.get('tasks', 'missing-task', makeRequest())).rejects.toMatchObject({
|
|
||||||
response: { error: { code: 'not_found' } },
|
|
||||||
status: 404,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 403 when the resource exists outside the RBAC/scope intersection', async () => {
|
|
||||||
const { controller } = makeController({
|
|
||||||
queryResult: { status: 'denied', reason: 'Task is outside the federated scope' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(controller.get('tasks', 'task-2', makeRequest())).rejects.toMatchObject({
|
|
||||||
response: {
|
|
||||||
error: {
|
|
||||||
code: 'scope_violation',
|
|
||||||
message: 'Task is outside the federated scope',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed when the query layer denies an unsupported resource', async () => {
|
|
||||||
const unsupportedFilter: FederationScopeQueryFilter = {
|
|
||||||
...TASK_FILTER,
|
|
||||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
|
||||||
};
|
|
||||||
const { controller } = makeController({
|
|
||||||
scopeResult: allowedScope(unsupportedFilter),
|
|
||||||
queryResult: {
|
|
||||||
status: 'denied',
|
|
||||||
reason: 'Unsupported federation get resource: unknown-resource',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(controller.get('unknown-resource', 'row-1', makeRequest())).rejects.toMatchObject({
|
|
||||||
response: {
|
|
||||||
error: {
|
|
||||||
code: 'scope_violation',
|
|
||||||
message: 'Unsupported federation get resource: unknown-resource',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects empty ids before evaluating scope', async () => {
|
|
||||||
const { controller, scope, query } = makeController();
|
|
||||||
|
|
||||||
await expect(controller.get('tasks', ' ', makeRequest())).rejects.toMatchObject({
|
|
||||||
response: { error: { code: 'invalid_request' } },
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
|
||||||
expect(query.get).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation get query layer (FED-M3-06).
|
|
||||||
*
|
|
||||||
* Read-only DB adapter used by GetController after FederationAuthGuard and
|
|
||||||
* FederationScopeService have established the subject user, allowed resource,
|
|
||||||
* native-RBAC intersection, and row cap. Audit writes are intentionally
|
|
||||||
* deferred to M4.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
and,
|
|
||||||
eq,
|
|
||||||
inArray,
|
|
||||||
insights,
|
|
||||||
or,
|
|
||||||
missionTasks,
|
|
||||||
missions,
|
|
||||||
preferences,
|
|
||||||
projects,
|
|
||||||
tasks,
|
|
||||||
teamMembers,
|
|
||||||
type Db,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import { DB } from '../../../database/database.module.js';
|
|
||||||
import type {
|
|
||||||
FederationNativeRbacEvaluator,
|
|
||||||
FederationNativeRbacRequest,
|
|
||||||
FederationNativeRbacResult,
|
|
||||||
FederationScopeQueryFilter,
|
|
||||||
} from '../scope.service.js';
|
|
||||||
|
|
||||||
export interface FederationGetQueryRequest {
|
|
||||||
readonly filter: FederationScopeQueryFilter;
|
|
||||||
readonly id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationGetQueryFoundResult<T extends object = Record<string, unknown>> {
|
|
||||||
readonly status: 'found';
|
|
||||||
readonly item: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationGetQueryNotFoundResult {
|
|
||||||
readonly status: 'not_found';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationGetQueryDeniedResult {
|
|
||||||
readonly status: 'denied';
|
|
||||||
readonly reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FederationGetQueryResult<T extends object = Record<string, unknown>> =
|
|
||||||
| FederationGetQueryFoundResult<T>
|
|
||||||
| FederationGetQueryNotFoundResult
|
|
||||||
| FederationGetQueryDeniedResult;
|
|
||||||
|
|
||||||
type RowObject = Record<string, unknown>;
|
|
||||||
|
|
||||||
function firstRow<T>(rows: T[]): T | undefined {
|
|
||||||
return rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowBelongsToAccessibleProjectOrMission(
|
|
||||||
row: { projectId?: string | null; missionId?: string | null },
|
|
||||||
projectIds: readonly string[],
|
|
||||||
missionIds: readonly string[],
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
(typeof row.projectId === 'string' && projectIds.includes(row.projectId)) ||
|
|
||||||
(typeof row.missionId === 'string' && missionIds.includes(row.missionId))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FederationGetQueryService implements FederationNativeRbacEvaluator {
|
|
||||||
constructor(@Inject(DB) private readonly db: Db) {}
|
|
||||||
|
|
||||||
async evaluateReadAccess(
|
|
||||||
request: FederationNativeRbacRequest,
|
|
||||||
): Promise<FederationNativeRbacResult> {
|
|
||||||
if (request.resource === 'credentials' || request.resource === 'api_keys') {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: `${request.resource} federation get access is not implemented in M3`,
|
|
||||||
details: { resource: request.resource },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.resource === 'memory') {
|
|
||||||
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
|
|
||||||
return { allowed: true, access: { includePersonal: true, teamIds } };
|
|
||||||
}
|
|
||||||
|
|
||||||
async get<T extends RowObject = RowObject>(
|
|
||||||
request: FederationGetQueryRequest,
|
|
||||||
): Promise<FederationGetQueryResult<T>> {
|
|
||||||
return this.getByResource(request.filter, request.id) as Promise<FederationGetQueryResult<T>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getByResource(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
id: string,
|
|
||||||
): Promise<FederationGetQueryResult> {
|
|
||||||
switch (filter.resource) {
|
|
||||||
case 'tasks':
|
|
||||||
return this.getTask(filter, id);
|
|
||||||
case 'notes':
|
|
||||||
return this.getNote(filter, id);
|
|
||||||
case 'memory':
|
|
||||||
return this.getMemory(filter, id);
|
|
||||||
case 'credentials':
|
|
||||||
case 'api_keys':
|
|
||||||
return { status: 'denied', reason: `${filter.resource} federation get is not implemented` };
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
status: 'denied',
|
|
||||||
reason: `Unsupported federation get resource: ${String(filter.resource)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
|
|
||||||
const rows = await this.db
|
|
||||||
.select({ teamId: teamMembers.teamId })
|
|
||||||
.from(teamMembers)
|
|
||||||
.where(eq(teamMembers.userId, subjectUserId));
|
|
||||||
|
|
||||||
return rows.map((row) => row.teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
|
|
||||||
const clauses = [];
|
|
||||||
if (filter.includePersonal) {
|
|
||||||
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
|
|
||||||
}
|
|
||||||
if (filter.teamIds.length > 0) {
|
|
||||||
// Project team ownership follows TeamsService.canAccessProject: team-owned
|
|
||||||
// rows are authorized through projects.teamId, while ownerId remains the
|
|
||||||
// user who created/bootstrapped the project.
|
|
||||||
clauses.push(
|
|
||||||
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clauses.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.select({ id: projects.id })
|
|
||||||
.from(projects)
|
|
||||||
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
|
|
||||||
|
|
||||||
return rows.map((row) => row.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
|
|
||||||
if (projectIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.select({ id: missions.id })
|
|
||||||
.from(missions)
|
|
||||||
.where(inArray(missions.projectId, [...projectIds]));
|
|
||||||
|
|
||||||
return rows.map((row) => row.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getTask(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
id: string,
|
|
||||||
): Promise<FederationGetQueryResult> {
|
|
||||||
const row = firstRow(
|
|
||||||
await this.db
|
|
||||||
.select({
|
|
||||||
id: tasks.id,
|
|
||||||
title: tasks.title,
|
|
||||||
description: tasks.description,
|
|
||||||
status: tasks.status,
|
|
||||||
priority: tasks.priority,
|
|
||||||
projectId: tasks.projectId,
|
|
||||||
missionId: tasks.missionId,
|
|
||||||
assignee: tasks.assignee,
|
|
||||||
tags: tasks.tags,
|
|
||||||
dueDate: tasks.dueDate,
|
|
||||||
metadata: tasks.metadata,
|
|
||||||
createdAt: tasks.createdAt,
|
|
||||||
updatedAt: tasks.updatedAt,
|
|
||||||
})
|
|
||||||
.from(tasks)
|
|
||||||
.where(eq(tasks.id, id))
|
|
||||||
.limit(1),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
return { status: 'not_found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
|
||||||
const missionIds = await this.listMissionIds(projectIds);
|
|
||||||
if (!rowBelongsToAccessibleProjectOrMission(row, projectIds, missionIds)) {
|
|
||||||
return { status: 'denied', reason: 'Task is outside the federated scope' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: 'found', item: row as RowObject };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getNote(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
id: string,
|
|
||||||
): Promise<FederationGetQueryResult> {
|
|
||||||
const row = firstRow(
|
|
||||||
await this.db
|
|
||||||
.select({
|
|
||||||
id: missionTasks.id,
|
|
||||||
missionId: missionTasks.missionId,
|
|
||||||
taskId: missionTasks.taskId,
|
|
||||||
userId: missionTasks.userId,
|
|
||||||
status: missionTasks.status,
|
|
||||||
content: missionTasks.notes,
|
|
||||||
createdAt: missionTasks.createdAt,
|
|
||||||
updatedAt: missionTasks.updatedAt,
|
|
||||||
})
|
|
||||||
.from(missionTasks)
|
|
||||||
.where(eq(missionTasks.id, id))
|
|
||||||
.limit(1),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!row || row.content === null || row.content === '') {
|
|
||||||
return { status: 'not_found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
|
||||||
const missionIds = await this.listMissionIds(projectIds);
|
|
||||||
|
|
||||||
// mission_tasks rows are user-scoped even when the mission belongs to a team.
|
|
||||||
// Scope-visible missions must intersect with subject ownership; team scope
|
|
||||||
// narrows mission IDs but never widens note reads to another user's rows.
|
|
||||||
if (row.userId !== filter.subjectUserId || !missionIds.includes(row.missionId)) {
|
|
||||||
return { status: 'denied', reason: 'Note is outside the federated scope' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = { ...row } as RowObject;
|
|
||||||
delete item['userId'];
|
|
||||||
return { status: 'found', item };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getMemory(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
id: string,
|
|
||||||
): Promise<FederationGetQueryResult> {
|
|
||||||
const [insightRow, preferenceRow] = await Promise.all([
|
|
||||||
this.db
|
|
||||||
.select({
|
|
||||||
id: insights.id,
|
|
||||||
userId: insights.userId,
|
|
||||||
kind: insights.source,
|
|
||||||
content: insights.content,
|
|
||||||
category: insights.category,
|
|
||||||
relevanceScore: insights.relevanceScore,
|
|
||||||
metadata: insights.metadata,
|
|
||||||
createdAt: insights.createdAt,
|
|
||||||
updatedAt: insights.updatedAt,
|
|
||||||
})
|
|
||||||
.from(insights)
|
|
||||||
.where(eq(insights.id, id))
|
|
||||||
.limit(1)
|
|
||||||
.then(firstRow),
|
|
||||||
this.db
|
|
||||||
.select({
|
|
||||||
id: preferences.id,
|
|
||||||
userId: preferences.userId,
|
|
||||||
kind: preferences.category,
|
|
||||||
key: preferences.key,
|
|
||||||
value: preferences.value,
|
|
||||||
source: preferences.source,
|
|
||||||
mutable: preferences.mutable,
|
|
||||||
createdAt: preferences.createdAt,
|
|
||||||
updatedAt: preferences.updatedAt,
|
|
||||||
})
|
|
||||||
.from(preferences)
|
|
||||||
.where(eq(preferences.id, id))
|
|
||||||
.limit(1)
|
|
||||||
.then(firstRow),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const candidates = [insightRow, preferenceRow].filter(
|
|
||||||
(row): row is NonNullable<typeof row> => row !== undefined,
|
|
||||||
);
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return { status: 'not_found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!filter.includePersonal) {
|
|
||||||
return { status: 'denied', reason: 'Memory personal rows are outside the federated scope' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessible = candidates.find((row) => row.userId === filter.subjectUserId);
|
|
||||||
if (!accessible) {
|
|
||||||
return { status: 'denied', reason: 'Memory row belongs to another subject user' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = { ...accessible } as RowObject;
|
|
||||||
delete item['userId'];
|
|
||||||
return { status: 'found', item };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation get verb (FED-M3-06).
|
|
||||||
*
|
|
||||||
* POST /api/federation/v1/get/:resource/:id
|
|
||||||
*
|
|
||||||
* Pipeline: FederationAuthGuard attaches the active grant context, then
|
|
||||||
* FederationScopeService enforces grant scope + native RBAC intersection, then
|
|
||||||
* the read-only query layer fetches one local row and tags it with `_source`.
|
|
||||||
* Read audit-log writes are deferred to M4; this controller does not persist
|
|
||||||
* request or response bodies.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common';
|
|
||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
import {
|
|
||||||
FederationInvalidRequestError,
|
|
||||||
FederationNotFoundError,
|
|
||||||
FederationScopeViolationError,
|
|
||||||
FederationUnauthorizedError,
|
|
||||||
SOURCE_LOCAL,
|
|
||||||
type FederationGetResponse,
|
|
||||||
type SourceTag,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
|
||||||
import '../federation-context.js';
|
|
||||||
import { FederationScopeService } from '../scope.service.js';
|
|
||||||
import { FederationGetQueryService } from './get-query.service.js';
|
|
||||||
|
|
||||||
type FederatedRow = Record<string, unknown> & SourceTag;
|
|
||||||
|
|
||||||
function scopeDenyToHttpException(deny: {
|
|
||||||
readonly statusCode: 400 | 403;
|
|
||||||
readonly message: string;
|
|
||||||
}): HttpException {
|
|
||||||
const ErrorClass =
|
|
||||||
deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError;
|
|
||||||
return new HttpException(new ErrorClass(deny.message, deny).toEnvelope(), deny.statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('api/federation/v1/get')
|
|
||||||
@UseGuards(FederationAuthGuard)
|
|
||||||
export class GetController {
|
|
||||||
constructor(
|
|
||||||
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
|
|
||||||
@Inject(FederationGetQueryService) private readonly query: FederationGetQueryService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Post(':resource/:id')
|
|
||||||
async get(
|
|
||||||
@Param('resource') resource: string,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Req() request: FastifyRequest,
|
|
||||||
): Promise<FederationGetResponse<FederatedRow>> {
|
|
||||||
if (!request.federationContext) {
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (id.trim().length === 0) {
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationInvalidRequestError('Federation get id must not be empty').toEnvelope(),
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopeResult = await this.scope.evaluateAccess({
|
|
||||||
context: request.federationContext,
|
|
||||||
resource,
|
|
||||||
requestedLimit: 1,
|
|
||||||
nativeRbac: this.query,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!scopeResult.allowed) {
|
|
||||||
throw scopeDenyToHttpException(scopeResult.deny);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.query.get({ filter: scopeResult.filter, id });
|
|
||||||
if (result.status === 'not_found') {
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationNotFoundError('Requested federation resource was not found').toEnvelope(),
|
|
||||||
404,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (result.status === 'denied') {
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationScopeViolationError(result.reason, {
|
|
||||||
resource,
|
|
||||||
id,
|
|
||||||
grantId: request.federationContext.grantId,
|
|
||||||
peerId: request.federationContext.peerId,
|
|
||||||
subjectUserId: request.federationContext.subjectUserId,
|
|
||||||
}).toEnvelope(),
|
|
||||||
403,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { item: { ...result.item, _source: SOURCE_LOCAL } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
|
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
|
||||||
import { SessionGCService } from './session-gc.service.js';
|
import { SessionGCService } from './session-gc.service.js';
|
||||||
import { REDIS } from './gc.tokens.js';
|
import { REDIS } from './gc.tokens.js';
|
||||||
|
|
||||||
@@ -11,17 +9,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: GC_QUEUE_HANDLE,
|
provide: GC_QUEUE_HANDLE,
|
||||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
useFactory: (): QueueHandle => {
|
||||||
// On Local tier there is no Redis — skip the ioredis connection entirely.
|
|
||||||
// The Valkey GC sweep is a no-op on Local (no session keys stored there).
|
|
||||||
if (config?.queue?.type === 'local') return null;
|
|
||||||
return createQueue();
|
return createQueue();
|
||||||
},
|
},
|
||||||
inject: [MOSAIC_CONFIG],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: REDIS,
|
provide: REDIS,
|
||||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
useFactory: (handle: QueueHandle) => handle.redis,
|
||||||
inject: [GC_QUEUE_HANDLE],
|
inject: [GC_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
SessionGCService,
|
SessionGCService,
|
||||||
@@ -29,13 +23,9 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
|||||||
exports: [SessionGCService],
|
exports: [SessionGCService],
|
||||||
})
|
})
|
||||||
export class GCModule implements OnApplicationShutdown {
|
export class GCModule implements OnApplicationShutdown {
|
||||||
constructor(
|
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||||
@Optional()
|
|
||||||
@Inject(GC_QUEUE_HANDLE)
|
|
||||||
private readonly handle: QueueHandle | null,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async onApplicationShutdown(): Promise<void> {
|
async onApplicationShutdown(): Promise<void> {
|
||||||
await this.handle?.close().catch(() => {});
|
await this.handle.close().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,19 +119,6 @@ describe('SessionGCService', () => {
|
|||||||
).resolves.toEqual({ allowed: true });
|
).resolves.toEqual({ allowed: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('collect() skips Valkey but still demotes only the requested session on local tier', async () => {
|
|
||||||
const localService = new SessionGCService(null, mockLogService as unknown as LogService);
|
|
||||||
|
|
||||||
const result = await localService.collect('local-session');
|
|
||||||
|
|
||||||
expect(result.sessionId).toBe('local-session');
|
|
||||||
expect(result.cleaned.valkeyKeys).toBeUndefined();
|
|
||||||
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
|
|
||||||
'local-session',
|
|
||||||
expect.any(Date),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('collect() returns sessionId in result', async () => {
|
it('collect() returns sessionId in result', async () => {
|
||||||
const result = await service.collect('test-session-id');
|
const result = await service.collect('test-session-id');
|
||||||
expect(result.sessionId).toBe('test-session-id');
|
expect(result.sessionId).toBe('test-session-id');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { QueueHandle } from '@mosaicstack/queue';
|
import type { QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { LogService } from '@mosaicstack/log';
|
import type { LogService } from '@mosaicstack/log';
|
||||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||||
@@ -21,10 +21,7 @@ function escapeRedisGlobLiteral(value: string): string {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionGCService {
|
export class SessionGCService {
|
||||||
constructor(
|
constructor(
|
||||||
// Local tier has no Redis; lifecycle cleanup still demotes this session's logs.
|
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||||
@Optional()
|
|
||||||
@Inject(REDIS)
|
|
||||||
private readonly redis: QueueHandle['redis'] | null,
|
|
||||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -32,10 +29,8 @@ export class SessionGCService {
|
|||||||
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
|
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
|
||||||
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
||||||
* duration, which can cause latency spikes under production key volumes.
|
* duration, which can cause latency spikes under production key volumes.
|
||||||
* Returns an empty population on the Local tier where Redis is disabled.
|
|
||||||
*/
|
*/
|
||||||
private async scanKeys(pattern: string): Promise<string[]> {
|
private async scanKeys(pattern: string): Promise<string[]> {
|
||||||
if (!this.redis) return [];
|
|
||||||
const collected: string[] = [];
|
const collected: string[] = [];
|
||||||
let cursor = '0';
|
let cursor = '0';
|
||||||
do {
|
do {
|
||||||
@@ -52,14 +47,12 @@ export class SessionGCService {
|
|||||||
async collect(sessionId: string): Promise<GCResult> {
|
async collect(sessionId: string): Promise<GCResult> {
|
||||||
const result: GCResult = { sessionId, cleaned: {} };
|
const result: GCResult = { sessionId, cleaned: {} };
|
||||||
|
|
||||||
// 1. Valkey: delete all session-scoped keys (skipped on Local tier).
|
// 1. Valkey: delete all session-scoped keys
|
||||||
if (this.redis) {
|
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
const valkeyKeys = await this.scanKeys(pattern);
|
||||||
const valkeyKeys = await this.scanKeys(pattern);
|
if (valkeyKeys.length > 0) {
|
||||||
if (valkeyKeys.length > 0) {
|
await this.redis.del(...valkeyKeys);
|
||||||
await this.redis.del(...valkeyKeys);
|
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. PG: demote hot-tier agent logs for this session only.
|
// 2. PG: demote hot-tier agent logs for this session only.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(CronService.name);
|
private readonly logger = new Logger(CronService.name);
|
||||||
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
|
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||||
@@ -26,12 +26,6 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
// Local tier deliberately has no BullMQ consumers or repeatable jobs.
|
|
||||||
if (!this.queueService.isEnabled()) {
|
|
||||||
this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||||
|
|
||||||
@@ -45,7 +39,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
||||||
await this.summarization.runSummarization();
|
await this.summarization.runSummarization();
|
||||||
});
|
});
|
||||||
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
|
this.registeredWorkers.push(summarizationWorker);
|
||||||
|
|
||||||
// M6-005: Tier management repeatable job
|
// M6-005: Tier management repeatable job
|
||||||
await this.queueService.addRepeatableJob(
|
await this.queueService.addRepeatableJob(
|
||||||
@@ -57,7 +51,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
||||||
await this.summarization.runTierManagement();
|
await this.summarization.runTierManagement();
|
||||||
});
|
});
|
||||||
if (tierWorker) this.registeredWorkers.push(tierWorker);
|
this.registeredWorkers.push(tierWorker);
|
||||||
|
|
||||||
// Retire any repeatable global GC schedule created by older deployments.
|
// Retire any repeatable global GC schedule created by older deployments.
|
||||||
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { McpClientService } from './mcp-client.service.js';
|
|
||||||
|
|
||||||
const MCP_LEAK_MARKER = 'MCP_LEAK_MARKER /srv/secret';
|
|
||||||
|
|
||||||
describe('McpClientService — failed connect error sanitization', () => {
|
|
||||||
const originalMcpServers = process.env['MCP_SERVERS'];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env['MCP_SERVERS'] = JSON.stringify([
|
|
||||||
{ name: 'leaky-server', url: 'http://localhost:9999/mcp' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
if (originalMcpServers === undefined) {
|
|
||||||
delete process.env['MCP_SERVERS'];
|
|
||||||
} else {
|
|
||||||
process.env['MCP_SERVERS'] = originalMcpServers;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores a generic serverEntry.error while logging the raw exception server-side', async () => {
|
|
||||||
vi.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error(MCP_LEAK_MARKER));
|
|
||||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
const service = new McpClientService();
|
|
||||||
await service.onModuleInit();
|
|
||||||
|
|
||||||
const statuses = service.getServerStatuses();
|
|
||||||
expect(statuses).toHaveLength(1);
|
|
||||||
expect(statuses[0]?.connected).toBe(false);
|
|
||||||
expect(statuses[0]?.error).toBe('Connection failed (see server logs).');
|
|
||||||
expect(statuses[0]?.error).not.toContain(MCP_LEAK_MARKER);
|
|
||||||
|
|
||||||
const loggedRawMarker = errorSpy.mock.calls.some((call) =>
|
|
||||||
call.some((arg) => typeof arg === 'string' && arg.includes(MCP_LEAK_MARKER)),
|
|
||||||
);
|
|
||||||
expect(loggedRawMarker).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -189,7 +189,7 @@ export class McpClientService implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
serverEntry.error = 'Connection failed (see server logs).';
|
serverEntry.error = message;
|
||||||
serverEntry.connected = false;
|
serverEntry.connected = false;
|
||||||
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import { SystemOverrideService } from './system-override.service.js';
|
|
||||||
|
|
||||||
const localConfig = { queue: { type: 'local' } } as MosaicConfig;
|
|
||||||
|
|
||||||
describe('SystemOverrideService local tier', () => {
|
|
||||||
it('keeps ephemeral overrides isolated by tenant and user scope', async () => {
|
|
||||||
const service = new SystemOverrideService(localConfig);
|
|
||||||
const firstScope = { tenantId: 'tenant-a', userId: 'user-a' };
|
|
||||||
const secondScope = { tenantId: 'tenant-b', userId: 'user-b' };
|
|
||||||
|
|
||||||
await service.set('shared-session', 'first override', firstScope);
|
|
||||||
await service.set('shared-session', 'second override', secondScope);
|
|
||||||
|
|
||||||
await expect(service.get('shared-session', firstScope)).resolves.toBe('first override');
|
|
||||||
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
|
|
||||||
|
|
||||||
await service.clear('shared-session', firstScope);
|
|
||||||
await expect(service.get('shared-session', firstScope)).resolves.toBeNull();
|
|
||||||
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
|
||||||
|
|
||||||
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
||||||
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
||||||
@@ -17,45 +15,16 @@ interface OverrideFragment {
|
|||||||
addedAt: number;
|
addedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LocalOverrideEntry {
|
|
||||||
condensed: string;
|
|
||||||
fragments: OverrideFragment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SystemOverrideService implements OnApplicationShutdown {
|
export class SystemOverrideService {
|
||||||
private readonly logger = new Logger(SystemOverrideService.name);
|
private readonly logger = new Logger(SystemOverrideService.name);
|
||||||
private readonly handle: QueueHandle | null;
|
private readonly handle: QueueHandle;
|
||||||
/** Local-tier fallback, keyed by the same tenant/user/session scope as Redis. */
|
|
||||||
private readonly localStore = new Map<string, LocalOverrideEntry>();
|
|
||||||
|
|
||||||
constructor(
|
constructor() {
|
||||||
@Optional()
|
this.handle = createQueue();
|
||||||
@Inject(MOSAIC_CONFIG)
|
|
||||||
private readonly mosaicConfig: MosaicConfig | null,
|
|
||||||
) {
|
|
||||||
this.handle = this.mosaicConfig?.queue?.type === 'local' ? null : createQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
async onApplicationShutdown(): Promise<void> {
|
|
||||||
await this.handle?.close().catch(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
||||||
if (!this.handle) {
|
|
||||||
const key = scopedSessionId(sessionId, scope);
|
|
||||||
const entry = this.localStore.get(key) ?? { condensed: '', fragments: [] };
|
|
||||||
entry.fragments.push({ text: override, addedAt: Date.now() });
|
|
||||||
entry.condensed = await this.condenseOverrides(
|
|
||||||
entry.fragments.map((fragment) => fragment.text),
|
|
||||||
);
|
|
||||||
this.localStore.set(key, entry);
|
|
||||||
this.logger.debug(
|
|
||||||
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load existing fragments
|
// Load existing fragments
|
||||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
||||||
const fragments: OverrideFragment[] = existing
|
const fragments: OverrideFragment[] = existing
|
||||||
@@ -85,14 +54,10 @@ export class SystemOverrideService implements OnApplicationShutdown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
||||||
if (!this.handle) {
|
|
||||||
return this.localStore.get(scopedSessionId(sessionId, scope))?.condensed ?? null;
|
|
||||||
}
|
|
||||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||||
if (!this.handle) return;
|
|
||||||
const pipeline = this.handle.redis.pipeline();
|
const pipeline = this.handle.redis.pipeline();
|
||||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
@@ -100,11 +65,6 @@ export class SystemOverrideService implements OnApplicationShutdown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||||
if (!this.handle) {
|
|
||||||
this.localStore.delete(scopedSessionId(sessionId, scope));
|
|
||||||
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.handle.redis.del(
|
await this.handle.redis.del(
|
||||||
SESSION_SYSTEM_KEY(sessionId, scope),
|
SESSION_SYSTEM_KEY(sessionId, scope),
|
||||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import { QueueService } from './queue.service.js';
|
|
||||||
|
|
||||||
const localConfig = {
|
|
||||||
queue: { type: 'local' },
|
|
||||||
} as MosaicConfig;
|
|
||||||
|
|
||||||
describe('QueueService local tier', () => {
|
|
||||||
it('disables BullMQ and treats queue operations as local no-ops', async () => {
|
|
||||||
const service = new QueueService(null, localConfig);
|
|
||||||
|
|
||||||
expect(service.isEnabled()).toBe(false);
|
|
||||||
expect(service.getQueue('mosaic-test')).toBeNull();
|
|
||||||
expect(service.registerWorker('mosaic-test', vi.fn())).toBeNull();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.addRepeatableJob('mosaic-test', 'local-noop', {}, '* * * * *'),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
await expect(service.removeRepeatableJobs('mosaic-test', 'local-noop')).resolves.toBe(0);
|
|
||||||
await expect(service.getHealthStatus()).resolves.toEqual({ queues: {}, healthy: true });
|
|
||||||
await expect(service.listJobs()).resolves.toEqual([]);
|
|
||||||
await expect(service.retryJob('mosaic-test__1')).resolves.toEqual({
|
|
||||||
ok: false,
|
|
||||||
message: 'BullMQ is disabled on local tier.',
|
|
||||||
});
|
|
||||||
await expect(service.pauseQueue('mosaic-test')).resolves.toEqual({
|
|
||||||
ok: false,
|
|
||||||
message: 'BullMQ is disabled on local tier.',
|
|
||||||
});
|
|
||||||
await expect(service.resumeQueue('mosaic-test')).resolves.toEqual({
|
|
||||||
ok: false,
|
|
||||||
message: 'BullMQ is disabled on local tier.',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -8,9 +8,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
||||||
import type { LogService } from '@mosaicstack/log';
|
import type { LogService } from '@mosaicstack/log';
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
|
||||||
import type { JobDto, JobStatus } from './queue-admin.dto.js';
|
import type { JobDto, JobStatus } from './queue-admin.dto.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -110,42 +108,21 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly connection: ConnectionOptions;
|
private readonly connection: ConnectionOptions;
|
||||||
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
||||||
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
||||||
/** False on Local tier — BullMQ/Redis operations become no-ops. */
|
|
||||||
private readonly enabled: boolean;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(LOG_SERVICE)
|
@Inject(LOG_SERVICE)
|
||||||
private readonly logService: LogService | null,
|
private readonly logService: LogService | null,
|
||||||
@Optional()
|
|
||||||
@Inject(MOSAIC_CONFIG)
|
|
||||||
private readonly mosaicConfig: MosaicConfig | null,
|
|
||||||
) {
|
) {
|
||||||
this.enabled = this.mosaicConfig?.queue?.type !== 'local';
|
this.connection = getConnection();
|
||||||
this.connection = this.enabled
|
|
||||||
? getConnection()
|
|
||||||
: ({ host: '127.0.0.1', port: 6380 } as ConnectionOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns true when BullMQ/Redis is active (Standalone and Federated tiers). */
|
|
||||||
isEnabled(): boolean {
|
|
||||||
return this.enabled;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onModuleInit(): void {
|
onModuleInit(): void {
|
||||||
if (this.enabled) {
|
this.logger.log('QueueService initialised (BullMQ)');
|
||||||
this.logger.log('QueueService initialised (BullMQ)');
|
|
||||||
} else {
|
|
||||||
this.logger.log(
|
|
||||||
'QueueService: BullMQ disabled for local tier — no Redis connections will be opened',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
if (this.enabled) {
|
await this.closeAll();
|
||||||
await this.closeAll();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -154,10 +131,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a BullMQ Queue for the given queue name.
|
* Get or create a BullMQ Queue for the given queue name.
|
||||||
* Returns null on Local tier where BullMQ is disabled.
|
|
||||||
*/
|
*/
|
||||||
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
|
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
|
||||||
if (!this.enabled) return null;
|
|
||||||
let queue = this.queues.get(name) as Queue<T> | undefined;
|
let queue = this.queues.get(name) as Queue<T> | undefined;
|
||||||
if (!queue) {
|
if (!queue) {
|
||||||
queue = new Queue<T>(name, { connection: this.connection });
|
queue = new Queue<T>(name, { connection: this.connection });
|
||||||
@@ -169,7 +144,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* Add a BullMQ repeatable job (cron-style).
|
* Add a BullMQ repeatable job (cron-style).
|
||||||
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
||||||
* No-op on Local tier.
|
|
||||||
*/
|
*/
|
||||||
async addRepeatableJob<T extends MosaicJobData>(
|
async addRepeatableJob<T extends MosaicJobData>(
|
||||||
queueName: string,
|
queueName: string,
|
||||||
@@ -177,13 +151,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
data: T,
|
data: T,
|
||||||
cronExpression: string,
|
cronExpression: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this.enabled) {
|
const queue = this.getQueue<T>(queueName);
|
||||||
this.logger.debug(
|
|
||||||
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const queue = this.getQueue<T>(queueName)!;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
await (queue as Queue<any>).add(jobName, data, {
|
await (queue as Queue<any>).add(jobName, data, {
|
||||||
repeat: { pattern: cronExpression },
|
repeat: { pattern: cronExpression },
|
||||||
@@ -199,14 +167,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* safe retirement of previously registered system-wide jobs.
|
* safe retirement of previously registered system-wide jobs.
|
||||||
*/
|
*/
|
||||||
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
||||||
if (!this.enabled) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Skipping repeatable-job removal for "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
|
|
||||||
);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const queue = this.getQueue(queueName);
|
const queue = this.getQueue(queueName);
|
||||||
if (!queue) return 0;
|
|
||||||
const jobs = await queue.getRepeatableJobs();
|
const jobs = await queue.getRepeatableJobs();
|
||||||
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
||||||
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
||||||
@@ -221,18 +182,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* Register a Worker for the given queue name with error handling and
|
* Register a Worker for the given queue name with error handling and
|
||||||
* exponential backoff.
|
* exponential backoff.
|
||||||
* Returns null on Local tier where BullMQ is disabled.
|
|
||||||
*/
|
*/
|
||||||
registerWorker<T extends MosaicJobData>(
|
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
|
||||||
queueName: string,
|
|
||||||
handler: JobHandler<T>,
|
|
||||||
): Worker<T> | null {
|
|
||||||
if (!this.enabled) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const worker = new Worker<T>(
|
const worker = new Worker<T>(
|
||||||
queueName,
|
queueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
@@ -289,12 +240,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Return queue health statistics for all managed queues.
|
* Return queue health statistics for all managed queues.
|
||||||
* Returns an empty healthy result on Local tier.
|
|
||||||
*/
|
*/
|
||||||
async getHealthStatus(): Promise<QueueHealthStatus> {
|
async getHealthStatus(): Promise<QueueHealthStatus> {
|
||||||
if (!this.enabled) {
|
|
||||||
return { queues: {}, healthy: true };
|
|
||||||
}
|
|
||||||
const queues: QueueHealthStatus['queues'] = {};
|
const queues: QueueHealthStatus['queues'] = {};
|
||||||
let healthy = true;
|
let healthy = true;
|
||||||
|
|
||||||
@@ -325,10 +272,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* List jobs across all managed queues, optionally filtered by status.
|
* List jobs across all managed queues, optionally filtered by status.
|
||||||
* BullMQ jobs are fetched by state type from each queue.
|
* BullMQ jobs are fetched by state type from each queue.
|
||||||
* Returns empty array on Local tier.
|
|
||||||
*/
|
*/
|
||||||
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
||||||
if (!this.enabled) return [];
|
|
||||||
const jobs: JobDto[] = [];
|
const jobs: JobDto[] = [];
|
||||||
const states: JobStatus[] = status
|
const states: JobStatus[] = status
|
||||||
? [status]
|
? [status]
|
||||||
@@ -355,10 +300,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
|
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
|
||||||
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
||||||
* job IDs are not globally unique — they are scoped to their queue.
|
* job IDs are not globally unique — they are scoped to their queue.
|
||||||
* Returns an error on Local tier.
|
|
||||||
*/
|
*/
|
||||||
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
|
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
|
||||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
|
||||||
const sep = compositeId.lastIndexOf('__');
|
const sep = compositeId.lastIndexOf('__');
|
||||||
if (sep === -1) {
|
if (sep === -1) {
|
||||||
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
|
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
|
||||||
@@ -390,7 +333,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Pause a queue by name.
|
* Pause a queue by name.
|
||||||
*/
|
*/
|
||||||
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
|
||||||
const queue = this.queues.get(name);
|
const queue = this.queues.get(name);
|
||||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||||
await queue.pause();
|
await queue.pause();
|
||||||
@@ -402,7 +344,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Resume a paused queue by name.
|
* Resume a paused queue by name.
|
||||||
*/
|
*/
|
||||||
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
|
||||||
const queue = this.queues.get(name);
|
const queue = this.queues.get(name);
|
||||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||||
await queue.resume();
|
await queue.resume();
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import type { SlashCommandPayload, SystemReloadPayload } from '@mosaicstack/types';
|
|
||||||
import { ReloadService } from './reload.service.js';
|
import { ReloadService } from './reload.service.js';
|
||||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
|
||||||
|
|
||||||
function createMockCommandRegistry() {
|
function createMockCommandRegistry() {
|
||||||
return {
|
return {
|
||||||
@@ -107,79 +104,3 @@ describe('ReloadService', () => {
|
|||||||
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
|
||||||
it('generic per-plugin errors reach the chat surface while raw markers stay server-side only', async () => {
|
|
||||||
const registry = {
|
|
||||||
getManifest: vi.fn().mockReturnValue({
|
|
||||||
version: 1,
|
|
||||||
commands: [
|
|
||||||
{ name: 'reload', aliases: [], scope: 'core', execution: 'socket', available: true },
|
|
||||||
],
|
|
||||||
skills: [],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const reloadService = new ReloadService(registry as never);
|
|
||||||
|
|
||||||
const RELOAD_LOAD_LEAK_MARKER = 'RELOAD_LOAD_LEAK_MARKER /srv/load-secret';
|
|
||||||
const RELOAD_UNLOAD_LEAK_MARKER = 'RELOAD_UNLOAD_LEAK_MARKER /srv/unload-secret';
|
|
||||||
|
|
||||||
reloadService.registerPlugin('unload-fails', {
|
|
||||||
pluginName: 'unload-fails',
|
|
||||||
onLoad: vi.fn().mockResolvedValue(undefined),
|
|
||||||
onUnload: vi.fn().mockRejectedValue(new Error(RELOAD_UNLOAD_LEAK_MARKER)),
|
|
||||||
});
|
|
||||||
reloadService.registerPlugin('load-fails', {
|
|
||||||
pluginName: 'load-fails',
|
|
||||||
onLoad: vi.fn().mockRejectedValue(new Error(RELOAD_LOAD_LEAK_MARKER)),
|
|
||||||
onUnload: vi.fn().mockResolvedValue(undefined),
|
|
||||||
});
|
|
||||||
|
|
||||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
const broadcastReload = vi.fn();
|
|
||||||
const mockChatGateway = { broadcastReload };
|
|
||||||
const mockAgentService = { getSession: vi.fn(), applyAgentConfig: vi.fn() };
|
|
||||||
const mockSystemOverride = { set: vi.fn(), get: vi.fn(), clear: vi.fn() };
|
|
||||||
const mockSessionGC = { sweepOrphans: vi.fn() };
|
|
||||||
const mockBrain = { agents: { findByName: vi.fn(), findById: vi.fn(), create: vi.fn() } };
|
|
||||||
|
|
||||||
const executor = new CommandExecutorService(
|
|
||||||
registry as never,
|
|
||||||
mockAgentService as never,
|
|
||||||
mockSystemOverride as never,
|
|
||||||
mockSessionGC as never,
|
|
||||||
null,
|
|
||||||
mockBrain as never,
|
|
||||||
reloadService,
|
|
||||||
mockChatGateway as never,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
|
||||||
const result = await executor.execute(payload, { userId: 'user-1', tenantId: 'user-1' });
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.message).toContain('unload-fails: unload failed (internal error)');
|
|
||||||
expect(result.message).toContain('load-fails: load failed (internal error)');
|
|
||||||
expect(result.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
|
|
||||||
expect(result.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
|
|
||||||
|
|
||||||
expect(broadcastReload).toHaveBeenCalledOnce();
|
|
||||||
const broadcastPayload = broadcastReload.mock.calls[0]?.[0] as SystemReloadPayload;
|
|
||||||
expect(broadcastPayload.message).toContain('unload-fails: unload failed (internal error)');
|
|
||||||
expect(broadcastPayload.message).toContain('load-fails: load failed (internal error)');
|
|
||||||
expect(broadcastPayload.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
|
|
||||||
expect(broadcastPayload.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
|
|
||||||
|
|
||||||
const loggedUnloadMarker = errorSpy.mock.calls.some((call) =>
|
|
||||||
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_UNLOAD_LEAK_MARKER)),
|
|
||||||
);
|
|
||||||
const loggedLoadMarker = errorSpy.mock.calls.some((call) =>
|
|
||||||
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_LOAD_LEAK_MARKER)),
|
|
||||||
);
|
|
||||||
expect(loggedUnloadMarker).toBe(true);
|
|
||||||
expect(loggedLoadMarker).toBe(true);
|
|
||||||
|
|
||||||
errorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
|
|||||||
await plugin.onUnload();
|
await plugin.onUnload();
|
||||||
reloaded.push(name);
|
reloaded.push(name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Plugin "${name}" failed during onUnload: ${err}`);
|
errors.push(`${name}: unload failed — ${err}`);
|
||||||
errors.push(`${name}: unload failed (internal error)`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,8 +69,7 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
|
|||||||
try {
|
try {
|
||||||
await plugin.onLoad();
|
await plugin.onLoad();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Plugin "${name}" failed during onLoad: ${err}`);
|
errors.push(`${name}: load failed — ${err}`);
|
||||||
errors.push(`${name}: load failed (internal error)`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import {
|
|
||||||
type CanActivate,
|
|
||||||
type ExecutionContext,
|
|
||||||
type INestApplication,
|
|
||||||
ValidationPipe,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
|
||||||
import { WorkspaceController } from './workspace.controller.js';
|
|
||||||
|
|
||||||
const bootstrapMock = vi.fn(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
projectId: 'project-1',
|
|
||||||
workspacePath: '/opt/mosaic/.workspaces/users/user-1/project-1',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const authGuard: CanActivate = {
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
|
||||||
requestContext.user = { id: 'user-1' };
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('POST /api/workspaces repoUrl validation', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
controllers: [WorkspaceController],
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: ProjectBootstrapService,
|
|
||||||
useValue: { bootstrap: bootstrapMock },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.overrideGuard(AuthGuard)
|
|
||||||
.useValue(authGuard)
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
||||||
app.useGlobalPipes(
|
|
||||||
new ValidationPipe({
|
|
||||||
whitelist: true,
|
|
||||||
forbidNonWhitelisted: true,
|
|
||||||
transform: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await app.init();
|
|
||||||
await app.getHttpAdapter().getInstance().ready();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
bootstrapMock.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['a leading-dash value', '--upload-pack=sh -c id'],
|
|
||||||
['an ext remote helper', 'ext::sh -c id'],
|
|
||||||
['a file URL', 'file:///tmp/repository'],
|
|
||||||
['an unparseable value', 'not a url'],
|
|
||||||
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
|
||||||
['a scheme without //', 'https:example.com/acme/repository.git'],
|
|
||||||
['a hostless git URL', 'git:///tmp/repository'],
|
|
||||||
])('returns 400 for %s', async (_description, repoUrl) => {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.post('/api/workspaces')
|
|
||||||
.send({ name: 'Example', repoUrl })
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(bootstrapMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['a plain HTTPS repository URL', 'https://example.com/acme/repository.git'],
|
|
||||||
['a git protocol repository URL', 'git://example.com/acme/repository.git'],
|
|
||||||
])('accepts %s', async (_description, repoUrl) => {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.post('/api/workspaces')
|
|
||||||
.send({ name: 'Example', repoUrl })
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
|
||||||
expect(bootstrapMock).toHaveBeenCalledWith({
|
|
||||||
name: 'Example',
|
|
||||||
description: undefined,
|
|
||||||
userId: 'user-1',
|
|
||||||
teamId: undefined,
|
|
||||||
repoUrl,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import {
|
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||||
ProjectBootstrapService,
|
|
||||||
type BootstrapProjectResult,
|
|
||||||
} from './project-bootstrap.service.js';
|
|
||||||
import { CreateWorkspaceDto } from './workspace.dto.js';
|
|
||||||
|
|
||||||
@Controller('api/workspaces')
|
@Controller('api/workspaces')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -15,14 +11,20 @@ export class WorkspaceController {
|
|||||||
@Post()
|
@Post()
|
||||||
async create(
|
async create(
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
@Body() dto: CreateWorkspaceDto,
|
@Body()
|
||||||
): Promise<BootstrapProjectResult> {
|
body: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
teamId?: string;
|
||||||
|
repoUrl?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
return this.bootstrap.bootstrap({
|
return this.bootstrap.bootstrap({
|
||||||
name: dto.name,
|
name: body.name,
|
||||||
description: dto.description,
|
description: body.description,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: dto.teamId,
|
teamId: body.teamId,
|
||||||
repoUrl: dto.repoUrl,
|
repoUrl: body.repoUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { IsOptional, IsString, IsUrl, Matches, MaxLength } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateWorkspaceDto {
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(255)
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(10_000)
|
|
||||||
description?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
teamId?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@Matches(/^(?:https|git):\/\//i, {
|
|
||||||
message: 'repoUrl must be a valid https:// or git:// URL',
|
|
||||||
})
|
|
||||||
@IsUrl(
|
|
||||||
{
|
|
||||||
protocols: ['https', 'git'],
|
|
||||||
require_host: true,
|
|
||||||
require_protocol: true,
|
|
||||||
require_tld: false,
|
|
||||||
require_valid_protocol: true,
|
|
||||||
},
|
|
||||||
{ message: 'repoUrl must be a valid https:// or git:// URL' },
|
|
||||||
)
|
|
||||||
repoUrl?: string;
|
|
||||||
}
|
|
||||||
@@ -1,33 +1,11 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { WorkspaceService } from './workspace.service.js';
|
import { WorkspaceService } from './workspace.service.js';
|
||||||
|
import path from 'node:path';
|
||||||
type ExecFileMock = (
|
|
||||||
command: string,
|
|
||||||
args: readonly string[],
|
|
||||||
options: { cwd: string },
|
|
||||||
callback: (error: Error | null, stdout: string, stderr: string) => void,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
const { execFileMock } = vi.hoisted(() => ({
|
|
||||||
execFileMock: vi.fn<ExecFileMock>(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('node:child_process', () => ({
|
|
||||||
execFile: execFileMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('WorkspaceService', () => {
|
describe('WorkspaceService', () => {
|
||||||
let service: WorkspaceService;
|
let service: WorkspaceService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
execFileMock.mockReset();
|
|
||||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
|
||||||
callback(null, '', '');
|
|
||||||
});
|
|
||||||
service = new WorkspaceService();
|
service = new WorkspaceService();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,69 +76,4 @@ describe('WorkspaceService', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('create', () => {
|
|
||||||
const project = {
|
|
||||||
id: 'project-1',
|
|
||||||
ownerType: 'user',
|
|
||||||
userId: 'user-1',
|
|
||||||
teamId: null,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
let originalRoot: string | undefined;
|
|
||||||
let temporaryRoot: string;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
originalRoot = process.env['MOSAIC_ROOT'];
|
|
||||||
temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mosaic-workspace-'));
|
|
||||||
process.env['MOSAIC_ROOT'] = temporaryRoot;
|
|
||||||
service = new WorkspaceService();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
if (originalRoot === undefined) {
|
|
||||||
delete process.env['MOSAIC_ROOT'];
|
|
||||||
} else {
|
|
||||||
process.env['MOSAIC_ROOT'] = originalRoot;
|
|
||||||
}
|
|
||||||
await fs.rm(temporaryRoot, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['a leading-dash URL', '--upload-pack=sh -c id'],
|
|
||||||
['an ext remote helper', 'ext::sh -c id'],
|
|
||||||
['a file URL', 'file:///tmp/repository'],
|
|
||||||
['an unparseable value', 'not a url'],
|
|
||||||
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
|
||||||
['a scheme without //', 'https:example.com/acme/repository.git'],
|
|
||||||
['a hostless git URL', 'git:///tmp/repository'],
|
|
||||||
])('rejects %s before invoking git', async (_description, repoUrl) => {
|
|
||||||
await expect(service.create(project, repoUrl)).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
expect(execFileMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['an HTTPS URL', 'https://example.com/acme/repository.git'],
|
|
||||||
['a git protocol URL', 'git://example.com/acme/repository.git'],
|
|
||||||
])('accepts %s and invokes hardened git clone arguments', async (_description, repoUrl) => {
|
|
||||||
const workspacePath = await service.create(project, repoUrl);
|
|
||||||
|
|
||||||
expect(execFileMock).toHaveBeenCalledOnce();
|
|
||||||
expect(execFileMock).toHaveBeenCalledWith(
|
|
||||||
'git',
|
|
||||||
[
|
|
||||||
'-c',
|
|
||||||
'protocol.ext.allow=never',
|
|
||||||
'-c',
|
|
||||||
'protocol.file.allow=never',
|
|
||||||
'clone',
|
|
||||||
'--',
|
|
||||||
repoUrl,
|
|
||||||
'.',
|
|
||||||
],
|
|
||||||
{ cwd: workspacePath },
|
|
||||||
expect.any(Function),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,30 +1,10 @@
|
|||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const allowedRepositoryProtocols = new Set(['https:', 'git:']);
|
|
||||||
const repositoryUrlPrefixPattern = /^(?:https|git):\/\//i;
|
|
||||||
const repositoryUrlError = 'repoUrl must be a valid https:// or git:// URL';
|
|
||||||
|
|
||||||
function assertAllowedRepositoryUrl(repoUrl: string): void {
|
|
||||||
if (repoUrl.startsWith('-') || !repositoryUrlPrefixPattern.test(repoUrl)) {
|
|
||||||
throw new BadRequestException(repositoryUrlError);
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsedUrl: URL;
|
|
||||||
try {
|
|
||||||
parsedUrl = new URL(repoUrl);
|
|
||||||
} catch {
|
|
||||||
throw new BadRequestException(repositoryUrlError);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allowedRepositoryProtocols.has(parsedUrl.protocol) || parsedUrl.hostname.length === 0) {
|
|
||||||
throw new BadRequestException(repositoryUrlError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkspaceProject {
|
export interface WorkspaceProject {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -59,32 +39,14 @@ export class WorkspaceService {
|
|||||||
* If repoUrl is provided, clone instead of init.
|
* If repoUrl is provided, clone instead of init.
|
||||||
*/
|
*/
|
||||||
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
||||||
if (repoUrl !== undefined) {
|
|
||||||
assertAllowedRepositoryUrl(repoUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspacePath = this.resolvePath(project);
|
const workspacePath = this.resolvePath(project);
|
||||||
|
|
||||||
// Create directory
|
// Create directory
|
||||||
await fs.mkdir(workspacePath, { recursive: true });
|
await fs.mkdir(workspacePath, { recursive: true });
|
||||||
|
|
||||||
if (repoUrl !== undefined) {
|
if (repoUrl) {
|
||||||
// Clone existing repo. Defense in depth keeps dangerous local helpers
|
// Clone existing repo
|
||||||
// disabled and terminates option parsing before positional arguments.
|
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath });
|
||||||
await execFileAsync(
|
|
||||||
'git',
|
|
||||||
[
|
|
||||||
'-c',
|
|
||||||
'protocol.ext.allow=never',
|
|
||||||
'-c',
|
|
||||||
'protocol.file.allow=never',
|
|
||||||
'clone',
|
|
||||||
'--',
|
|
||||||
repoUrl,
|
|
||||||
'.',
|
|
||||||
],
|
|
||||||
{ cwd: workspacePath },
|
|
||||||
);
|
|
||||||
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
||||||
} else {
|
} else {
|
||||||
// Init new git repo
|
// Init new git repo
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Mosaic</title>
|
|
||||||
<meta name="description" content="Mosaic Stack Dashboard" />
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
|
|
||||||
/>
|
|
||||||
<script>
|
|
||||||
// set data-theme before first paint so the stored theme never flashes
|
|
||||||
(function () {
|
|
||||||
try {
|
|
||||||
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
|
|
||||||
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
|
|
||||||
} catch (error) {
|
|
||||||
document.documentElement.setAttribute('data-theme', 'dark');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -4,9 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node ../../scripts/build-web.mjs",
|
"build": "node ../../scripts/build-web.mjs",
|
||||||
"build:vite": "vite build",
|
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"dev:vite": "vite",
|
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
@@ -15,14 +13,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/design-tokens": "workspace:^",
|
"@mosaicstack/design-tokens": "workspace:^",
|
||||||
"@mosaicstack/types": "workspace:^",
|
|
||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.18.2",
|
|
||||||
"socket.io-client": "^4.8.0",
|
"socket.io-client": "^4.8.0",
|
||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
@@ -32,11 +28,9 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^6.0.5",
|
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vite": "^8.2.1",
|
"vitest": "^2.0.0"
|
||||||
"vitest": "^3.2.7"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,56 +3,41 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useSearchParams } from 'next/navigation';
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
|
||||||
import { signIn } from '@/lib/auth-client';
|
import { signIn } from '@/lib/auth-client';
|
||||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
import { getSsoProvider } from '@/lib/sso-providers';
|
||||||
|
|
||||||
export default function AuthProviderRedirectPage(): React.ReactElement {
|
export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||||
const params = useParams<{ provider: string }>();
|
const params = useParams<{ provider: string }>();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
||||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
const provider = getSsoProvider(providerId);
|
||||||
const [providerName, setProviderName] = useState<string | null>(null);
|
const callbackURL = searchParams.get('callbackURL') ?? '/chat';
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const currentProvider = provider;
|
||||||
|
|
||||||
|
if (!currentProvider) {
|
||||||
|
setError('Unknown SSO provider.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentProvider.enabled) {
|
||||||
|
setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeProvider = currentProvider;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function redirectToProvider(): Promise<void> {
|
async function redirectToProvider(): Promise<void> {
|
||||||
try {
|
const result = await signIn.oauth2({
|
||||||
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
providerId: activeProvider.id,
|
||||||
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
callbackURL,
|
||||||
if (cancelled) return;
|
});
|
||||||
|
|
||||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
if (!cancelled && result?.error) {
|
||||||
if (!provider) {
|
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
|
||||||
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.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,22 +46,19 @@ export default function AuthProviderRedirectPage(): React.ReactElement {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [providerId, requestedCallbackURL]);
|
}, [callbackURL, provider]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||||
<p className="mt-2 text-sm text-text-secondary">
|
<p className="mt-2 text-sm text-text-secondary">
|
||||||
{providerName
|
{provider
|
||||||
? `Redirecting you to ${providerName}...`
|
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...`
|
||||||
: 'Preparing your sign-in request...'}
|
: 'Preparing your sign-in request...'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div
|
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error">
|
||||||
role="alert"
|
|
||||||
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
|
||||||
>
|
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { api } from './api';
|
|
||||||
|
|
||||||
describe('api', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches the supplied relative path with credentials and a JSON body', async () => {
|
|
||||||
const fetchMock = vi.fn<typeof fetch>();
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ ok: true }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
api<{ ok: boolean }>('/api/projects', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { name: 'Mosaic' },
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({ ok: true });
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledOnce();
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
'/api/projects',
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ name: 'Mosaic' }),
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws the gateway JSON error with its statusCode', async () => {
|
|
||||||
const fetchMock = vi.fn<typeof fetch>();
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ statusCode: 403, message: 'Forbidden' }), {
|
|
||||||
status: 403,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
await expect(api('/api/admin/users')).rejects.toMatchObject({
|
|
||||||
name: 'Error',
|
|
||||||
message: 'Forbidden',
|
|
||||||
statusCode: 403,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
||||||
|
|
||||||
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
}
|
}
|
||||||
@@ -23,7 +25,7 @@ export async function api<T>(path: string, init?: ApiRequestInit): Promise<T> {
|
|||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(path, {
|
const res = await fetch(`${GATEWAY_URL}${path}`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
...rest,
|
...rest,
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
describe('auth client origin contract', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
vi.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the same-origin BetterAuth mount at /api/auth', async () => {
|
|
||||||
const fetchMock = vi.fn<typeof fetch>();
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ session: null, user: null }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
const { authClient } = await import('./auth-client');
|
|
||||||
await authClient.getSession();
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledOnce();
|
|
||||||
const firstCall = fetchMock.mock.calls.at(0);
|
|
||||||
expect(firstCall).toBeDefined();
|
|
||||||
const requestURL = new URL(String(firstCall?.[0]), window.location.origin);
|
|
||||||
expect(requestURL.origin).toBe(window.location.origin);
|
|
||||||
expect(requestURL.pathname).toBe('/api/auth/get-session');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { createAuthClient } from 'better-auth/react';
|
import { createAuthClient } from 'better-auth/react';
|
||||||
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
||||||
|
|
||||||
// The gateway and BetterAuth client both use /api/auth. Omitting baseURL keeps
|
|
||||||
// every browser request on the current origin in development and production.
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
|
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242',
|
||||||
plugins: [adminClient(), genericOAuthClient()],
|
plugins: [adminClient(), genericOAuthClient()],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { resolveAuthCallbackURL } from './auth-redirect';
|
|
||||||
|
|
||||||
const CURRENT_ORIGIN = 'https://mosaic.example';
|
|
||||||
|
|
||||||
describe('resolveAuthCallbackURL', () => {
|
|
||||||
it('preserves a canonical same-origin path with search and hash', () => {
|
|
||||||
expect(resolveAuthCallbackURL('/projects?view=active#current', CURRENT_ORIGIN)).toBe(
|
|
||||||
'/projects?view=active#current',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
null,
|
|
||||||
'chat',
|
|
||||||
'//evil.example',
|
|
||||||
'/..//evil.com',
|
|
||||||
'/..//evil.com/x',
|
|
||||||
'/./..//evil.com',
|
|
||||||
'/../..//evil.com',
|
|
||||||
'/foo/..//evil.com',
|
|
||||||
'/\\evil.example',
|
|
||||||
'/\n//evil.example',
|
|
||||||
'/\r//evil.example',
|
|
||||||
'/\t//evil.example',
|
|
||||||
'https://evil.example/phish',
|
|
||||||
])('falls back to chat for an unsafe callback target %#', (candidate) => {
|
|
||||||
expect(resolveAuthCallbackURL(candidate, CURRENT_ORIGIN)).toBe('/chat');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
const DEFAULT_AUTH_CALLBACK_URL = '/chat';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a canonical same-origin path for post-auth navigation.
|
|
||||||
*
|
|
||||||
* Parsing before comparing origins rejects protocol-relative URLs, backslash
|
|
||||||
* variants, and control characters that the WHATWG parser normalizes away.
|
|
||||||
*/
|
|
||||||
export function resolveAuthCallbackURL(candidate: string | null, currentOrigin: string): string {
|
|
||||||
if (!candidate?.startsWith('/')) return DEFAULT_AUTH_CALLBACK_URL;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const expectedOrigin = new URL(currentOrigin).origin;
|
|
||||||
const resolved = new URL(candidate, expectedOrigin);
|
|
||||||
if (resolved.origin !== expectedOrigin || resolved.pathname.startsWith('//')) {
|
|
||||||
return DEFAULT_AUTH_CALLBACK_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_AUTH_CALLBACK_URL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
// Centralizes the type-only import of the shared `/chat` Socket.IO contract from
|
|
||||||
// the public `@mosaicstack/types` package. `import type` is erased at compile
|
|
||||||
// time, so this introduces no runtime dependency — it only reuses the exact
|
|
||||||
// payload shapes instead of redeclaring them.
|
|
||||||
import type { Socket } from 'socket.io-client';
|
|
||||||
import type {
|
|
||||||
AbortPayload,
|
|
||||||
AgentEndPayload,
|
|
||||||
AgentStartPayload,
|
|
||||||
AgentTextPayload,
|
|
||||||
AgentThinkingPayload,
|
|
||||||
ChatMessagePayload,
|
|
||||||
ClientToServerEvents,
|
|
||||||
CommandDef,
|
|
||||||
CommandManifest,
|
|
||||||
CommandManifestPayload,
|
|
||||||
ErrorPayload,
|
|
||||||
MessageAckPayload,
|
|
||||||
RoutingDecisionInfo,
|
|
||||||
ServerToClientEvents,
|
|
||||||
SessionInfoPayload,
|
|
||||||
SessionUsagePayload,
|
|
||||||
SetThinkingPayload,
|
|
||||||
SkillCommandDef,
|
|
||||||
SlashCommandApprovalResultPayload,
|
|
||||||
SlashCommandPayload,
|
|
||||||
SlashCommandResultPayload,
|
|
||||||
SystemReloadPayload,
|
|
||||||
ToolEndPayload,
|
|
||||||
ToolStartPayload,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
AbortPayload,
|
|
||||||
AgentEndPayload,
|
|
||||||
AgentStartPayload,
|
|
||||||
AgentTextPayload,
|
|
||||||
AgentThinkingPayload,
|
|
||||||
ChatMessagePayload,
|
|
||||||
ClientToServerEvents,
|
|
||||||
CommandDef,
|
|
||||||
CommandManifest,
|
|
||||||
CommandManifestPayload,
|
|
||||||
ErrorPayload,
|
|
||||||
MessageAckPayload,
|
|
||||||
RoutingDecisionInfo,
|
|
||||||
ServerToClientEvents,
|
|
||||||
SessionInfoPayload,
|
|
||||||
SessionUsagePayload,
|
|
||||||
SetThinkingPayload,
|
|
||||||
SkillCommandDef,
|
|
||||||
SlashCommandApprovalResultPayload,
|
|
||||||
SlashCommandPayload,
|
|
||||||
SlashCommandResultPayload,
|
|
||||||
SystemReloadPayload,
|
|
||||||
ToolEndPayload,
|
|
||||||
ToolStartPayload,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The `/chat` namespace socket, narrowed to the exact typed event contract. */
|
|
||||||
export type ChatSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const { ioMock } = vi.hoisted(() => ({
|
|
||||||
ioMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('socket.io-client', () => ({
|
|
||||||
io: ioMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { destroySocket, getSocket } from './socket';
|
|
||||||
|
|
||||||
interface MockChatSocket {
|
|
||||||
on: ReturnType<typeof vi.fn>;
|
|
||||||
offAny: ReturnType<typeof vi.fn>;
|
|
||||||
disconnect: ReturnType<typeof vi.fn>;
|
|
||||||
/** Test-only helper: fires every handler registered for `event` via
|
|
||||||
* `.on`, mirroring how a real socket.io-client instance invokes its own
|
|
||||||
* listeners (e.g. calling the registered `disconnect` handler(s) on a
|
|
||||||
* real transient disconnect). */
|
|
||||||
trigger(event: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockSocket(): MockChatSocket {
|
|
||||||
const handlers = new Map<string, Set<() => void>>();
|
|
||||||
const mockSocket: MockChatSocket = {
|
|
||||||
on: vi.fn((event: string, handler: () => void) => {
|
|
||||||
if (!handlers.has(event)) handlers.set(event, new Set());
|
|
||||||
handlers.get(event)?.add(handler);
|
|
||||||
return mockSocket;
|
|
||||||
}),
|
|
||||||
offAny: vi.fn(() => mockSocket),
|
|
||||||
disconnect: vi.fn(() => mockSocket),
|
|
||||||
trigger(event: string): void {
|
|
||||||
for (const handler of handlers.get(event) ?? []) handler();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return mockSocket;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentMock!: MockChatSocket;
|
|
||||||
|
|
||||||
describe('chat socket', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
ioMock.mockReset();
|
|
||||||
// A fresh object per io() call so identity assertions (same singleton vs.
|
|
||||||
// a genuinely new instance) are meaningful.
|
|
||||||
ioMock.mockImplementation(() => {
|
|
||||||
currentMock = createMockSocket();
|
|
||||||
return currentMock;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
destroySocket();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates one same-origin /chat namespace socket', () => {
|
|
||||||
const first = getSocket();
|
|
||||||
const second = getSocket();
|
|
||||||
|
|
||||||
expect(first).toBe(second);
|
|
||||||
expect(ioMock).toHaveBeenCalledOnce();
|
|
||||||
expect(ioMock).toHaveBeenCalledWith('/chat', {
|
|
||||||
withCredentials: true,
|
|
||||||
autoConnect: false,
|
|
||||||
transports: ['websocket', 'polling'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the same singleton instance across a transient disconnect', () => {
|
|
||||||
const first = getSocket();
|
|
||||||
|
|
||||||
// socket.ts must not react to a real socket's `disconnect` event by
|
|
||||||
// nulling the singleton — it registers no such handler at all now.
|
|
||||||
// Actually fire every handler registered via `.on('disconnect', ...)`
|
|
||||||
// (mirroring a real socket.io-client reconnect) instead of merely
|
|
||||||
// calling getSocket() again: this is what makes the test fail if
|
|
||||||
// production reintroduces `socket.on('disconnect', () => { socket =
|
|
||||||
// null; })`, since that handler would run here and null the singleton
|
|
||||||
// before the next getSocket() call.
|
|
||||||
currentMock.trigger('disconnect');
|
|
||||||
const second = getSocket();
|
|
||||||
|
|
||||||
expect(second).toBe(first);
|
|
||||||
expect(ioMock).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only creates a new singleton after an explicit destroySocket()', () => {
|
|
||||||
const first = getSocket();
|
|
||||||
|
|
||||||
destroySocket();
|
|
||||||
const second = getSocket();
|
|
||||||
|
|
||||||
expect(second).not.toBe(first);
|
|
||||||
expect(ioMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+13
-17
@@ -1,27 +1,23 @@
|
|||||||
import { io } from 'socket.io-client';
|
import { io, type Socket } from 'socket.io-client';
|
||||||
import type { ChatSocket } from './chat-contract';
|
|
||||||
|
|
||||||
let socket: ChatSocket | null = null;
|
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
||||||
|
|
||||||
export function getSocket(): ChatSocket {
|
let socket: Socket | null = null;
|
||||||
|
|
||||||
|
export function getSocket(): Socket {
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
// socket.io-client 4.8.3's `io()` factory declaration always returns the
|
socket = io(`${GATEWAY_URL}/chat`, {
|
||||||
// default unparameterized Socket (it accepts no <ListenEvents, EmitEvents>
|
|
||||||
// generics), so this one cast is the unavoidable boundary between that and the
|
|
||||||
// typed `/chat` contract. Every other call site uses the resulting ChatSocket
|
|
||||||
// with no further assertions.
|
|
||||||
socket = io('/chat', {
|
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
}) as unknown as ChatSocket;
|
});
|
||||||
|
|
||||||
// A transient `disconnect` (network blip, server restart) must NOT null
|
// Reset singleton reference when socket is fully closed so the next
|
||||||
// the singleton: socket.io-client auto-reconnects this same instance,
|
// getSocket() call creates a fresh instance instead of returning a
|
||||||
// and its listeners stay registered across that reconnect. Nulling here
|
// closed/dead socket.
|
||||||
// previously orphaned those listeners on the next getSocket() call by
|
socket.on('disconnect', () => {
|
||||||
// handing back a brand-new, unconnected instance. Only destroySocket()
|
socket = null;
|
||||||
// (an explicit, intentional teardown) may reset the singleton.
|
});
|
||||||
}
|
}
|
||||||
return socket;
|
return socket;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getEnabledSsoProviders, getSsoProvider } from './sso-providers';
|
||||||
|
|
||||||
|
describe('sso-providers', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the enabled providers in login button order', () => {
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true');
|
||||||
|
|
||||||
|
expect(getEnabledSsoProviders()).toEqual([
|
||||||
|
{
|
||||||
|
id: 'workos',
|
||||||
|
buttonLabel: 'Continue with WorkOS',
|
||||||
|
description: 'Enterprise SSO via WorkOS',
|
||||||
|
enabled: true,
|
||||||
|
href: '/auth/provider/workos',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'keycloak',
|
||||||
|
buttonLabel: 'Continue with Keycloak',
|
||||||
|
description: 'Enterprise SSO via Keycloak',
|
||||||
|
enabled: true,
|
||||||
|
href: '/auth/provider/keycloak',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks disabled providers without exposing them in the enabled list', () => {
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false');
|
||||||
|
|
||||||
|
expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']);
|
||||||
|
expect(getSsoProvider('keycloak')).toEqual({
|
||||||
|
id: 'keycloak',
|
||||||
|
buttonLabel: 'Continue with Keycloak',
|
||||||
|
description: 'Enterprise SSO via Keycloak',
|
||||||
|
enabled: false,
|
||||||
|
href: '/auth/provider/keycloak',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown providers', () => {
|
||||||
|
expect(getSsoProvider('authentik')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export type SsoProviderId = 'workos' | 'keycloak';
|
||||||
|
|
||||||
|
export interface SsoProvider {
|
||||||
|
id: SsoProviderId;
|
||||||
|
buttonLabel: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDER_METADATA: Record<SsoProviderId, Omit<SsoProvider, 'enabled' | 'href'>> = {
|
||||||
|
workos: {
|
||||||
|
id: 'workos',
|
||||||
|
buttonLabel: 'Continue with WorkOS',
|
||||||
|
description: 'Enterprise SSO via WorkOS',
|
||||||
|
},
|
||||||
|
keycloak: {
|
||||||
|
id: 'keycloak',
|
||||||
|
buttonLabel: 'Continue with Keycloak',
|
||||||
|
description: 'Enterprise SSO via Keycloak',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getEnabledSsoProviders(): SsoProvider[] {
|
||||||
|
return (Object.keys(PROVIDER_METADATA) as SsoProviderId[])
|
||||||
|
.map((providerId) => getSsoProvider(providerId))
|
||||||
|
.filter((provider): provider is SsoProvider => provider?.enabled === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSsoProvider(providerId: string): SsoProvider | null {
|
||||||
|
if (!isSsoProviderId(providerId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...PROVIDER_METADATA[providerId],
|
||||||
|
enabled: isSsoProviderEnabled(providerId),
|
||||||
|
href: `/auth/provider/${providerId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSsoProviderId(value: string): value is SsoProviderId {
|
||||||
|
return value === 'workos' || value === 'keycloak';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSsoProviderEnabled(providerId: SsoProviderId): boolean {
|
||||||
|
switch (providerId) {
|
||||||
|
case 'workos':
|
||||||
|
return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
|
||||||
|
case 'keycloak':
|
||||||
|
return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { StrictMode } from 'react';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import { RouterProvider } from 'react-router-dom';
|
|
||||||
import { ThemeProvider } from '@/providers/theme-provider';
|
|
||||||
import { createAppRouter } from '@/routes';
|
|
||||||
import '@/app/globals.css';
|
|
||||||
|
|
||||||
const container = document.getElementById('root');
|
|
||||||
if (!container) {
|
|
||||||
throw new Error('missing #root element');
|
|
||||||
}
|
|
||||||
|
|
||||||
createRoot(container).render(
|
|
||||||
<StrictMode>
|
|
||||||
<ThemeProvider>
|
|
||||||
<RouterProvider router={createAppRouter()} />
|
|
||||||
</ThemeProvider>
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-router-dom';
|
|
||||||
import { LoginPage } from '@/spa/pages/login';
|
|
||||||
import { RegisterPage } from '@/spa/pages/register';
|
|
||||||
import { SsoCallbackPage } from '@/spa/pages/sso-callback';
|
|
||||||
import { ChatPage } from '@/spa/pages/chat';
|
|
||||||
import { ChatRouteErrorBoundary } from '@/spa/pages/chat-error-boundary';
|
|
||||||
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
|
||||||
import { Placeholder } from '@/spa/placeholder';
|
|
||||||
|
|
||||||
function GuestLayout(): ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-surface-bg px-4 py-8">
|
|
||||||
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const routes: RouteObject[] = [
|
|
||||||
{
|
|
||||||
element: <GuestGuard />,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
element: <GuestLayout />,
|
|
||||||
children: [
|
|
||||||
{ path: '/login', element: <LoginPage /> },
|
|
||||||
{ path: '/register', element: <RegisterPage /> },
|
|
||||||
{ path: '/auth/provider/:provider', element: <SsoCallbackPage /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
element: <AuthGuard />,
|
|
||||||
children: [
|
|
||||||
{ path: '/', element: <Navigate to="/chat" replace /> },
|
|
||||||
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
|
||||||
{ path: '/projects', element: <Placeholder title="Projects" /> },
|
|
||||||
{ path: '/projects/:id', element: <Placeholder title="Project" /> },
|
|
||||||
{ path: '/tasks', element: <Placeholder title="Tasks" /> },
|
|
||||||
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
|
||||||
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function createAppRouter(): ReturnType<typeof createBrowserRouter> {
|
|
||||||
return createBrowserRouter(routes);
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { CommandsPanel } from './commands-panel';
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
let root: Root | null;
|
|
||||||
let container: HTMLElement | null;
|
|
||||||
|
|
||||||
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
|
||||||
container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(node);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
document.body.replaceChildren();
|
|
||||||
root = null;
|
|
||||||
container = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CommandsPanel', () => {
|
|
||||||
it('shows the frozen local pendingApproval args in the confirmation area, regardless of misleading server message text', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[]}
|
|
||||||
approval={{
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'deploy', // matches pendingApproval — this is a legitimately approved request
|
|
||||||
success: true,
|
|
||||||
approvalId: 'ap1',
|
|
||||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
// Free-text server message claims a different, less alarming target
|
|
||||||
// than what will actually be sent — the UI must not rely on this.
|
|
||||||
message: 'This will only affect the staging environment.',
|
|
||||||
}}
|
|
||||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
|
||||||
hasConversation
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The exact frozen combined action is visible...
|
|
||||||
expect(container?.textContent).toContain('/deploy');
|
|
||||||
expect(container?.textContent).toContain('prod');
|
|
||||||
// ...and the misleading server free-text is never shown next to it.
|
|
||||||
expect(container?.textContent).not.toContain('staging environment');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not throw when a manifest commands entry is null', async () => {
|
|
||||||
const manifest = {
|
|
||||||
commands: [
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
name: 'model',
|
|
||||||
aliases: [],
|
|
||||||
description: 'Change the active model',
|
|
||||||
scope: 'core',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
skills: [null],
|
|
||||||
version: 1,
|
|
||||||
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={manifest}
|
|
||||||
results={[]}
|
|
||||||
approval={null}
|
|
||||||
pendingApproval={null}
|
|
||||||
hasConversation={false}
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
),
|
|
||||||
).resolves.not.toThrow();
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain('model');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows an explicit no-args fallback when the frozen pendingApproval has no args', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[]}
|
|
||||||
approval={{
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'deploy',
|
|
||||||
success: true,
|
|
||||||
approvalId: 'ap1',
|
|
||||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
}}
|
|
||||||
pendingApproval={{ command: 'deploy' }}
|
|
||||||
hasConversation
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container?.textContent?.toLowerCase()).toContain('no args');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders skills from a skills-only manifest', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={{
|
|
||||||
commands: [],
|
|
||||||
skills: [{ name: 'brave-search', description: 'Search the web', available: true }],
|
|
||||||
version: 1,
|
|
||||||
}}
|
|
||||||
results={[]}
|
|
||||||
approval={null}
|
|
||||||
pendingApproval={null}
|
|
||||||
hasConversation={false}
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain('brave-search');
|
|
||||||
expect(container?.textContent).toContain('Search the web');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not show the Run affordance when approval.success/approvalId are objects, even though command matches pendingApproval', async () => {
|
|
||||||
const approval = {
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'deploy',
|
|
||||||
success: { truthy: 'object' },
|
|
||||||
approvalId: { also: 'object' },
|
|
||||||
} as unknown as Parameters<typeof CommandsPanel>[0]['approval'];
|
|
||||||
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[]}
|
|
||||||
approval={approval}
|
|
||||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
|
||||||
hasConversation
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
[...(container?.querySelectorAll('button') ?? [])].some((button) =>
|
|
||||||
button.textContent?.includes('Run approved command'),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the guarded server-provided denial reason for a denied approval', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[]}
|
|
||||||
approval={{
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'deploy',
|
|
||||||
success: false,
|
|
||||||
message: 'Not authorized',
|
|
||||||
}}
|
|
||||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
|
||||||
hasConversation
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain('Not authorized');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to a stable "Denied." copy when a denial has no usable message', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[]}
|
|
||||||
approval={{ conversationId: 'c1', command: 'deploy', success: false }}
|
|
||||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
|
||||||
hasConversation
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain('Denied.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the guarded contract-provided reason for a failed command result, falling back to a stable copy only when absent', async () => {
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[
|
|
||||||
{ conversationId: 'c1', command: 'model', success: false, message: 'Unknown model' },
|
|
||||||
{ conversationId: 'c1', command: 'deploy', success: false },
|
|
||||||
]}
|
|
||||||
approval={null}
|
|
||||||
pendingApproval={null}
|
|
||||||
hasConversation={false}
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain('Unknown model');
|
|
||||||
expect(container?.textContent).toContain('Command failed.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bounds an oversized command result message at the render site as defense-in-depth', async () => {
|
|
||||||
const hostileMessage = 'y'.repeat(50_000);
|
|
||||||
await render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={null}
|
|
||||||
results={[
|
|
||||||
{ conversationId: 'c1', command: 'model', success: false, message: hostileMessage },
|
|
||||||
]}
|
|
||||||
approval={null}
|
|
||||||
pendingApproval={null}
|
|
||||||
hasConversation={false}
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const text = container?.textContent ?? '';
|
|
||||||
expect(text.length).toBeLessThan(hostileMessage.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not throw when the manifest fields are malformed (non-array commands/skills)', async () => {
|
|
||||||
const manifest = {
|
|
||||||
commands: 'not-an-array',
|
|
||||||
skills: null,
|
|
||||||
version: 1,
|
|
||||||
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
render(
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={manifest}
|
|
||||||
results={[]}
|
|
||||||
approval={null}
|
|
||||||
pendingApproval={null}
|
|
||||||
hasConversation={false}
|
|
||||||
onExecute={vi.fn()}
|
|
||||||
onApprove={vi.fn()}
|
|
||||||
onRunApproved={vi.fn()}
|
|
||||||
/>,
|
|
||||||
),
|
|
||||||
).resolves.not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import { useState, type ReactElement } from 'react';
|
|
||||||
import type { PendingApproval } from './use-chat-connection';
|
|
||||||
import { MAX_COMMAND_MESSAGE_CHARS } from './limits';
|
|
||||||
import { asNonEmptyString, asString } from './runtime-guards';
|
|
||||||
import type {
|
|
||||||
CommandManifest,
|
|
||||||
SlashCommandApprovalResultPayload,
|
|
||||||
SlashCommandResultPayload,
|
|
||||||
} from '@/lib/chat-contract';
|
|
||||||
|
|
||||||
/** Stable fallback copy shown for a failed command only when the server's
|
|
||||||
* own guarded, non-empty `message` (e.g. "Unknown model") is absent or
|
|
||||||
* malformed — the structured contract reason itself is otherwise shown
|
|
||||||
* directly, never a raw thrown exception, stack trace, or object value. */
|
|
||||||
const COMMAND_FAILURE_COPY = 'Command failed.';
|
|
||||||
|
|
||||||
/** Render-site defense-in-depth: `use-chat-connection.ts` already bounds a
|
|
||||||
* stored command:result message at ingestion, but this component must never
|
|
||||||
* assume every caller went through that path — bounding again here means a
|
|
||||||
* hostile/oversized message can never force an unbounded render. */
|
|
||||||
function boundMessage(value: string): string {
|
|
||||||
return value.length > MAX_COMMAND_MESSAGE_CHARS
|
|
||||||
? value.slice(0, MAX_COMMAND_MESSAGE_CHARS)
|
|
||||||
: value;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommandsPanelProps {
|
|
||||||
manifest: CommandManifest | null;
|
|
||||||
results: SlashCommandResultPayload[];
|
|
||||||
approval: SlashCommandApprovalResultPayload | null;
|
|
||||||
pendingApproval: PendingApproval | null;
|
|
||||||
hasConversation: boolean;
|
|
||||||
onExecute: (input: { command: string; args?: string }) => void;
|
|
||||||
onApprove: (input: { command: string; args?: string }) => void;
|
|
||||||
onRunApproved: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommandsPanel({
|
|
||||||
manifest,
|
|
||||||
results,
|
|
||||||
approval,
|
|
||||||
pendingApproval,
|
|
||||||
hasConversation,
|
|
||||||
onExecute,
|
|
||||||
onApprove,
|
|
||||||
onRunApproved,
|
|
||||||
}: CommandsPanelProps): ReactElement {
|
|
||||||
const [command, setCommand] = useState('');
|
|
||||||
const [args, setArgs] = useState('');
|
|
||||||
|
|
||||||
// Defense-in-depth: the reducer already normalizes success/approvalId
|
|
||||||
// before storing `approval`, but a matching command string alone must
|
|
||||||
// never be trusted here either — require the literal boolean `true` and a
|
|
||||||
// non-empty string approvalId, not merely truthy values.
|
|
||||||
const canRunApproved =
|
|
||||||
approval?.success === true &&
|
|
||||||
typeof approval.approvalId === 'string' &&
|
|
||||||
approval.approvalId.length > 0 &&
|
|
||||||
!!pendingApproval &&
|
|
||||||
pendingApproval.command === approval.command;
|
|
||||||
|
|
||||||
// A manifest arrives from the server as untyped JSON at runtime — guard
|
|
||||||
// both collections before mapping so a malformed manifest cannot throw.
|
|
||||||
const commands = Array.isArray(manifest?.commands) ? manifest.commands : [];
|
|
||||||
const skills = Array.isArray(manifest?.skills) ? manifest.skills : [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section aria-label="Commands" className="flex flex-col gap-2 border-b px-4 py-3 text-xs">
|
|
||||||
{commands.length > 0 ? (
|
|
||||||
<ul aria-label="Available commands" className="flex flex-col gap-1">
|
|
||||||
{commands.map((cmd, index) => (
|
|
||||||
<li key={asString(cmd?.name) || `cmd-${index}`}>
|
|
||||||
<strong>/{asString(cmd?.name)}</strong> — {asString(cmd?.description)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{skills.length > 0 ? (
|
|
||||||
<ul aria-label="Available skills" className="flex flex-col gap-1">
|
|
||||||
{skills.map((skill, index) => (
|
|
||||||
<li key={asString(skill?.name) || `skill-${index}`}>
|
|
||||||
<strong>/skill:{asString(skill?.name)}</strong> — {asString(skill?.description)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<input
|
|
||||||
aria-label="Command name"
|
|
||||||
value={command}
|
|
||||||
onChange={(event) => setCommand(event.target.value)}
|
|
||||||
placeholder="command"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
aria-label="Command arguments"
|
|
||||||
value={args}
|
|
||||||
onChange={(event) => setArgs(event.target.value)}
|
|
||||||
placeholder="args (optional)"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!hasConversation || !command.trim()}
|
|
||||||
onClick={() => onExecute({ command: command.trim(), args: args.trim() || undefined })}
|
|
||||||
>
|
|
||||||
Run command
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!hasConversation || !command.trim()}
|
|
||||||
onClick={() => onApprove({ command: command.trim(), args: args.trim() || undefined })}
|
|
||||||
>
|
|
||||||
Request approval
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{approval ? (
|
|
||||||
<div role={approval.success ? 'status' : 'alert'} className="flex items-center gap-2">
|
|
||||||
{/* A successful approval shows stable client copy only — never
|
|
||||||
the server-controlled approval.message or echoed
|
|
||||||
approval.command as the primary confirmation. The frozen local
|
|
||||||
pendingApproval below (not this line) is the sole authoritative
|
|
||||||
statement of what will run. A denial, by contrast, is not an
|
|
||||||
execution authority and safely surfaces the guarded structured
|
|
||||||
reason the server gave (e.g. "Not authorized"), falling back to
|
|
||||||
a stable copy only when absent/malformed. */}
|
|
||||||
<span>
|
|
||||||
{approval.success ? 'Approved.' : asNonEmptyString(approval.message, 'Denied.')}
|
|
||||||
</span>
|
|
||||||
{canRunApproved && pendingApproval ? (
|
|
||||||
<>
|
|
||||||
{/* Authoritative frozen local command+args — what the click below
|
|
||||||
will actually emit. The server's `approval` above is display-only
|
|
||||||
and must never be trusted to represent the executed payload. */}
|
|
||||||
<span>
|
|
||||||
Will run: /{pendingApproval.command}{' '}
|
|
||||||
{pendingApproval.args ? pendingApproval.args : '(no args)'}
|
|
||||||
</span>
|
|
||||||
<button type="button" onClick={onRunApproved}>
|
|
||||||
Run approved command
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{results.length > 0 ? (
|
|
||||||
<ul aria-label="Command results" className="flex flex-col gap-1">
|
|
||||||
{results.map((result, index) => (
|
|
||||||
<li key={`${result.command}-${index}`} role={result.success ? 'status' : 'alert'}>
|
|
||||||
/{asString(result.command)}: {result.success ? 'success' : 'failed'}
|
|
||||||
{result.success
|
|
||||||
? typeof result.message === 'string' && result.message
|
|
||||||
? ` — ${boundMessage(result.message)}`
|
|
||||||
: ''
|
|
||||||
: ` — ${boundMessage(asNonEmptyString(result.message, COMMAND_FAILURE_COPY))}`}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
|
||||||
|
|
||||||
interface ComposerProps {
|
|
||||||
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
|
|
||||||
onStop: () => void;
|
|
||||||
streaming: boolean;
|
|
||||||
/** True from local send time through server turn startup/ack and
|
|
||||||
* throughout streaming — a superset of `streaming` that also covers the
|
|
||||||
* pre-ack window where a second send could otherwise slip through. */
|
|
||||||
sending: boolean;
|
|
||||||
hasConversation: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Composer({
|
|
||||||
onSend,
|
|
||||||
onStop,
|
|
||||||
streaming,
|
|
||||||
sending,
|
|
||||||
hasConversation,
|
|
||||||
}: ComposerProps): ReactElement {
|
|
||||||
const [content, setContent] = useState('');
|
|
||||||
const [provider, setProvider] = useState('');
|
|
||||||
const [modelId, setModelId] = useState('');
|
|
||||||
const busy = streaming || sending;
|
|
||||||
|
|
||||||
function submit(): void {
|
|
||||||
if (busy) return;
|
|
||||||
const trimmed = content.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
onSend({
|
|
||||||
content: trimmed,
|
|
||||||
provider: provider.trim() || undefined,
|
|
||||||
modelId: modelId.trim() || undefined,
|
|
||||||
});
|
|
||||||
setContent('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
|
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
submit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
submit();
|
|
||||||
}}
|
|
||||||
className="flex flex-col gap-2 border-t p-4"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<input
|
|
||||||
aria-label="Provider"
|
|
||||||
value={provider}
|
|
||||||
onChange={(event) => setProvider(event.target.value)}
|
|
||||||
placeholder="Provider (optional)"
|
|
||||||
className="rounded border px-2 py-1 text-xs"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
aria-label="Model"
|
|
||||||
value={modelId}
|
|
||||||
onChange={(event) => setModelId(event.target.value)}
|
|
||||||
placeholder="Model (optional)"
|
|
||||||
className="rounded border px-2 py-1 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<textarea
|
|
||||||
aria-label="Message"
|
|
||||||
value={content}
|
|
||||||
onChange={(event) => setContent(event.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
rows={2}
|
|
||||||
placeholder="Message… (Enter to send, Shift+Enter for a new line)"
|
|
||||||
className="flex-1 resize-none rounded border px-3 py-2 text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!content.trim() || busy}
|
|
||||||
className="rounded px-3 py-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Stop"
|
|
||||||
disabled={!hasConversation || !streaming}
|
|
||||||
onClick={onStop}
|
|
||||||
className="rounded px-3 py-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Bounds on server-fed chat state. A hostile or malfunctioning gateway can
|
|
||||||
* flood any of these collections; caps keep memory/render cost flat instead
|
|
||||||
* of growing unboundedly for the lifetime of the connection.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Max characters retained for the in-flight streamed text/thinking buffers. */
|
|
||||||
export const MAX_STREAM_CHARS = 20_000;
|
|
||||||
/** Max transcript turns retained (oldest dropped first). */
|
|
||||||
export const MAX_MESSAGES = 500;
|
|
||||||
/** Max tool-call entries (including anomaly entries) retained per turn history. */
|
|
||||||
export const MAX_TOOLS = 200;
|
|
||||||
/** Max slash-command results retained. */
|
|
||||||
export const MAX_COMMAND_RESULTS = 200;
|
|
||||||
/** Max commands/skills accepted from a single manifest push. */
|
|
||||||
export const MAX_MANIFEST_ITEMS = 500;
|
|
||||||
/** Max executed approval IDs remembered for single-flight dedup. */
|
|
||||||
export const MAX_EXECUTED_APPROVAL_IDS = 200;
|
|
||||||
/** Max characters retained for a single command:result message — a hostile
|
|
||||||
* or malfunctioning gateway must not be able to push an unbounded curated
|
|
||||||
* success/failure reason into state (or, defensively, onto the page). */
|
|
||||||
export const MAX_COMMAND_MESSAGE_CHARS = 1_000;
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import type { ChatTranscriptMessage } from './use-chat-connection';
|
|
||||||
|
|
||||||
interface MessageTranscriptProps {
|
|
||||||
messages: ChatTranscriptMessage[];
|
|
||||||
streaming: boolean;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MessageTranscript({
|
|
||||||
messages,
|
|
||||||
streaming,
|
|
||||||
text,
|
|
||||||
}: MessageTranscriptProps): ReactElement {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="log"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-label="Conversation"
|
|
||||||
className="flex flex-1 flex-col gap-3 overflow-y-auto p-4"
|
|
||||||
>
|
|
||||||
{messages.map((message) => (
|
|
||||||
<div key={message.id} data-role={message.role} className="whitespace-pre-wrap text-sm">
|
|
||||||
<span className="font-medium">{message.role === 'user' ? 'You' : 'Assistant'}: </span>
|
|
||||||
<span>{message.text}</span>
|
|
||||||
{message.thinking ? (
|
|
||||||
<div className="pt-1 text-xs italic opacity-70">{message.thinking}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{streaming ? (
|
|
||||||
<div data-role="assistant-streaming" className="whitespace-pre-wrap text-sm">
|
|
||||||
<span className="font-medium">Assistant: </span>
|
|
||||||
<span>{text || 'Thinking…'}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
/**
|
|
||||||
* Socket.IO payloads are only statically typed at the call site — a
|
|
||||||
* misbehaving or compromised gateway can send anything at runtime. These
|
|
||||||
* guards protect the dereference sites that would otherwise throw (`.map` on
|
|
||||||
* a non-array, `.toFixed` on a non-number) or render an object as a React
|
|
||||||
* child.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function asString(value: unknown, fallback = ''): string {
|
|
||||||
return typeof value === 'string' ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Like `asString`, but an empty string also falls back — used for guarded
|
|
||||||
* contract-provided reason strings (e.g. a denial or failure message) where
|
|
||||||
* an empty string is not a meaningful value to display in place of the
|
|
||||||
* stable fallback copy. */
|
|
||||||
export function asNonEmptyString(value: unknown, fallback: string): string {
|
|
||||||
return typeof value === 'string' && value.length > 0 ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function asFiniteNumber(value: unknown, fallback = 0): number {
|
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Like `asFiniteNumber`, but returns `null` on failure instead of a numeric
|
|
||||||
* fallback — callers that must not fabricate a plausible-looking value (e.g.
|
|
||||||
* `0 tokens` / `$0.0000` for genuinely unknown usage) use this to render an
|
|
||||||
* honest "unavailable" label instead. */
|
|
||||||
export function asFiniteNumberOrNull(value: unknown): number | null {
|
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function asStringArray(value: unknown): string[] {
|
|
||||||
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The single point of truth for what counts as a valid conversation ID
|
|
||||||
* anywhere a scoped server event may adopt one into state — a non-empty
|
|
||||||
* string, nothing else. Every site that establishes or compares
|
|
||||||
* `state.conversationId` against a raw socket payload must route through
|
|
||||||
* this guard so a malformed first frame (null/object/number/empty string)
|
|
||||||
* can never be adopted verbatim. */
|
|
||||||
export function asConversationId(value: unknown): string | null {
|
|
||||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import type { SessionInfoPayload } from '@/lib/chat-contract';
|
|
||||||
import { MAX_MANIFEST_ITEMS } from './limits';
|
|
||||||
import { asString, asStringArray } from './runtime-guards';
|
|
||||||
|
|
||||||
interface SessionPanelProps {
|
|
||||||
sessionInfo: SessionInfoPayload | null;
|
|
||||||
onSetThinking: (level: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const THINKING_LEVEL_UNAVAILABLE = '';
|
|
||||||
|
|
||||||
export function SessionPanel({
|
|
||||||
sessionInfo,
|
|
||||||
onSetThinking,
|
|
||||||
}: SessionPanelProps): ReactElement | null {
|
|
||||||
if (!sessionInfo) return null;
|
|
||||||
|
|
||||||
// The reducer already caps this before storing it, but the render site
|
|
||||||
// defends independently — a hostile payload must never be able to force
|
|
||||||
// this <select> to lay out an unbounded number of options.
|
|
||||||
const availableThinkingLevels = asStringArray(sessionInfo.availableThinkingLevels).slice(
|
|
||||||
0,
|
|
||||||
MAX_MANIFEST_ITEMS,
|
|
||||||
);
|
|
||||||
const hasThinkingLevels = availableThinkingLevels.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
aria-label="Session info"
|
|
||||||
className="flex flex-wrap items-center gap-3 border-b px-4 py-2 text-xs"
|
|
||||||
>
|
|
||||||
<span>{asString(sessionInfo.provider, 'unknown')}</span>
|
|
||||||
<span>{asString(sessionInfo.modelId, 'unknown')}</span>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<span>Thinking level</span>
|
|
||||||
<select
|
|
||||||
aria-label="Thinking level"
|
|
||||||
value={
|
|
||||||
hasThinkingLevels ? asString(sessionInfo.thinkingLevel) : THINKING_LEVEL_UNAVAILABLE
|
|
||||||
}
|
|
||||||
onChange={(event) => {
|
|
||||||
// The placeholder option is not a real, settable level — a
|
|
||||||
// malformed availableThinkingLevels list must never let the
|
|
||||||
// client emit set:thinking for it.
|
|
||||||
if (!hasThinkingLevels) return;
|
|
||||||
onSetThinking(event.target.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hasThinkingLevels ? (
|
|
||||||
availableThinkingLevels.map((level) => (
|
|
||||||
<option key={level} value={level}>
|
|
||||||
{level}
|
|
||||||
</option>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<option value={THINKING_LEVEL_UNAVAILABLE}>Thinking level unavailable</option>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{sessionInfo.routingDecision ? (
|
|
||||||
<span title={asString(sessionInfo.routingDecision.ruleName)}>
|
|
||||||
{asString(sessionInfo.routingDecision.reason)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { vi } from 'vitest';
|
|
||||||
import type { ClientToServerEvents, ServerToClientEvents } from '@/lib/chat-contract';
|
|
||||||
|
|
||||||
type ServerEvent = keyof ServerToClientEvents;
|
|
||||||
type ClientEvent = keyof ClientToServerEvents;
|
|
||||||
type ServerHandler<K extends ServerEvent> = ServerToClientEvents[K];
|
|
||||||
type ClientPayload<K extends ClientEvent> = Parameters<ClientToServerEvents[K]>[0];
|
|
||||||
|
|
||||||
export interface EmittedEvent<K extends ClientEvent = ClientEvent> {
|
|
||||||
event: K;
|
|
||||||
payload: ClientPayload<K>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */
|
|
||||||
export interface FakeChatSocket {
|
|
||||||
connected: boolean;
|
|
||||||
connect(): FakeChatSocket;
|
|
||||||
on<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
|
||||||
off<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
|
||||||
emit<K extends ClientEvent>(event: K, payload: ClientPayload<K>): FakeChatSocket;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A typed in-memory stand-in for `getSocket()`. Unlike a bare
|
|
||||||
* `(event: string, payload: unknown) => void` mock, every public method here is
|
|
||||||
* checked against the real `/chat` contract — a typo'd event name or a payload
|
|
||||||
* missing a required field fails to compile instead of silently no-op'ing at
|
|
||||||
* runtime.
|
|
||||||
*/
|
|
||||||
/** Socket.IO's built-in connection-state events. Not part of the app-level
|
|
||||||
* ServerToClientEvents contract, but real sockets always support them and
|
|
||||||
* `useChatConnection` registers a `disconnect` handler on the real socket. */
|
|
||||||
type LifecycleEvent = 'connect' | 'disconnect';
|
|
||||||
|
|
||||||
export function createFakeChatSocket(): {
|
|
||||||
socket: FakeChatSocket;
|
|
||||||
listeners: Map<ServerEvent, Set<(payload: never) => void>>;
|
|
||||||
emitted: EmittedEvent[];
|
|
||||||
serverEmit<K extends ServerEvent>(
|
|
||||||
event: K,
|
|
||||||
payload: Parameters<ServerToClientEvents[K]>[0],
|
|
||||||
): void;
|
|
||||||
/** Escape hatch for malformed-payload tests: bypasses the compile-time
|
|
||||||
* payload contract to simulate a genuinely untrusted runtime value from the
|
|
||||||
* server, e.g. a `session:info` with a non-array `availableThinkingLevels`. */
|
|
||||||
serverEmitRaw(event: ServerEvent, payload: unknown): void;
|
|
||||||
/** Simulates a transient Socket.IO `disconnect` — fires any handler(s)
|
|
||||||
* registered via `socket.on('disconnect', ...)` without clearing any
|
|
||||||
* listeners, mirroring how a real reconnecting socket behaves. */
|
|
||||||
simulateDisconnect(): void;
|
|
||||||
/** Simulates socket.io-client's automatic reconnect of the *same*
|
|
||||||
* instance after a transient disconnect: marks the socket connected again
|
|
||||||
* and fires any handler(s) registered via `socket.on('connect', ...)`,
|
|
||||||
* without clearing or replacing any listeners. */
|
|
||||||
simulateReconnect(): void;
|
|
||||||
} {
|
|
||||||
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
|
|
||||||
const emitted: EmittedEvent[] = [];
|
|
||||||
|
|
||||||
// Internal storage is intentionally keyed loosely (the per-event handler shape
|
|
||||||
// varies by K, which a single Map can't express); the generic signatures on the
|
|
||||||
// exported `socket`/`serverEmit` above and below are what keep test call sites
|
|
||||||
// type-checked against ServerToClientEvents/ClientToServerEvents.
|
|
||||||
const socket = {
|
|
||||||
connected: false,
|
|
||||||
connect: vi.fn(function connect(this: void) {
|
|
||||||
socket.connected = true;
|
|
||||||
return socket;
|
|
||||||
}),
|
|
||||||
on: vi.fn(function on(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
|
||||||
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
||||||
listeners.get(event)?.add(handler);
|
|
||||||
return socket;
|
|
||||||
}),
|
|
||||||
off: vi.fn(function off(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
|
||||||
listeners.get(event)?.delete(handler);
|
|
||||||
return socket;
|
|
||||||
}),
|
|
||||||
emit: vi.fn(function emit(this: void, event: ClientEvent, payload: unknown) {
|
|
||||||
emitted.push({ event, payload } as EmittedEvent);
|
|
||||||
return socket;
|
|
||||||
}),
|
|
||||||
} as unknown as FakeChatSocket;
|
|
||||||
|
|
||||||
function serverEmit<K extends ServerEvent>(
|
|
||||||
event: K,
|
|
||||||
payload: Parameters<ServerToClientEvents[K]>[0],
|
|
||||||
): void {
|
|
||||||
for (const handler of listeners.get(event) ?? []) {
|
|
||||||
(handler as (payload: Parameters<ServerToClientEvents[K]>[0]) => void)(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function serverEmitRaw(event: ServerEvent, payload: unknown): void {
|
|
||||||
for (const handler of listeners.get(event) ?? []) {
|
|
||||||
(handler as (payload: unknown) => void)(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function simulateDisconnect(): void {
|
|
||||||
socket.connected = false;
|
|
||||||
const lifecycleKey = 'disconnect' satisfies LifecycleEvent as unknown as ServerEvent;
|
|
||||||
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
|
||||||
(handler as () => void)();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function simulateReconnect(): void {
|
|
||||||
socket.connected = true;
|
|
||||||
const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent;
|
|
||||||
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
|
||||||
(handler as () => void)();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
socket,
|
|
||||||
listeners,
|
|
||||||
emitted,
|
|
||||||
serverEmit,
|
|
||||||
serverEmitRaw,
|
|
||||||
simulateDisconnect,
|
|
||||||
simulateReconnect,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { ToolCallList } from './tool-call-list';
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
let root: Root | null;
|
|
||||||
let container: HTMLElement | null;
|
|
||||||
|
|
||||||
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
|
||||||
container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(node);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
document.body.replaceChildren();
|
|
||||||
root = null;
|
|
||||||
container = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ToolCallList', () => {
|
|
||||||
it('renders two entries independently, without a duplicate-key warning, when a valid toolCallId is shared', async () => {
|
|
||||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
|
|
||||||
await render(
|
|
||||||
<ToolCallList
|
|
||||||
tools={[
|
|
||||||
{ toolCallId: 'dup', toolName: 'search', status: 'success' },
|
|
||||||
{ toolCallId: 'dup', toolName: 'search', status: 'running' },
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const items = [...(container?.querySelectorAll('li') ?? [])];
|
|
||||||
expect(items).toHaveLength(2);
|
|
||||||
expect(items[0]?.textContent).toContain('success');
|
|
||||||
expect(items[1]?.textContent).toContain('running');
|
|
||||||
|
|
||||||
const duplicateKeyWarning = consoleError.mock.calls.some((args) =>
|
|
||||||
args.some((arg) => typeof arg === 'string' && arg.includes('same key')),
|
|
||||||
);
|
|
||||||
expect(duplicateKeyWarning).toBe(false);
|
|
||||||
|
|
||||||
consoleError.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import type { ToolCallState } from './use-chat-connection';
|
|
||||||
|
|
||||||
export function ToolCallList({ tools }: { tools: ToolCallState[] }): ReactElement | null {
|
|
||||||
if (tools.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul aria-label="Tool calls" className="flex flex-col gap-1 px-4 pb-2 text-xs">
|
|
||||||
{tools.map((tool, index) => (
|
|
||||||
<li
|
|
||||||
// A valid server-controlled toolCallId can legitimately repeat
|
|
||||||
// (e.g. two tool:start events sharing one id) — keying on it alone
|
|
||||||
// would give React two identical keys. Pairing it with its
|
|
||||||
// (stable, append-only) render index keeps every key unique.
|
|
||||||
key={`${tool.toolCallId}-${index}`}
|
|
||||||
role={tool.status === 'error' || tool.status === 'anomaly' ? 'alert' : 'status'}
|
|
||||||
>
|
|
||||||
{tool.toolName} —{' '}
|
|
||||||
{tool.status === 'anomaly' ? 'unexpected end (unknown tool call)' : tool.status}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,135 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const { useSessionMock } = vi.hoisted(() => ({
|
|
||||||
useSessionMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth-client', () => ({
|
|
||||||
useSession: useSessionMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { AuthGuard, GuestGuard } from './guards';
|
|
||||||
|
|
||||||
interface RenderedRouter {
|
|
||||||
container: HTMLDivElement;
|
|
||||||
router: ReturnType<typeof createMemoryRouter>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mountedRoots: Root[] = [];
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
async function renderRouter(
|
|
||||||
routeObjects: RouteObject[],
|
|
||||||
initialEntry: string,
|
|
||||||
): Promise<RenderedRouter> {
|
|
||||||
const container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
const router = createMemoryRouter(routeObjects, { initialEntries: [initialEntry] });
|
|
||||||
const root = createRoot(container);
|
|
||||||
mountedRoots.push(root);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(<RouterProvider router={router} />);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { container, router };
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
for (const root of mountedRoots.splice(0)) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
document.body.replaceChildren();
|
|
||||||
useSessionMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
const guestRoutes: RouteObject[] = [
|
|
||||||
{
|
|
||||||
path: '/login',
|
|
||||||
element: <GuestGuard />,
|
|
||||||
children: [{ index: true, element: <p>Guest page</p> }],
|
|
||||||
},
|
|
||||||
{ path: '/chat', element: <p>Chat page</p> },
|
|
||||||
];
|
|
||||||
|
|
||||||
const authenticatedRoutes: RouteObject[] = [
|
|
||||||
{
|
|
||||||
path: '/chat',
|
|
||||||
element: <AuthGuard />,
|
|
||||||
children: [{ index: true, element: <p>Private page</p> }],
|
|
||||||
},
|
|
||||||
{ path: '/login', element: <p>Login page</p> },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('GuestGuard', () => {
|
|
||||||
it('renders the guest outlet while session lookup is pending', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
|
||||||
|
|
||||||
const view = await renderRouter(guestRoutes, '/login');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Guest page');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/login');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the guest outlet when no session exists', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
|
||||||
|
|
||||||
const view = await renderRouter(guestRoutes, '/login');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Guest page');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/login');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redirects an authenticated session to chat', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
|
||||||
|
|
||||||
const view = await renderRouter(guestRoutes, '/login');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Chat page');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/chat');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AuthGuard', () => {
|
|
||||||
it('renders the existing loading treatment while session lookup is pending', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
|
||||||
|
|
||||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Loading...');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/chat');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redirects an unauthenticated visitor to login', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
|
||||||
|
|
||||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Login page');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/login');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the authenticated outlet when a session exists', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
|
||||||
|
|
||||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
|
||||||
|
|
||||||
expect(view.container.textContent).toContain('Private page');
|
|
||||||
expect(view.router.state.location.pathname).toBe('/chat');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import { Navigate, Outlet } from 'react-router-dom';
|
|
||||||
import { useSession } from '@/lib/auth-client';
|
|
||||||
|
|
||||||
export function GuestGuard(): ReactElement {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
|
|
||||||
return session ? <Navigate to="/chat" replace /> : <Outlet />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthGuard(): ReactElement {
|
|
||||||
const { data: session, isPending } = useSession();
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="text-sm text-text-muted">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const { useSessionMock } = vi.hoisted(() => ({
|
|
||||||
useSessionMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth-client', () => ({
|
|
||||||
useSession: useSessionMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { routes } from '@/routes';
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
function Boom(): never {
|
|
||||||
throw new Error('render blew up');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recursively clones the real exported route table, replacing only the
|
|
||||||
* `/chat` route's `element` with `<Boom />` — every other route (including
|
|
||||||
* the real `AuthGuard` nesting and the real `/chat` `errorElement`) is left
|
|
||||||
* exactly as exported. This is what makes the test fail if a future change
|
|
||||||
* removes the real route's `errorElement`, unlike a hand-built independent
|
|
||||||
* route tree that could drift from production undetected. */
|
|
||||||
function replaceChatElementWithBoom(nodes: RouteObject[]): RouteObject[] {
|
|
||||||
return nodes.map((node) => {
|
|
||||||
const cloned: RouteObject = { ...node };
|
|
||||||
if (cloned.path === '/chat') {
|
|
||||||
cloned.element = <Boom />;
|
|
||||||
}
|
|
||||||
if (cloned.children) {
|
|
||||||
cloned.children = replaceChatElementWithBoom(cloned.children);
|
|
||||||
}
|
|
||||||
return cloned;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let root: Root | null;
|
|
||||||
let container: HTMLElement;
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
document.body.replaceChildren();
|
|
||||||
root = null;
|
|
||||||
useSessionMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ChatRouteErrorBoundary', () => {
|
|
||||||
it('renders a recoverable, non-blank fallback when the /chat route element throws during render', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
|
||||||
|
|
||||||
const routeObjects = replaceChatElementWithBoom(routes);
|
|
||||||
const router = createMemoryRouter(routeObjects, { initialEntries: ['/chat'] });
|
|
||||||
|
|
||||||
container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
try {
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(<RouterProvider router={router} />);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(container.textContent).not.toBe('');
|
|
||||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import { useRouteError } from 'react-router-dom';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `/chat` renders live, server-driven state (streamed text, tool calls,
|
|
||||||
* manifests) that can carry malformed payloads no compile-time contract can
|
|
||||||
* fully rule out at every dereference site. This is the last line of
|
|
||||||
* defense: if something still throws during render, show a recoverable
|
|
||||||
* alert instead of leaving the user on a blank/white screen.
|
|
||||||
*/
|
|
||||||
export function ChatRouteErrorBoundary(): ReactElement {
|
|
||||||
useRouteError();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div role="alert" className="flex min-h-screen flex-col items-center justify-center gap-3 p-8">
|
|
||||||
<p className="text-sm font-medium">Something went wrong loading chat.</p>
|
|
||||||
<a href="/chat" className="text-sm underline">
|
|
||||||
Reload chat
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,636 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { createFakeChatSocket } from '@/spa/chat/test-support/fake-chat-socket';
|
|
||||||
import { MAX_MANIFEST_ITEMS } from '@/spa/chat/limits';
|
|
||||||
|
|
||||||
const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({
|
|
||||||
getSocketMock: vi.fn(),
|
|
||||||
destroySocketMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/socket', () => ({
|
|
||||||
getSocket: getSocketMock,
|
|
||||||
destroySocket: destroySocketMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { ChatPage } from './chat';
|
|
||||||
|
|
||||||
function setValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): void {
|
|
||||||
const proto =
|
|
||||||
el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
||||||
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
||||||
setter?.call(el, value);
|
|
||||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectValue(el: HTMLSelectElement, value: string): void {
|
|
||||||
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
|
|
||||||
setter?.call(el, value);
|
|
||||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
|
|
||||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
|
||||||
candidate.textContent?.includes(text),
|
|
||||||
);
|
|
||||||
if (!button) throw new Error(`Button with text "${text}" not found`);
|
|
||||||
return button;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fake: ReturnType<typeof createFakeChatSocket>;
|
|
||||||
let root: Root | null;
|
|
||||||
let container: HTMLElement;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
fake = createFakeChatSocket();
|
|
||||||
getSocketMock.mockReset().mockReturnValue(fake.socket);
|
|
||||||
destroySocketMock.mockReset();
|
|
||||||
container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(<ChatPage />);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
document.body.replaceChildren();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ChatPage', () => {
|
|
||||||
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'pondering…' });
|
|
||||||
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hel' });
|
|
||||||
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'lo!' });
|
|
||||||
fake.serverEmit('agent:tool:start', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
toolCallId: 't1',
|
|
||||||
toolName: 'web_search',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('pondering…');
|
|
||||||
expect(container.textContent).toContain('Hello!');
|
|
||||||
expect(container.textContent).toContain('web_search');
|
|
||||||
expect(container.textContent).toMatch(/running/i);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('agent:tool:end', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
toolCallId: 't1',
|
|
||||||
toolName: 'web_search',
|
|
||||||
isError: false,
|
|
||||||
});
|
|
||||||
fake.serverEmit('agent:end', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
usage: {
|
|
||||||
provider: 'anthropic',
|
|
||||||
modelId: 'claude',
|
|
||||||
thinkingLevel: 'medium',
|
|
||||||
tokens: { input: 12, output: 34, cacheRead: 0, cacheWrite: 0, total: 46 },
|
|
||||||
cost: 0.02,
|
|
||||||
context: { percent: 3, window: 200000 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.textContent).toMatch(/success/i);
|
|
||||||
expect(container.textContent).toContain('Hello!');
|
|
||||||
expect(container.textContent).toMatch(/46/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the commands manifest and session info, and lets the user pick a thinking level', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmit('commands:manifest', {
|
|
||||||
manifest: {
|
|
||||||
commands: [
|
|
||||||
{
|
|
||||||
name: 'model',
|
|
||||||
aliases: ['m'],
|
|
||||||
description: 'Change the active model',
|
|
||||||
scope: 'core',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
skills: [],
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
fake.serverEmit('session:info', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
provider: 'anthropic',
|
|
||||||
modelId: 'claude',
|
|
||||||
thinkingLevel: 'medium',
|
|
||||||
availableThinkingLevels: ['low', 'medium', 'high'],
|
|
||||||
routingDecision: {
|
|
||||||
model: 'claude',
|
|
||||||
provider: 'anthropic',
|
|
||||||
ruleName: 'default',
|
|
||||||
reason: 'default routing',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('model');
|
|
||||||
expect(container.textContent).toContain('Change the active model');
|
|
||||||
expect(container.textContent).toContain('anthropic');
|
|
||||||
expect(container.textContent).toContain('default routing');
|
|
||||||
|
|
||||||
const select = container.querySelector(
|
|
||||||
'select[aria-label="Thinking level"]',
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
expect(select).toBeTruthy();
|
|
||||||
expect([...select.options].map((o) => o.value)).toEqual(['low', 'medium', 'high']);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
selectValue(select, 'high');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted).toContainEqual({
|
|
||||||
event: 'set:thinking',
|
|
||||||
payload: { conversationId: 'c1', level: 'high' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('executes and approves commands with exact payloads and surfaces the approval affordance', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
const commandInput = container.querySelector(
|
|
||||||
'input[aria-label="Command name"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const argsInput = container.querySelector(
|
|
||||||
'input[aria-label="Command arguments"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
setValue(commandInput, 'model');
|
|
||||||
setValue(argsInput, 'gpt-5');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
findButton(container, 'Run command').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted).toContainEqual({
|
|
||||||
event: 'command:execute',
|
|
||||||
payload: { conversationId: 'c1', command: 'model', args: 'gpt-5' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
setValue(commandInput, 'deploy');
|
|
||||||
setValue(argsInput, 'prod');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
findButton(container, 'Request approval').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted).toContainEqual({
|
|
||||||
event: 'command:approve',
|
|
||||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('command:approval', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'deploy',
|
|
||||||
success: true,
|
|
||||||
approvalId: 'ap1',
|
|
||||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.textContent).toMatch(/approved/i);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
findButton(container, 'Run approved command').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted).toContainEqual({
|
|
||||||
event: 'command:execute',
|
|
||||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows visible alert surfaces for a server error and the structured contract reason for a failed command result', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmit('error', { conversationId: 'c1', error: 'The model is unavailable' });
|
|
||||||
fake.serverEmit('command:result', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'model',
|
|
||||||
success: false,
|
|
||||||
message: 'Unknown model',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
|
||||||
const alertText = alerts.map((node) => node.textContent).join(' ');
|
|
||||||
expect(alertText).toContain('The model is unavailable');
|
|
||||||
// The structured, contract-provided denial reason is visibly rendered.
|
|
||||||
expect(alertText).toContain('Unknown model');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to a stable "Command failed." copy when a failed command result has no usable message', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmitRaw('command:result', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'model',
|
|
||||||
success: false,
|
|
||||||
message: { bad: 'object' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
|
||||||
const alertText = alerts.map((node) => node.textContent).join(' ');
|
|
||||||
expect(alertText).toContain('Command failed.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('caps availableThinkingLevels before storing and rendering a hostile session payload', async () => {
|
|
||||||
const hostileLevels = Array.from({ length: MAX_MANIFEST_ITEMS + 50 }, (_, i) => `level-${i}`);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmit('session:info', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
provider: 'anthropic',
|
|
||||||
modelId: 'claude',
|
|
||||||
thinkingLevel: 'level-0',
|
|
||||||
availableThinkingLevels: hostileLevels,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const select = container.querySelector(
|
|
||||||
'select[aria-label="Thinking level"]',
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
expect(select).toBeTruthy();
|
|
||||||
expect(select.options.length).toBeLessThanOrEqual(MAX_MANIFEST_ITEMS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a safe fallback when session:info arrives with a malformed (non-array) availableThinkingLevels, without throwing', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmitRaw('session:info', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
provider: 'anthropic',
|
|
||||||
modelId: 'claude',
|
|
||||||
thinkingLevel: 'medium',
|
|
||||||
availableThinkingLevels: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
|
||||||
const select = container.querySelector(
|
|
||||||
'select[aria-label="Thinking level"]',
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
expect(select).toBeTruthy();
|
|
||||||
// A malformed level list still shows a visible, safe placeholder option
|
|
||||||
// rather than a silently empty select.
|
|
||||||
expect([...select.options]).toHaveLength(1);
|
|
||||||
expect(select.options[0]?.textContent).toMatch(/unavailable/i);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
selectValue(select, '');
|
|
||||||
});
|
|
||||||
expect(fake.emitted.filter((e) => e.event === 'set:thinking')).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders honest unavailable labels — not fabricated zeros — when agent:end usage has malformed/missing numeric fields', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
||||||
fake.serverEmitRaw('agent:end', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
usage: {
|
|
||||||
provider: { nested: 'object' },
|
|
||||||
modelId: undefined,
|
|
||||||
thinkingLevel: 'medium',
|
|
||||||
tokens: { total: 'not-a-number' },
|
|
||||||
cost: undefined,
|
|
||||||
context: { percent: null, window: 200000 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const usage = container.querySelector('[aria-label="Usage"]');
|
|
||||||
expect(usage).toBeTruthy();
|
|
||||||
expect(usage?.textContent).toContain('tokens unavailable');
|
|
||||||
expect(usage?.textContent).toContain('cost unavailable');
|
|
||||||
expect(usage?.textContent).not.toContain('0 tokens');
|
|
||||||
expect(usage?.textContent).not.toContain('$0.0000');
|
|
||||||
expect(usage?.textContent).toContain('unknown/unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a safe fallback for message:ack when messageId is a malformed non-string value, without throwing', async () => {
|
|
||||||
await expect(
|
|
||||||
act(async () => {
|
|
||||||
fake.serverEmitRaw('message:ack', { conversationId: 'c1', messageId: { bad: 'object' } });
|
|
||||||
}),
|
|
||||||
).resolves.not.toThrow();
|
|
||||||
|
|
||||||
const status = [...container.querySelectorAll('[role="status"]')].find((node) =>
|
|
||||||
node.textContent?.includes('Message accepted'),
|
|
||||||
);
|
|
||||||
expect(status).toBeTruthy();
|
|
||||||
// A malformed messageId gets a stable, visible fallback — never blank,
|
|
||||||
// never the raw object.
|
|
||||||
expect(status?.textContent).toContain('unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders safely and does not throw when system:reload.message is a malformed non-string value', async () => {
|
|
||||||
await expect(
|
|
||||||
act(async () => {
|
|
||||||
fake.serverEmitRaw('system:reload', {
|
|
||||||
commands: [],
|
|
||||||
skills: [],
|
|
||||||
providers: [],
|
|
||||||
message: { bad: 'object' },
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
).resolves.not.toThrow();
|
|
||||||
|
|
||||||
const status = container.querySelector('[role="status"]');
|
|
||||||
expect(status).toBeTruthy();
|
|
||||||
// A malformed reload message renders a stable, visible fallback rather
|
|
||||||
// than a silently empty status line.
|
|
||||||
expect(status?.textContent).toContain('Commands reloaded.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders safely and does not throw when a scoped error carries a malformed non-string error value', async () => {
|
|
||||||
await expect(
|
|
||||||
act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmitRaw('error', { conversationId: 'c1', error: ['not', 'a', 'string'] });
|
|
||||||
}),
|
|
||||||
).resolves.not.toThrow();
|
|
||||||
|
|
||||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
|
|
||||||
const textarea = container.querySelector(
|
|
||||||
'textarea[aria-label="Message"]',
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
const providerInput = container.querySelector(
|
|
||||||
'input[aria-label="Provider"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const modelInput = container.querySelector('input[aria-label="Model"]') as HTMLInputElement;
|
|
||||||
|
|
||||||
const stopButtonBefore = container.querySelector(
|
|
||||||
'button[aria-label="Stop"]',
|
|
||||||
) as HTMLButtonElement;
|
|
||||||
expect(stopButtonBefore.disabled).toBe(true);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
setValue(textarea, 'hello there');
|
|
||||||
setValue(providerInput, 'anthropic');
|
|
||||||
setValue(modelInput, 'claude');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
textarea.dispatchEvent(
|
|
||||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted).toContainEqual({
|
|
||||||
event: 'message',
|
|
||||||
payload: {
|
|
||||||
conversationId: undefined,
|
|
||||||
content: 'hello there',
|
|
||||||
provider: 'anthropic',
|
|
||||||
modelId: 'claude',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(container.textContent).toContain('hello there');
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
const stopButtonDuring = container.querySelector(
|
|
||||||
'button[aria-label="Stop"]',
|
|
||||||
) as HTMLButtonElement;
|
|
||||||
expect(stopButtonDuring.disabled).toBe(false);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
stopButtonDuring.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => {
|
|
||||||
const textarea = container.querySelector(
|
|
||||||
'textarea[aria-label="Message"]',
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
setValue(textarea, 'hello');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
textarea.dispatchEvent(
|
|
||||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('session:info', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
provider: 'anthropic',
|
|
||||||
modelId: 'claude',
|
|
||||||
thinkingLevel: 'medium',
|
|
||||||
availableThinkingLevels: ['low', 'medium', 'high'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
|
||||||
expect(container.textContent).toContain('anthropic');
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
|
||||||
expect(container.textContent).toContain('anthropic');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('surfaces a pre-ack error as an alert without leaving the Stop control stuck active', async () => {
|
|
||||||
const textarea = container.querySelector(
|
|
||||||
'textarea[aria-label="Message"]',
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
setValue(textarea, 'hello');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
textarea.dispatchEvent(
|
|
||||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('error', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
error: 'Failed to start agent session. Please try again.',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
|
||||||
expect(alerts.some((node) => node.textContent?.includes('Failed to start agent session'))).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
const stopButton = container.querySelector('button[aria-label="Stop"]') as HTMLButtonElement;
|
|
||||||
expect(stopButton.disabled).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows an accessible status once the message is acknowledged', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
const statuses = [...container.querySelectorAll('[role="status"]')];
|
|
||||||
expect(statuses.some((node) => node.textContent?.includes('m1'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders finalized thinking text in the transcript after agent:end, not only while streaming', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
||||||
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'reasoning about it' });
|
|
||||||
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Done.' });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('reasoning about it');
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('agent:end', { conversationId: 'c1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('reasoning about it');
|
|
||||||
expect(container.textContent).toContain('Done.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores a concurrent approval request and only executes the approved command once', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
const commandInput = container.querySelector(
|
|
||||||
'input[aria-label="Command name"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const argsInput = container.querySelector(
|
|
||||||
'input[aria-label="Command arguments"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
setValue(commandInput, 'deploy');
|
|
||||||
setValue(argsInput, 'prod');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
findButton(container, 'Request approval').click();
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
setValue(argsInput, 'staging');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
findButton(container, 'Request approval').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(1);
|
|
||||||
expect(fake.emitted).toContainEqual({
|
|
||||||
event: 'command:approve',
|
|
||||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('command:approval', {
|
|
||||||
conversationId: 'c1',
|
|
||||||
command: 'deploy',
|
|
||||||
success: true,
|
|
||||||
approvalId: 'ap1',
|
|
||||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
findButton(container, 'Run approved command').click();
|
|
||||||
findButton(container, 'Run approved command').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(1);
|
|
||||||
expect(fake.emitted).toContainEqual({
|
|
||||||
event: 'command:execute',
|
|
||||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables sending a second message while a turn is streaming', async () => {
|
|
||||||
const textarea = container.querySelector(
|
|
||||||
'textarea[aria-label="Message"]',
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
setValue(textarea, 'first');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
textarea.dispatchEvent(
|
|
||||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
||||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
const sendButton = findButton(container, 'Send');
|
|
||||||
expect(sendButton.disabled).toBe(true);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
setValue(textarea, 'second');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
textarea.dispatchEvent(
|
|
||||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => {
|
|
||||||
expect(fake.listeners.size).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
root = null;
|
|
||||||
|
|
||||||
for (const [, handlers] of fake.listeners) {
|
|
||||||
expect(handlers.size).toBe(0);
|
|
||||||
}
|
|
||||||
expect(destroySocketMock).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import { CommandsPanel } from '@/spa/chat/commands-panel';
|
|
||||||
import { Composer } from '@/spa/chat/composer';
|
|
||||||
import { MessageTranscript } from '@/spa/chat/message-transcript';
|
|
||||||
import { asFiniteNumberOrNull, asString } from '@/spa/chat/runtime-guards';
|
|
||||||
import { SessionPanel } from '@/spa/chat/session-panel';
|
|
||||||
import { ToolCallList } from '@/spa/chat/tool-call-list';
|
|
||||||
import { useChatConnection } from '@/spa/chat/use-chat-connection';
|
|
||||||
|
|
||||||
/** Renders a real value normally, but an honest "unavailable" label instead
|
|
||||||
* of a fabricated `0` for a missing/malformed count — a real `0 tokens` and
|
|
||||||
* an unknown token count must never look the same. */
|
|
||||||
function formatTokens(value: unknown): string {
|
|
||||||
const tokens = asFiniteNumberOrNull(value);
|
|
||||||
return tokens === null ? 'tokens unavailable' : `${tokens} tokens`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same honesty guarantee as `formatTokens`, for cost. */
|
|
||||||
function formatCost(value: unknown): string {
|
|
||||||
const cost = asFiniteNumberOrNull(value);
|
|
||||||
return cost === null ? 'cost unavailable' : `$${cost.toFixed(4)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatPage(): ReactElement {
|
|
||||||
const { state, actions } = useChatConnection();
|
|
||||||
const hasConversation = state.conversationId !== null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-[calc(100vh-3.5rem)] min-h-0 flex-col overflow-hidden md:h-screen">
|
|
||||||
<header className="border-b px-4 py-3">
|
|
||||||
<h1 className="text-lg font-semibold">Chat</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{state.systemReload ? (
|
|
||||||
<div role="status" className="border-b px-4 py-2 text-sm">
|
|
||||||
{asString(state.systemReload.message)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{state.error ? (
|
|
||||||
<div role="alert" className="border-b px-4 py-2 text-sm">
|
|
||||||
{asString(state.error)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{state.ack ? (
|
|
||||||
<div role="status" className="border-b px-4 py-1 text-xs opacity-70">
|
|
||||||
Message accepted · conversation {asString(state.ack.conversationId, 'unknown')} · id{' '}
|
|
||||||
{asString(state.ack.messageId, 'unknown')}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<SessionPanel sessionInfo={state.sessionInfo} onSetThinking={actions.setThinking} />
|
|
||||||
|
|
||||||
<MessageTranscript messages={state.messages} streaming={state.streaming} text={state.text} />
|
|
||||||
|
|
||||||
{state.thinking ? (
|
|
||||||
<section aria-label="Thinking" className="px-4 pb-2 text-xs italic opacity-80">
|
|
||||||
{state.thinking}
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ToolCallList tools={state.tools} />
|
|
||||||
|
|
||||||
{state.usage ? (
|
|
||||||
<div aria-label="Usage" className="px-4 pb-2 text-xs opacity-80">
|
|
||||||
{formatTokens(state.usage.tokens?.total)} · {formatCost(state.usage.cost)} ·{' '}
|
|
||||||
{asString(state.usage.provider, 'unknown')}/{asString(state.usage.modelId, 'unknown')}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<CommandsPanel
|
|
||||||
manifest={state.manifest}
|
|
||||||
results={state.commandResults}
|
|
||||||
approval={state.approval}
|
|
||||||
pendingApproval={state.pendingApproval}
|
|
||||||
hasConversation={hasConversation}
|
|
||||||
onExecute={actions.executeCommand}
|
|
||||||
onApprove={actions.approveCommand}
|
|
||||||
onRunApproved={actions.runApprovedCommand}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Composer
|
|
||||||
onSend={actions.sendMessage}
|
|
||||||
onStop={actions.abort}
|
|
||||||
streaming={state.streaming}
|
|
||||||
sending={state.sending}
|
|
||||||
hasConversation={hasConversation}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import { useEffect, useState, type FormEvent, type ReactElement } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { authClient, signIn } from '@/lib/auth-client';
|
|
||||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
|
||||||
|
|
||||||
export function LoginPage(): ReactElement {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
|
||||||
const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState<
|
|
||||||
SsoProviderDiscovery['id'] | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
void api<SsoProviderDiscovery[]>('/api/sso/providers').then(
|
|
||||||
(providers) => {
|
|
||||||
if (active) setSsoProviders(providers.filter((provider) => provider.configured));
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
if (active) setSsoProviders([]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
|
||||||
event.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const form = new FormData(event.currentTarget);
|
|
||||||
const email = String(form.get('email') ?? '');
|
|
||||||
const password = String(form.get('password') ?? '');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await signIn.email({ email, password });
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message ?? 'Sign in failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate('/chat', { replace: true });
|
|
||||||
} catch (caught: unknown) {
|
|
||||||
setError(caught instanceof Error ? caught.message : 'Sign in failed');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise<void> {
|
|
||||||
setError(null);
|
|
||||||
setSsoLoadingProviderId(providerId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await authClient.signIn.oauth2({
|
|
||||||
providerId,
|
|
||||||
callbackURL: '/chat',
|
|
||||||
newUserCallbackURL: '/chat',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message ?? `Sign in with ${providerId} failed`);
|
|
||||||
setSsoLoadingProviderId(null);
|
|
||||||
}
|
|
||||||
} catch (caught: unknown) {
|
|
||||||
setError(caught instanceof Error ? caught.message : `Sign in with ${providerId} failed`);
|
|
||||||
setSsoLoadingProviderId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">Sign in</h1>
|
|
||||||
<p className="mt-1 text-sm text-text-secondary">Sign in to your Mosaic account</p>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="[email protected]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? 'Signing in...' : 'Sign in'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<SsoProviderButtons
|
|
||||||
providers={ssoProviders}
|
|
||||||
loadingProviderId={ssoLoadingProviderId}
|
|
||||||
onOidcSignIn={(providerId) => {
|
|
||||||
void handleSsoSignIn(providerId);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="mt-4 text-center text-sm text-text-muted">
|
|
||||||
Don't have an account?{' '}
|
|
||||||
<Link to="/register" className="text-blue-400 hover:text-blue-300">
|
|
||||||
Sign up
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { useState, type FormEvent, type ReactElement } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import { signUp } from '@/lib/auth-client';
|
|
||||||
|
|
||||||
export function RegisterPage(): ReactElement {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
|
||||||
event.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const form = new FormData(event.currentTarget);
|
|
||||||
const name = String(form.get('name') ?? '');
|
|
||||||
const email = String(form.get('email') ?? '');
|
|
||||||
const password = String(form.get('password') ?? '');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await signUp.email({ name, email, password });
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message ?? 'Registration failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate('/chat', { replace: true });
|
|
||||||
} catch (caught: unknown) {
|
|
||||||
setError(caught instanceof Error ? caught.message : 'Registration failed');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">Create account</h1>
|
|
||||||
<p className="mt-1 text-sm text-text-secondary">Get started with Mosaic</p>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
autoComplete="name"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="Your name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="[email protected]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? 'Creating account...' : 'Create account'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="mt-4 text-center text-sm text-text-muted">
|
|
||||||
Already have an account?{' '}
|
|
||||||
<Link to="/login" className="text-blue-400 hover:text-blue-300">
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
|
||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const { apiMock, oauth2Mock } = vi.hoisted(() => ({
|
|
||||||
apiMock: vi.fn(),
|
|
||||||
oauth2Mock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/api', () => ({
|
|
||||||
api: apiMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth-client', () => ({
|
|
||||||
signIn: { oauth2: oauth2Mock },
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { SsoCallbackPage } from './sso-callback';
|
|
||||||
|
|
||||||
const mountedRoots: Root[] = [];
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
apiMock.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'authentik',
|
|
||||||
name: 'Authentik',
|
|
||||||
protocols: ['oidc'],
|
|
||||||
configured: true,
|
|
||||||
loginMode: 'oidc',
|
|
||||||
callbackPath: '/api/auth/oauth2/callback/authentik',
|
|
||||||
teamSync: { enabled: false, claim: null },
|
|
||||||
samlFallback: { configured: false, loginUrl: null },
|
|
||||||
warnings: [],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
oauth2Mock.mockResolvedValue({ data: null, error: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
for (const root of mountedRoots.splice(0)) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
document.body.replaceChildren();
|
|
||||||
apiMock.mockReset();
|
|
||||||
oauth2Mock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SsoCallbackPage', () => {
|
|
||||||
it('rejects a control-character callback that normalizes to an external origin', async () => {
|
|
||||||
const router = createMemoryRouter(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
path: '/auth/provider/:provider',
|
|
||||||
element: <SsoCallbackPage />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{
|
|
||||||
initialEntries: ['/auth/provider/authentik?callbackURL=%2F%0A%2F%2Fevil.example'],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
const root = createRoot(container);
|
|
||||||
mountedRoots.push(root);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(<RouterProvider router={router} />);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(oauth2Mock).toHaveBeenCalledWith({
|
|
||||||
providerId: 'authentik',
|
|
||||||
callbackURL: '/chat',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { useEffect, useState, type ReactElement } from 'react';
|
|
||||||
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
|
||||||
import { signIn } from '@/lib/auth-client';
|
|
||||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
|
||||||
|
|
||||||
export function SsoCallbackPage(): ReactElement {
|
|
||||||
const { provider: providerId = '' } = useParams<'provider'>();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
|
||||||
const [providerName, setProviderName] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function redirectToProvider(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
|
||||||
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
|
||||||
if (!provider) {
|
|
||||||
setError('Unknown SSO provider.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setProviderName(provider.name);
|
|
||||||
if (!provider.configured) {
|
|
||||||
setError(`${provider.name} is not enabled in this deployment.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (provider.loginMode !== 'oidc') {
|
|
||||||
setError(`${provider.name} is not available for OIDC sign in.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await signIn.oauth2({
|
|
||||||
providerId: provider.id,
|
|
||||||
callbackURL,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!cancelled && result?.error) {
|
|
||||||
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
|
||||||
}
|
|
||||||
} catch (caught: unknown) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void redirectToProvider();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [providerId, requestedCallbackURL]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
|
||||||
<p className="mt-2 text-sm text-text-secondary">
|
|
||||||
{providerName
|
|
||||||
? `Redirecting you to ${providerName}...`
|
|
||||||
: 'Preparing your sign-in request...'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
|
||||||
>
|
|
||||||
<p>{error}</p>
|
|
||||||
<Link
|
|
||||||
to="/login"
|
|
||||||
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
|
|
||||||
>
|
|
||||||
Return to login
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
|
|
||||||
If the redirect does not start automatically, return to the login page and try again.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
|
|
||||||
export function Placeholder({ title }: { title: string }): ReactElement {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-screen items-center justify-center">
|
|
||||||
<h1 className="text-xl font-medium">{title}</h1>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { isValidElement } from 'react';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { RouteObject } from 'react-router-dom';
|
|
||||||
import { routes } from '@/routes';
|
|
||||||
import { Placeholder } from '@/spa/placeholder';
|
|
||||||
import { ChatPage } from '@/spa/pages/chat';
|
|
||||||
|
|
||||||
function collectPaths(routeObjects: RouteObject[]): string[] {
|
|
||||||
return routeObjects.flatMap((route) => [
|
|
||||||
...(route.path ? [route.path] : []),
|
|
||||||
...(route.children ? collectPaths(route.children) : []),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findRoute(routeObjects: RouteObject[], path: string): RouteObject | undefined {
|
|
||||||
for (const route of routeObjects) {
|
|
||||||
if (route.path === path) return route;
|
|
||||||
const nested = route.children ? findRoute(route.children, path) : undefined;
|
|
||||||
if (nested) return nested;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('SPA route table', () => {
|
|
||||||
it('covers every v1 parity route from the Phase P RFC', () => {
|
|
||||||
expect(collectPaths(routes).sort()).toEqual(
|
|
||||||
[
|
|
||||||
'/',
|
|
||||||
'/admin',
|
|
||||||
'/auth/provider/:provider',
|
|
||||||
'/chat',
|
|
||||||
'/login',
|
|
||||||
'/projects',
|
|
||||||
'/projects/:id',
|
|
||||||
'/register',
|
|
||||||
'/settings',
|
|
||||||
'/tasks',
|
|
||||||
].sort(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('separates guest and authenticated route groups', () => {
|
|
||||||
const guestPaths = collectPaths(routes.at(0)?.children ?? []);
|
|
||||||
const authPaths = collectPaths(routes.at(1)?.children ?? []);
|
|
||||||
expect(guestPaths).toContain('/login');
|
|
||||||
expect(guestPaths).not.toContain('/chat');
|
|
||||||
expect(authPaths).toContain('/chat');
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['/login', '/register', '/auth/provider/:provider'])(
|
|
||||||
'renders a real guest page instead of the P1 placeholder at %s',
|
|
||||||
(path) => {
|
|
||||||
const element = findRoute(routes, path)?.element;
|
|
||||||
expect(isValidElement(element)).toBe(true);
|
|
||||||
if (!isValidElement(element)) throw new Error(`Missing route element for ${path}`);
|
|
||||||
expect(element.type).not.toBe(Placeholder);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('renders the real chat page instead of the P1 placeholder at /chat, inside the authenticated group', () => {
|
|
||||||
const authPaths = collectPaths(routes.at(1)?.children ?? []);
|
|
||||||
expect(authPaths).toContain('/chat');
|
|
||||||
|
|
||||||
const element = findRoute(routes, '/chat')?.element;
|
|
||||||
expect(isValidElement(element)).toBe(true);
|
|
||||||
if (!isValidElement(element)) throw new Error('Missing route element for /chat');
|
|
||||||
expect(element.type).not.toBe(Placeholder);
|
|
||||||
expect(element.type).toBe(ChatPage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
describe('Vitest abort-controller realm', () => {
|
|
||||||
it('provides a global signal accepted by Node native Request', () => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const request = new Request('https://mosaic.invalid/navigation', {
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(request.signal).toBeInstanceOf(AbortSignal);
|
|
||||||
controller.abort();
|
|
||||||
expect(request.signal.aborted).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { transferableAbortController } from 'node:util';
|
|
||||||
|
|
||||||
// jsdom installs realm-local abort constructors while Node's undici Request
|
|
||||||
// remains native. React Router passes a global AbortSignal to Request, so both
|
|
||||||
// constructors must come from Node's native realm during tests.
|
|
||||||
const nativeController = transferableAbortController();
|
|
||||||
const nativeAbortController = nativeController.constructor;
|
|
||||||
const nativeAbortSignal = nativeController.signal.constructor;
|
|
||||||
|
|
||||||
for (const target of [globalThis, window]) {
|
|
||||||
Object.defineProperties(target, {
|
|
||||||
AbortController: {
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
value: nativeAbortController,
|
|
||||||
},
|
|
||||||
AbortSignal: {
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
value: nativeAbortSignal,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
|
|
||||||
// The proxy exists only in dev; in production the SPA is same-origin with the gateway
|
|
||||||
// (served by it under Candidate A, or behind one FQDN under Candidate B) and every
|
|
||||||
// request uses a relative path, so no origin may ever be configured here or in src/.
|
|
||||||
const gatewayTarget = 'http://localhost:14242';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
port: 3100,
|
|
||||||
proxy: {
|
|
||||||
'/api': gatewayTarget,
|
|
||||||
'/socket.io': { target: gatewayTarget, ws: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,21 +1,9 @@
|
|||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// tsconfig uses "jsx": "preserve" for Next; tests need esbuild to compile it
|
|
||||||
esbuild: {
|
|
||||||
jsx: 'automatic',
|
|
||||||
},
|
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./src/test/setup.ts'],
|
|
||||||
isolate: true,
|
|
||||||
exclude: ['e2e/**', 'node_modules/**'],
|
exclude: ['e2e/**', 'node_modules/**'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
|||||||
COPY apps/gateway/package.json ./apps/gateway/
|
COPY apps/gateway/package.json ./apps/gateway/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
COPY plugins/ ./plugins/
|
COPY plugins/ ./plugins/
|
||||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
|
||||||
COPY scripts/ ./scripts/
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
||||||
@@ -23,22 +21,11 @@ RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
|
|||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
# WorkspaceService shells out to git at runtime and roots workspaces at
|
|
||||||
# $MOSAIC_ROOT/.workspaces (apps/gateway/src/workspace/workspace.service.ts);
|
|
||||||
# mount a volume over /opt/mosaic to persist workspaces across container restarts.
|
|
||||||
# Intentionally unpinned: Alpine's signed repository is the trust anchor; pinning
|
|
||||||
# git was declined so routine base-image security updates remain maintainable.
|
|
||||||
RUN apk add --no-cache git \
|
|
||||||
&& mkdir -p /opt/mosaic/.workspaces \
|
|
||||||
&& chown -R node:node /opt/mosaic /app
|
|
||||||
ENV MOSAIC_ROOT=/opt/mosaic
|
|
||||||
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
||||||
COPY --chown=node:node --from=builder /deploy/node_modules ./node_modules
|
COPY --from=builder /deploy/node_modules ./node_modules
|
||||||
COPY --chown=node:node --from=builder /deploy/package.json ./package.json
|
COPY --from=builder /deploy/package.json ./package.json
|
||||||
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
||||||
# copy from builder explicitly as belt-and-suspenders
|
# copy from builder explicitly as belt-and-suspenders
|
||||||
COPY --chown=node:node --from=builder /app/apps/gateway/dist ./dist
|
COPY --from=builder /app/apps/gateway/dist ./dist
|
||||||
# gateway defaults to port 14242 (apps/gateway/src/main.ts)
|
EXPOSE 4000
|
||||||
EXPOSE 14242
|
|
||||||
USER node
|
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ WORKDIR /app
|
|||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
COPY apps/web/package.json ./apps/web/
|
COPY apps/web/package.json ./apps/web/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
|
||||||
COPY scripts/ ./scripts/
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm --filter @mosaicstack/web build
|
RUN pnpm --filter @mosaic/web build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
+30
@@ -146,6 +146,36 @@ lands. M0 consists only of these normative requirements, the complete task DAG,
|
|||||||
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
|
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
|
||||||
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
|
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
|
||||||
|
|
||||||
|
### Fleet git identity launch propagation (#1043)
|
||||||
|
|
||||||
|
#### Problem and objective
|
||||||
|
|
||||||
|
A fleet seat can have a registered per-agent Git credential while its launched runtime process lacks
|
||||||
|
`MOSAIC_GIT_IDENTITY`. The credential resolver then cannot select the seat identity reliably, which
|
||||||
|
blocks repository operations on fail-closed estates and can fall through to an unrelated identity on
|
||||||
|
estates where that refusal is not active. The objective is to make Git identity a deterministic,
|
||||||
|
roster-derived part of the generated launch projection and prove it reaches the launched process.
|
||||||
|
|
||||||
|
#### Normative requirements
|
||||||
|
|
||||||
|
1. `FGI-REQ-01`: Every generated fleet agent projection SHALL declare
|
||||||
|
`MOSAIC_GIT_IDENTITY=<MOSAIC_AGENT_NAME>`; a differing or unsafe identity SHALL fail closed before
|
||||||
|
tmux launch.
|
||||||
|
2. `FGI-REQ-02`: The clean `/usr/bin/env -i` pane boundary SHALL pass every variable declared by the
|
||||||
|
generated projection, including `MOSAIC_GIT_IDENTITY`, to the launched runtime process.
|
||||||
|
3. `FGI-REQ-03`: A behavioral integration test SHALL set-compare the complete generated projection
|
||||||
|
against the launched process environment. Source-text/string-presence assertions are insufficient.
|
||||||
|
4. `FGI-REQ-04`: Verification SHALL include RED-first evidence and a delete-the-subject mutation that
|
||||||
|
removes Git-identity pane propagation and makes the behavioral test fail.
|
||||||
|
|
||||||
|
#### Acceptance criteria
|
||||||
|
|
||||||
|
1. `AC-FGI-01`: A launched seat process contains every key/value pair declared by its generated
|
||||||
|
environment projection, including the roster-derived Git identity.
|
||||||
|
2. `AC-FGI-02`: Missing, unsafe, or split Git identity is rejected before a tmux session is created.
|
||||||
|
3. `AC-FGI-03`: Focused launcher and generated-environment tests, repository quality gates,
|
||||||
|
independent review, and the required RED/green/R7 evidence are recorded before push.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Exact Cross-Harness Fleet Communications Contract (#766)
|
## Exact Cross-Harness Fleet Communications Contract (#766)
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
# npm `@next` prerelease lane
|
|
||||||
|
|
||||||
Status: **IMPLEMENTED**
|
|
||||||
|
|
||||||
## Current behavior
|
|
||||||
|
|
||||||
`tools/install.sh --next` provides the prerelease integration lane for the permanent `next` branch.
|
|
||||||
|
|
||||||
The lane is fast-by-default:
|
|
||||||
|
|
||||||
1. Install framework files from the `next` source archive.
|
|
||||||
2. Resolve the Gitea npm registry `next` dist-tag for the globally installed packages:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm view @mosaicstack/gateway@next version
|
|
||||||
npm view @mosaicstack/mosaic@next version
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Require both resolved versions to share the same `next.<pipeline>` suffix, then install the exact resolved versions.
|
|
||||||
4. If either `@next` package is missing, unreachable, mismatched, or fails to install, fall back to the source-build path at `next`.
|
|
||||||
|
|
||||||
`--next` never hard-fails solely because the prerelease npm dist-tag is unavailable.
|
|
||||||
|
|
||||||
## Published packages
|
|
||||||
|
|
||||||
The `next` publish pipeline publishes non-private `@mosaicstack/*` packages to the Mosaic Gitea npm registry:
|
|
||||||
|
|
||||||
```text
|
|
||||||
https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed `next` dist-tags after enabling the pipeline:
|
|
||||||
|
|
||||||
```text
|
|
||||||
@mosaicstack/mosaic@next -> 0.0.49-next.1633
|
|
||||||
@mosaicstack/gateway@next -> 0.0.7-next.1633
|
|
||||||
```
|
|
||||||
|
|
||||||
The gateway also publishes a Docker image as `gateway:sha-<short>` on `next` merges. The installer fast path uses the npm gateway package when available; the Docker image is for deployed gateway/runtime harness flows.
|
|
||||||
|
|
||||||
## Explicit source lanes
|
|
||||||
|
|
||||||
Source builds remain available and are still the authority for explicit ref validation:
|
|
||||||
|
|
||||||
- `--dev` always builds from source.
|
|
||||||
- `--ref <ref>` / `MOSAIC_REF=<ref>` wins over `--next` and uses the source path for that exact ref.
|
|
||||||
|
|
||||||
## Pipeline shape
|
|
||||||
|
|
||||||
1. Trigger on `next` merges.
|
|
||||||
2. Compute the next prerelease version from the upcoming stable version plus the Woodpecker pipeline number (`<target-stable>-next.<CI_PIPELINE_NUMBER>`).
|
|
||||||
3. Build and publish non-private packages in CI.
|
|
||||||
4. Publish to the Mosaic Gitea npm registry with dist-tag `next`.
|
|
||||||
5. Keep `latest` untouched; only main/release promotion can update `latest`.
|
|
||||||
6. Publish gateway Docker images from `next` as `gateway:sha-<short>` only.
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- `@next` is mutable prerelease convenience, not a deployment pin.
|
|
||||||
- Stable installs continue to use `@latest`.
|
|
||||||
- Contributor validation remains available through `--dev --ref <branch>`.
|
|
||||||
- Pipeline output traces every prerelease package back to the source commit on `next`.
|
|
||||||
- The installer falls back to source rather than hard-failing on prerelease registry issues.
|
|
||||||
+13
-10
@@ -5,14 +5,14 @@ Generated environment files are rebuildable projections, not an operator-editabl
|
|||||||
|
|
||||||
## Launch chain
|
## Launch chain
|
||||||
|
|
||||||
| Layer | Responsibility |
|
| Layer | Responsibility |
|
||||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. |
|
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket; Git identity is derived from the exact agent name. |
|
||||||
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
|
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
|
||||||
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
|
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
|
||||||
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
|
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
|
||||||
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
|
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
|
||||||
| runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
|
| runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
|
||||||
|
|
||||||
The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
|
The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
|
||||||
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
|
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
|
||||||
@@ -24,6 +24,7 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
MOSAIC_AGENT_NAME=<roster name>
|
MOSAIC_AGENT_NAME=<roster name>
|
||||||
|
MOSAIC_GIT_IDENTITY=<roster name>
|
||||||
MOSAIC_AGENT_CLASS=<roster class>
|
MOSAIC_AGENT_CLASS=<roster class>
|
||||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||||
@@ -33,8 +34,10 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
|||||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add
|
`MOSAIC_GIT_IDENTITY` is not independently configurable: it must equal `MOSAIC_AGENT_NAME`, preventing
|
||||||
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
|
split runtime and repository identity authority. The generated launch contract supports `claude`,
|
||||||
|
`codex`, `opencode`, and `pi`. mosaic fleet add rejects another runtime before it writes the roster or
|
||||||
|
modifies generated, local, or quarantine state.
|
||||||
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
|
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
|
||||||
it has no generated-launch adapter and cannot be added through this path.
|
it has no generated-launch adapter and cannot be added through this path.
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
The launcher consumes validated data, not shell configuration.
|
The launcher consumes validated data, not shell configuration.
|
||||||
|
|
||||||
1. Read and validate the canonical roster.
|
1. Read and validate the canonical roster.
|
||||||
2. Render deterministic <name>.env.generated data from that roster.
|
2. Render deterministic <name>.env.generated data from that roster, including `MOSAIC_GIT_IDENTITY` derived exactly from the roster agent name.
|
||||||
3. Parse optional <name>.env.local through a strict allowlist.
|
3. Parse optional <name>.env.local through a strict allowlist.
|
||||||
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
|
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
|
||||||
5. Derive the runtime command from validated runtime/model/reasoning data.
|
5. Reject a Git identity that is unsafe or differs from the generated agent name.
|
||||||
6. Target only the exact configured tmux socket and roster session after ownership checks.
|
6. Derive the runtime command from validated runtime/model/reasoning data and pass every generated projection entry through the clean process environment boundary.
|
||||||
|
7. Target only the exact configured tmux socket and roster session after ownership checks.
|
||||||
|
|
||||||
## File precedence and ownership
|
## File precedence and ownership
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ values, credential material, or command text.
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
MOSAIC_AGENT_NAME=<roster name>
|
MOSAIC_AGENT_NAME=<roster name>
|
||||||
|
MOSAIC_GIT_IDENTITY=<roster name>
|
||||||
MOSAIC_AGENT_CLASS=<roster class>
|
MOSAIC_AGENT_CLASS=<roster class>
|
||||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||||
@@ -44,8 +45,9 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
|||||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add
|
`MOSAIC_GIT_IDENTITY` is derived from and must equal `MOSAIC_AGENT_NAME`; it is not a separate
|
||||||
uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
operator-controlled identity authority. The generated launch contract supports only `claude`, `codex`,
|
||||||
|
`opencode`, and `pi`. fleet add uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
||||||
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
|
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
|
||||||
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
|
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
|
||||||
this projection path.
|
this projection path.
|
||||||
|
|||||||
@@ -195,17 +195,6 @@ pnpm format:check && pnpm typecheck && pnpm lint
|
|||||||
|
|
||||||
A pre-push hook enforces this mechanically.
|
A pre-push hook enforces this mechanically.
|
||||||
|
|
||||||
### CI Publish Channels
|
|
||||||
|
|
||||||
Woodpecker `.woodpecker/publish.yml` keeps stable and integration-line artifacts separate:
|
|
||||||
|
|
||||||
| Source | npm packages | Gateway image |
|
|
||||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
||||||
| `main` push/manual or release tag | committed package versions published to Gitea npm without changing the dist-tag workflow | `gateway:sha-<short>` plus `gateway:latest` on `main`, and the release tag on tag events |
|
|
||||||
| `next` push/manual | CI-computed prereleases, `<target-stable>-next.<CI_PIPELINE_NUMBER>`, published with `npm publish --tag next` | `gateway:sha-<short>` only |
|
|
||||||
|
|
||||||
`next` never publishes npm `latest` or Docker `latest`. The next npm publish step verifies that `@mosaicstack/mosaic@next` resolves to the computed prerelease before the pipeline can pass.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Adding New Agent Tools
|
## Adding New Agent Tools
|
||||||
|
|||||||
@@ -175,18 +175,8 @@ Or use the direct URL:
|
|||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`.
|
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
|
||||||
|
non-interactive use:
|
||||||
Install lanes:
|
|
||||||
|
|
||||||
| Lane | Command | Source |
|
|
||||||
| ------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
||||||
| Stable | `bash tools/install.sh` | npm `@mosaicstack/mosaic@latest` + `main` |
|
|
||||||
| Prerelease integration | `bash tools/install.sh --next` | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
|
|
||||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | Build-from-source at the requested ref |
|
|
||||||
|
|
||||||
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` still wins and uses the source path.
|
|
||||||
Flags for non-interactive use:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
--yes # Accept all defaults
|
--yes # Accept all defaults
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
# #1019 — Zero-timeout queue-guard harness race
|
||||||
|
|
||||||
|
- **Issue:** #1019 (parent status remains `believed-fixed, pending jarvis validation`; do not close)
|
||||||
|
- **Branch:** `fix/1019-ci-queue-timeout-harness`
|
||||||
|
- **Owner:** `be-coder-08`
|
||||||
|
- **Base:** `origin/main` at `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`
|
||||||
|
- **Charter:** `/home/hermes/agent-work/tl-mosaic/CHARTER-1019-HARNESS-FIX.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Make `test-ci-queue-wait-tristate.sh` deterministic without changing any asserted outcome. Remove the indiscriminate zero-timeout race, require every status-classification case to prove the provider was observed, and prove the harness-controlled virtual clock is active.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- In scope: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` only, plus this evidence scratchpad.
|
||||||
|
- Out of scope: guard parsers, D2/D3 behavior, installer/reseed staleness, PR #1060, and issue closure.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
1. RED deterministically reproduces deadline pre-emption before the provider call.
|
||||||
|
2. Every case that intends status classification positively proves provider observation.
|
||||||
|
3. Pending observes `pending` before deterministic virtual-time expiration.
|
||||||
|
4. The virtual clock has a positive interception control; a broken-clock mutant makes the suite red.
|
||||||
|
5. The exact CI-base image passes the final harness repeatedly with zero failures.
|
||||||
|
6. Baseline gates, independent code/security review, exact-head CI, and coordinator-authorized squash merge pass.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add deterministic RED instrumentation for the known merge/provider-unreachable pre-emption.
|
||||||
|
2. Replace global `-t 0` with a nonzero timeout interpreted under an event-driven virtual clock; stub sleep without wall waiting.
|
||||||
|
3. Add provider-observation and virtual-clock positive controls without changing outcome assertions.
|
||||||
|
4. Run focused shell checks, repeat in exact CI-base image, baseline gates, and independent reviews.
|
||||||
|
5. Commit with both identity layers, queue-guard plus direct Woodpecker terminal-state verification, push, self-post PR, verify poster/head/CI, obtain coordinator merge authorization, then squash merge without closing #1019.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
- No explicit token cap supplied. Keep scope to one harness file and one scratchpad; stop/report at the charter's 60% context gate.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- RED, deterministic pre-provider expiry: `evidence/1019-harness-fix/red-pre-provider-expiry.log` — rc 1; merge/provider-unreachable got rc 124 instead of 75, omitted CANNOT_ASSERT, did not observe the status provider, and wrote no additional audit record (four named failures).
|
||||||
|
- GREEN host focused harness: `evidence/1019-harness-fix/green-host.log` — rc 0, all outcome classes passed.
|
||||||
|
- Load-bearing clock negative control: a temporary same-directory mutant replaced the virtual `date` body with `/bin/date`; `evidence/1019-harness-fix/red-clock-not-intercepted.log` — rc 1 with named `virtual clock interception did not run` failures. The mutant file was removed after the run.
|
||||||
|
- Exact CI-base repeat: `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`, repository mounted read-only, harness work under container `/tmp`; `evidence/1019-harness-fix/ci-image-repeat/summary.log` — **100 pass / 0 fail / 100 total**.
|
||||||
|
- Synchronization design: provider-status observation creates the event marker; virtual time is 1000 before the event and 1002 afterward. Pending alone reaches the stubbed no-op sleep and a post-observation deadline check. `-t 1` is uniquely load-bearing because removing it restores the 900-second default deadline at virtual time 1900, which 1002 does not cross. The numeric timeout is subject semantics under virtual time, not a wall-clock synchronization duration.
|
||||||
|
|
||||||
|
## Review remediation — semantic timeout vs. liveness bound
|
||||||
|
|
||||||
|
Security review found that virtual time remained at 1000 forever before provider observation and stubbed sleep never waited. A regression looping before the status endpoint—or blocking in the first provider call—therefore could prevent `run_guard` from returning, so the post-return provider assertion could never fire.
|
||||||
|
|
||||||
|
**General rule:** A timeout usually serves two purposes: semantics and liveness. Removing wall time from semantic synchronization can silently remove the only independent hang bound. Preserve deterministic virtual time for subject semantics, but provide a separately implemented real-clock liveness watchdog and prove that watchdog fires.
|
||||||
|
|
||||||
|
Remediation:
|
||||||
|
|
||||||
|
- Every guard subject invocation is launched by absolute `/usr/bin/python3` in a new session. Python's internal monotonic `wait(timeout=...)` provides real-clock liveness independently of PATH; expiry kills the entire isolated process group, so neither PATH-front shims nor a blocked provider descendant can retain the capture pipe.
|
||||||
|
- Watchdog expiry returns distinct harness rc 90 plus `FAIL HANG watchdog`, separate from subject timeout rc 124.
|
||||||
|
- A first attempt using absolute `/usr/bin/timeout -s KILL` passed on GNU coreutils but failed in the exact Alpine CI-base image: BusyBox killed the immediate wrapper while the guard/provider descendants survived and retained the command-substitution pipe. The process-group kill is therefore required behavior, not portability polish.
|
||||||
|
- A committed positive control hangs the branch-provider stub before the status endpoint. It must terminate through the watchdog, emit the hang-specific diagnostic, return rc 90, and prove the status provider was never reached.
|
||||||
|
- RED before remediation: a temporary ordinary-success mutant hung before provider observation; only an external control could kill the suite (rc 137), and there was no internal hang-specific diagnostic (`red-watchdog-absent.log`).
|
||||||
|
- The watchdog mutant/control is load-bearing: removing the internal watchdog leaves the control unable to produce its required rc 90 and diagnostic.
|
||||||
|
|
||||||
|
Post-review evidence:
|
||||||
|
|
||||||
|
- Host focused harness with process-group watchdog: rc 0 (`green-watchdog-process-group-host.log`).
|
||||||
|
- Exact Alpine CI-base focused harness with process-group watchdog: rc 0 (`green-watchdog-ci-image.log`).
|
||||||
|
- Hanging ordinary-success mutant: suite rc 1; success returned rc 90, emitted `FAIL HANG watchdog`, and loudly reported that provider/clock observation did not occur (`red-watchdog-fires.log`).
|
||||||
|
- Removed-`-t 1` mutant: suite rc 1; pending was terminated by the watchdog instead of producing `ASSERTED_NOT_READY`, proving the explicit timeout is load-bearing (`red-timeout-argument-removed.log`).
|
||||||
|
|
||||||
|
## 60% context hold
|
||||||
|
|
||||||
|
Stopped before baseline/review/commit as required by the charter. Remaining: inspect final diff, shell/static/baseline gates, independent code/security review, remediation if any, identity-bound commit/trailer verification, mandatory queue guard plus direct terminal Woodpecker `mosaic` enumeration, push, self-posted PR/provider poster read-back, exact-head terminal-green CI, coordinator merge authorization, squash merge, main CI verification, and leave #1019 unclosed as `believed-fixed, pending jarvis validation`.
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# #1043 — Fleet pane git-identity propagation
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Ensure a fleet seat's launched runtime process receives its roster-derived `MOSAIC_GIT_IDENTITY`, and lock the complete generated-environment propagation boundary with an enumerated set comparison.
|
||||||
|
|
||||||
|
## Tracking
|
||||||
|
|
||||||
|
- External issue: `mosaicstack/stack#1043`
|
||||||
|
- Branch: `fix/1043-pane-git-identity`
|
||||||
|
- Coordinator: `tl-mosaic`
|
||||||
|
- `docs/TASKS.md`: read-only by project worker contract; not modified.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- RED-first bug reproducer is mandatory.
|
||||||
|
- R7 delete-the-subject mutation must turn the behavioral test red.
|
||||||
|
- Assert launched-process environment, not source text.
|
||||||
|
- One push only; do not poll CI after push.
|
||||||
|
- Run the CI queue guard immediately before push and report its `state=` line as state, not evidence.
|
||||||
|
- Do not modify a live host launcher or obtain/copy another credential.
|
||||||
|
- Self-post the PR, verify provider attribution, then stop.
|
||||||
|
- Final status wording: `believed-fixed, pending jarvis validation`.
|
||||||
|
|
||||||
|
## Scope inventory
|
||||||
|
|
||||||
|
Re-derived against `origin/main` at `85d2108e`:
|
||||||
|
|
||||||
|
- Launch consumer: `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
|
||||||
|
- Behavioral launch test: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||||
|
- Generated-environment contract/parser: `packages/mosaic/src/fleet/generated-env-boundary.ts`
|
||||||
|
- Roster projection producers:
|
||||||
|
- `packages/mosaic/src/commands/fleet.ts`
|
||||||
|
- `packages/mosaic/src/fleet/fleet-reconciler.ts`
|
||||||
|
- `packages/mosaic/src/fleet/fleet-agent-crud.ts`
|
||||||
|
- `packages/mosaic/src/fleet/v1-v2-migration.ts`
|
||||||
|
- Contract and producer tests discovered by repository search.
|
||||||
|
- Generated-environment operator/developer docs and their executable documentation contract test.
|
||||||
|
|
||||||
|
Discrepancy sent to `tl-mosaic`: current main no longer contains the charter's `PANE_SHELL_SNIPPET`; #772 replaced it with an `/usr/bin/env -i` argv launch boundary, and current generated projections do not declare git identity. Code-read inventory is **NOT MEASURED** behavior.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add the process-environment set-comparison regression first and record RED.
|
||||||
|
2. Add roster-derived `MOSAIC_GIT_IDENTITY=<agent name>` to the complete generated projection contract.
|
||||||
|
3. Validate identity syntax and equality with `MOSAIC_AGENT_NAME`; pass it through the clean pane environment.
|
||||||
|
4. Update affected projection tests and generated-environment docs.
|
||||||
|
5. Run focused and baseline gates.
|
||||||
|
6. Perform R7 by deleting the pane propagation entry, prove RED, restore, and prove GREEN.
|
||||||
|
7. Run independent review, remediate, commit, queue guard, one push, self-post PR, verify provider attribution, and stop without CI polling.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
No explicit token cap was provided. Working cap: one narrow logical unit, no dependency installation unless existing tooling requires it, no unrelated refactor.
|
||||||
|
|
||||||
|
## Evidence log
|
||||||
|
|
||||||
|
### TDD and mutation evidence
|
||||||
|
|
||||||
|
- RED-first, repository launcher: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` exited 64 on pre-fix source with `code=unknown-key key=MOSAIC_GIT_IDENTITY`. The generated seat could not launch with the required declared identity.
|
||||||
|
- GREEN: the same repository launcher test emitted `ok - start-agent-session generated environment boundary`.
|
||||||
|
- R7 delete-the-subject: removed only `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"` from the repository launch array; the same test exited 1 with `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
|
||||||
|
- R7 restoration: restored that launch entry; the same test returned green.
|
||||||
|
- Launcher under test is explicitly `packages/mosaic/framework/tools/fleet/start-agent-session.sh` through the test's `$START`, **not** the stale installed host copy.
|
||||||
|
|
||||||
|
### Situational and focused tests
|
||||||
|
|
||||||
|
- Repository launcher boundary: green, including set comparison of all nine generated projection entries and fail-before-tmux cases for missing, unsafe, mismatched, and local-shadow Git identity.
|
||||||
|
- Fleet systemd launcher integration: `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — green.
|
||||||
|
- Focused Mosaic Vitest set: 6 files, 311 tests — green.
|
||||||
|
- `bash -n` on changed shell files — green.
|
||||||
|
- `git diff --check` — green.
|
||||||
|
|
||||||
|
### Baseline gates
|
||||||
|
|
||||||
|
- `pnpm typecheck` — 45/45 tasks green.
|
||||||
|
- `pnpm lint` — 25/25 tasks green.
|
||||||
|
- `pnpm format:check` — green.
|
||||||
|
- `pnpm test:checkout` — green.
|
||||||
|
- Repository-wide Vitest under a hermetic current-version npm prefix: Mosaic 81/81 files and 1510/1510 tests green; other workspace test tasks shown green before the framework-shell phase.
|
||||||
|
- Canonical `pnpm test` is not fully green on this host for unrelated environment-sensitive gates:
|
||||||
|
1. the first two runs exposed the globally installed Mosaic 0.0.48 update banner in three CLI smoke tests expecting empty stderr;
|
||||||
|
2. after isolating that global-version input, the framework wake assertion aborted at the known `#973` Bash `BASH_LINENO` convention check (exit 97; observed `[3 5]`, expected `[3 4]`).
|
||||||
|
No tests were weakened or bypassed; focused changed-surface tests are green. CI remains the canonical clean-environment result and is intentionally not polled after push per charter.
|
||||||
|
|
||||||
|
### Independent review
|
||||||
|
|
||||||
|
- Codex code review first pass: request changes for missing shell rejection-path coverage.
|
||||||
|
- Remediation: added table-driven missing/unsafe/mismatch/local-shadow launcher cases, each asserting no tmux call.
|
||||||
|
- Codex code re-review: **approve**, no findings, confidence 0.88.
|
||||||
|
- Codex security review: risk `none`, no findings, confidence 0.97.
|
||||||
|
|
||||||
|
### Acceptance criteria mapping
|
||||||
|
|
||||||
|
| Acceptance criterion | Evidence |
|
||||||
|
| --- | --- |
|
||||||
|
| AC-FGI-01: launched process receives every generated key/value | Repository launcher process-environment `comm -23` set comparison; GREEN and R7 RED evidence above |
|
||||||
|
| AC-FGI-02: missing, unsafe, or split identity fails before tmux | Table-driven shell cases plus TypeScript generated-boundary tests |
|
||||||
|
| AC-FGI-03: focused/baseline/review evidence recorded | Commands and review outcomes above; host-sensitive full-suite limitations stated explicitly |
|
||||||
|
|
||||||
|
### Documentation checklist
|
||||||
|
|
||||||
|
- PRD updated with #1043 requirements and acceptance criteria.
|
||||||
|
- Fleet launch runbook, generated-env concept, and generated-env reference updated.
|
||||||
|
- No API/OpenAPI, sitemap, user publishing target, deployment, or external docs publication change applies.
|
||||||
|
- `docs/TASKS.md` remains unmodified per its single-writer project contract.
|
||||||
|
|
||||||
|
## Round 2 — PR #1073 review 97 remediation
|
||||||
|
|
||||||
|
### Review blocker
|
||||||
|
|
||||||
|
The launched-process suite was signed-excluded from CI enumeration. Manual GREEN/R7 evidence therefore did not prove a PR workflow could detect regression.
|
||||||
|
|
||||||
|
### RED-first and canonical wiring
|
||||||
|
|
||||||
|
1. Removed the suite's signed exclusion before adding a CI execution path.
|
||||||
|
2. `check-test-enumeration.sh` went RED with exact `UNENUMERATED` output for `test-start-agent-session.sh`: population 49, enumerated 30, excluded 18.
|
||||||
|
3. Added both `framework/tools/fleet/test-start-agent-session.sh` and `framework/systemd/user/test-fleet-units.sh` to `@mosaicstack/mosaic`'s canonical `test:framework-shell` chain.
|
||||||
|
4. The guard returned GREEN: population 49, enumerated 32, excluded 18, surfaces 45. The systemd suite is outside the guard's tools-only population but now has the same explicit canonical execution disposition.
|
||||||
|
|
||||||
|
### Workflow-level R7
|
||||||
|
|
||||||
|
- Deleted only the pane launch entry `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"`.
|
||||||
|
- Ran the exact `.woodpecker/ci.yml` test-step command, `pnpm test`, with only a temporary PATH-scoped npm shim reporting the checkout's current 0.0.49 version so the unrelated global 0.0.48 banner could not preempt the shell chain.
|
||||||
|
- Result: exit 1 at `@mosaicstack/mosaic#test`, with the enumeration guard GREEN followed by `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
|
||||||
|
- Restored the launch entry. The canonical `test:framework-shell` chain then reached both newly wired suites and printed both GREEN markers before the known unrelated #973 host-only `BASH_LINENO` abort.
|
||||||
|
- An actual provider PR workflow on the intentionally broken mutant is **NOT MEASURED**: the one-push constraint forbids pushing a red mutant and then a repaired head. Local execution proves the exact PR workflow command and dependency chain go RED on the subject deletion; CI on the repaired pushed head remains canonical.
|
||||||
|
|
||||||
|
### Workflow population
|
||||||
|
|
||||||
|
- **DEFINED:** 3 workflows (`ci.yml`, `ci-image.yml`, `publish.yml`).
|
||||||
|
- **ELIGIBLE for `pull_request`:** 1/3 (`ci.yml`), based on top-level `when:` clauses.
|
||||||
|
- **REPORTED:** Round-1 exact-head provider read reported 1/1 eligible context (`ci/woodpecker/pr/ci`). Post-remediation-head reported count is **NOT MEASURED** by this seat because CI polling is prohibited; workflow definitions and eligibility did not change.
|
||||||
|
|
||||||
|
### Independent remediation review
|
||||||
|
|
||||||
|
- First Round-2 review identified a CI-image blocker: the newly wired launcher suite used Perl, which the Alpine CI base does not install.
|
||||||
|
- Replaced the suite's three Perl-only fixture mutations with POSIX/BusyBox-compatible `sed -i` substitutions; production behavior and assertions are unchanged.
|
||||||
|
- Codex re-review: **APPROVE**, confidence 0.93, no findings.
|
||||||
|
|
||||||
|
### Vitest denominator reconciliation
|
||||||
|
|
||||||
|
The PR's `311/311` is correct for its explicitly named six-file command at both the original and remediation worktrees:
|
||||||
|
|
||||||
|
- generated environment boundary: 24
|
||||||
|
- fleet documentation: 23
|
||||||
|
- Tess service profile: 6
|
||||||
|
- fleet regen command: 27
|
||||||
|
- fleet agent CRUD command: 22
|
||||||
|
- fleet command: 209
|
||||||
|
- total: **311**
|
||||||
|
|
||||||
|
Review 97 reported 312/312 without naming its six files. That is a different or miscounted population and cannot replace the command-scoped 311 denominator; the PR follow-up will name the exact files and arithmetic.
|
||||||
|
|
||||||
|
## Round 3 — Alpine stale-marker portability
|
||||||
|
|
||||||
|
### Objective and plan
|
||||||
|
|
||||||
|
- Replace the GNU-only relative-date fixture with a deterministic POSIX/BusyBox timestamp while preserving the required stale-marker assertion.
|
||||||
|
- Re-run the launcher suite in the canonical `ci-base:latest` Alpine image, then run applicable repository gates and independent review.
|
||||||
|
- Update the PR body to name the repeated GNU-host/Alpine-CI portability pattern, run the mandatory queue guard, push once, verify provider attribution, and stop without CI polling.
|
||||||
|
- Working budget: 8K tokens; scope is one fixture line plus delivery evidence. No production behavior changes.
|
||||||
|
|
||||||
|
### RED-first evidence
|
||||||
|
|
||||||
|
Before the fix, the canonical CI image command
|
||||||
|
`docker run --rm -v "$PWD:/work" -w /work git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||||
|
exited 1 at the stale-marker setup with exact BusyBox output
|
||||||
|
`touch: invalid date '10 seconds ago'`. The prior fresh-marker assertions had already executed, matching pipeline 2233's failure location.
|
||||||
|
|
||||||
|
### Root cause and fix
|
||||||
|
|
||||||
|
The test used GNU `touch -d` relative-date parsing although the PR workflow runs on Alpine/BusyBox. The fixture now uses POSIX `touch -t 200001010000.00`, a fixed timestamp that is unconditionally stale; the stale assertion remains mandatory and was not made tolerant of missing timestamp metadata.
|
||||||
|
|
||||||
|
### Structural pattern
|
||||||
|
|
||||||
|
This is the third GNU-host/Alpine-CI portability defect in the lane: GNU `grep` multi-match counting, Perl-only fixture mutation, and GNU `touch -d` date parsing. The repeated cause is shell suites authored on a GNU host but executed in an Alpine CI image; durable prevention belongs in CI-image execution or portability lint, not assertion weakening.
|
||||||
|
|
||||||
|
### GREEN and quality evidence
|
||||||
|
|
||||||
|
- Focused launcher suite in `ci-base:latest`: exit 0, `ok - start-agent-session generated environment boundary`.
|
||||||
|
- Canonical test step in `ci-base:latest` with the pipeline's `pgvector/pgvector:pg17` service, readiness check, migration, and `pnpm test`: exit 0; 46/46 Turbo tasks; Mosaic 81/81 files and 1510/1510 tests; Gateway 57 passed/5 skipped files and 629 passed/11 skipped tests; enumeration 49 population / 32 enumerated / 18 signed exclusions / 45 named surfaces.
|
||||||
|
- The first image-only `pnpm test` attempt lacked the pipeline PostgreSQL service and failed only on connection refusal after the launcher suite was GREEN. The rerun supplied the canonical service precondition and passed.
|
||||||
|
- Canonical-image baseline: typecheck 45/45 tasks, lint 25/25 tasks, format check GREEN; `git diff --check` GREEN.
|
||||||
|
- Independent Codex code review: APPROVE, confidence 0.96, 2/2 Round-3 files, no findings.
|
||||||
|
- Independent Codex security review: risk none, confidence 0.99, 2/2 Round-3 files, no findings.
|
||||||
|
|
||||||
|
### Re-derived inventory and denominators
|
||||||
|
|
||||||
|
- Round-3 git delta: **2/2 files** — launcher suite and task scratchpad; 25 insertions / 1 deletion before evidence finalization.
|
||||||
|
- Full PR path inventory against `origin/main` at `85d2108e`: **19/19 changed paths**; Round 3 adds no new PR path.
|
||||||
|
- Workflow definition population: **1/3 pull-request-eligible** (`ci.yml` of `ci.yml`, `ci-image.yml`, `publish.yml`).
|
||||||
|
- Do not re-litigate the settled 311/312 populations; both are valid for their separately named Tess6 and CRUD-core7 sets.
|
||||||
|
|
||||||
|
## Round 4 — bound stale-marker observation
|
||||||
|
|
||||||
|
### Objective and plan
|
||||||
|
|
||||||
|
- Make the heartbeat assertion discriminate an initially stale native marker from a fresh marker without changing the production staleness threshold or shortening the polling window.
|
||||||
|
- Freeze only the sidecar's numeric observation clock during the stale-fixture arm so elapsed assertion time cannot turn a fresh mutant stale.
|
||||||
|
- Prove two independent mutants RED: disable production stale-marker detection while retaining the stale fixture; replace the stale fixture with a fresh marker. Restore the tree and prove GREEN in the canonical Alpine image.
|
||||||
|
- Re-derive the changed-path inventory, run applicable quality and independent review gates, commit with environment-only author/committer identity, queue-guard, push once, verify provider attribution using curl stdin config, and stop without CI polling.
|
||||||
|
- Working budget: 8K tokens. Scope is the launcher test and its scratchpad evidence; production launcher behavior remains unchanged.
|
||||||
|
|
||||||
|
### Root cause and bounded observation
|
||||||
|
|
||||||
|
The 30 × 0.1-second assertion window overlaps the production `now - marker > interval * 2 + 1` threshold at interval 1. Depending on second boundaries and load, a fresh marker can age past the threshold before the assertion ends. A focused pre-fix fresh-mutant attempt returned RED while Review 101's full-suite run returned GREEN; the differing result is itself timing dependence, not a discriminating assertion.
|
||||||
|
|
||||||
|
The test now supplies a fixed numeric epoch only to the stale-fixture sidecar. Its real marker mtime is still read from the filesystem, but assertion runtime cannot advance `now`. Date formatting still delegates to the image's real `/bin/date`. Neither the production threshold nor the 30 × 0.1-second polling window changed.
|
||||||
|
|
||||||
|
### Two-mutant RED / restored GREEN
|
||||||
|
|
||||||
|
All three runs used `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`:
|
||||||
|
|
||||||
|
1. **Stale-detection mutant RED:** replaced only the production stale-age predicate with `false` while retaining the fixed stale marker; suite exit 1 with `FAIL: heartbeat sidecar did not resume after native marker became stale or absent`.
|
||||||
|
2. **Fresh-marker mutant RED:** replaced only `touch -t 200001010000.00` with fresh `touch`; suite exit 1 with the same failed stale-resumption assertion. The fixed observation epoch kept the mutant fresh throughout all 30 polls.
|
||||||
|
3. **Restored tree GREEN:** suite exit 0 with `ok - start-agent-session generated environment boundary`.
|
||||||
|
|
||||||
|
### Re-derived inventory
|
||||||
|
|
||||||
|
- Round-4 delta: **2/2 files** — launcher test plus task scratchpad; production launcher delta is empty.
|
||||||
|
- Full PR inventory against `origin/main`: **19/19 paths**; Round 4 adds no path.
|
||||||
|
- Production stale threshold remains `now - marker > iv * 2 + 1`; assertion polling remains 30 × 0.1 seconds.
|
||||||
|
- Review 101's confirmed enumeration/workflow/CI and attribution evidence is accepted without re-polling or re-derivation.
|
||||||
|
|
||||||
|
## Residual risk
|
||||||
|
|
||||||
|
- Landing on `main` does not update the currently installed host launcher. Host framework installation/reseed and Jarvis live-seat validation are separate downstream events.
|
||||||
|
- Canonical CI result is pending and will not be polled by this seat.
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Scratchpad — FED-M3-06 get verb
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement `POST /api/federation/v1/get/:resource/:id` for M3 inbound federation reads.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- `apps/gateway/src/federation/server/verbs/get.controller.ts`
|
|
||||||
- `apps/gateway/src/federation/server/verbs/get-query.service.ts`
|
|
||||||
- Unit coverage for controller pipeline + query service RBAC guardrails
|
|
||||||
- Register controller/service in `FederationModule`
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Mirror the list verb pipeline: `FederationAuthGuard` → `FederationScopeService` → read-only query service.
|
|
||||||
2. Return one `_source: "local"` tagged item on success.
|
|
||||||
3. Return federation error envelopes:
|
|
||||||
- `404 not_found` when the resource id does not exist.
|
|
||||||
- `403 scope_violation` when the row exists but falls outside native RBAC/scope intersection.
|
|
||||||
- `400 invalid_request` for malformed ids/scope requests.
|
|
||||||
4. Keep read audit persistence deferred to M4; no body or response persistence in M3.
|
|
||||||
|
|
||||||
## Verification Evidence
|
|
||||||
|
|
||||||
- Rebased onto `origin/main` at `86e106fcc9a1dfa3a18f7846bb477be128794aad` after M3-05 merged; resolved `FederationModule` by registering both list and get verb controllers/services.
|
|
||||||
- Review-change coverage added for comment 15971:
|
|
||||||
- get note access now requires subject ownership AND authorized mission intersection.
|
|
||||||
- missing federation context returns structured `401 unauthorized` envelope.
|
|
||||||
- unsupported get resources fail closed with structured denial.
|
|
||||||
- PGlite regressions cover cross-user note exclusion and subject-note unauthorized-mission exclusion.
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/verbs/__tests__/get.controller.spec.ts src/federation/server/verbs/__tests__/get-query.service.spec.ts` — pass (2 files / 17 tests; re-run after review changes).
|
|
||||||
- `pnpm --filter @mosaicstack/gateway build` — pass (re-run after review changes).
|
|
||||||
- `pnpm build` — pass (23 successful tasks before review changes).
|
|
||||||
- `pnpm typecheck` — pass (41 successful tasks; re-run after review changes).
|
|
||||||
- `pnpm lint` — pass (23 successful tasks; re-run after review changes).
|
|
||||||
- `pnpm format:check` — pass (re-run after review changes).
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings after review changes.
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# B1 / @next Durable Publish Pipeline — Design
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Make `next` a durable integration line that publishes the artifacts required by downstream federation boot tests without manual builds.
|
|
||||||
|
|
||||||
Every merge to `next` publishes:
|
|
||||||
|
|
||||||
1. **npm prerelease packages** to the Gitea npm registry with dist-tag `next`.
|
|
||||||
2. **Gateway container image** tagged only as `gateway:sha-<short>`.
|
|
||||||
|
|
||||||
The existing stable release behavior remains isolated to `main` / tags.
|
|
||||||
|
|
||||||
## Registry verification
|
|
||||||
|
|
||||||
Target registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`.
|
|
||||||
|
|
||||||
Pre-implementation checks:
|
|
||||||
|
|
||||||
- `npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json` returned a dist-tags object (`latest: 0.0.48`).
|
|
||||||
- `npm view @mosaicstack/mosaic@latest version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/` resolved `0.0.48`.
|
|
||||||
- `@next` currently returns 404 because no `next` dist-tag exists yet; this is expected before the first next prerelease publish.
|
|
||||||
|
|
||||||
Pipeline design includes a post-publish verification that `npm view @mosaicstack/mosaic@next version` resolves to the exact CI-computed prerelease version. If Gitea fails to honor the `next` dist-tag, the pipeline fails closed.
|
|
||||||
|
|
||||||
## Version scheme
|
|
||||||
|
|
||||||
The prerelease version is computed at publish time only; no `package.json` version changes are committed.
|
|
||||||
|
|
||||||
For each non-private `@mosaicstack/*` package:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<target-stable>-next.<CI_PIPELINE_NUMBER>
|
|
||||||
```
|
|
||||||
|
|
||||||
Where:
|
|
||||||
|
|
||||||
- `CI_PIPELINE_NUMBER` is Woodpecker's monotonic pipeline number.
|
|
||||||
- `target-stable` is the package's current committed stable version with the patch component incremented.
|
|
||||||
- Example: `@mosaicstack/mosaic` `0.0.48` publishes as `0.0.49-next.1626`.
|
|
||||||
- Example: `@mosaicstack/gateway` `0.0.6` publishes as `0.0.7-next.1626`.
|
|
||||||
|
|
||||||
Rationale:
|
|
||||||
|
|
||||||
- npm semver sorts `0.0.49-next.1627` above `0.0.49-next.1626`.
|
|
||||||
- The prerelease does not overtake the future stable `0.0.49`.
|
|
||||||
- The monotonic pipeline number avoids conflicts across repeated `next` merges.
|
|
||||||
|
|
||||||
## Branch and tag guardrails
|
|
||||||
|
|
||||||
| Pipeline path | Branch/event | Publishes | Forbidden |
|
|
||||||
| --------------------- | ------------------------------ | ------------------------------------------------------- | ---------------------- |
|
|
||||||
| stable npm publish | `main` push/manual or tag | package versions already committed in package manifests | `@next` dist-tag |
|
|
||||||
| next npm publish | `next` push/manual only | CI-computed prereleases with `--tag next` | `latest` dist-tag |
|
|
||||||
| gateway image | `main` push/manual or tag | `sha-<short>` + `latest` on main + tag on tag events | next prerelease npm |
|
|
||||||
| gateway image | `next` push/manual only | `sha-<short>` only | `latest` |
|
|
||||||
| appservice/web images | `main` push/manual or tag only | existing stable image behavior | next image publication |
|
|
||||||
|
|
||||||
The pipeline has explicit branch checks inside the publish commands as a second fail-closed layer beyond Woodpecker `when` clauses.
|
|
||||||
|
|
||||||
## Implementation plan
|
|
||||||
|
|
||||||
1. Widen `.woodpecker/publish.yml` top-level `when` to include `next` so the publish pipeline runs on next merges.
|
|
||||||
2. Keep existing `publish-npm` on `main` / tags only.
|
|
||||||
3. Add `publish-next-npm` for `next` push/manual only:
|
|
||||||
- configure Gitea npm auth from existing `gitea_token` secret as `NPM_TOKEN`;
|
|
||||||
- preflight registry dist-tag metadata;
|
|
||||||
- compute prerelease versions in CI by temporarily editing package manifests in the workspace;
|
|
||||||
- run `pnpm publish ... --tag next` against non-private `@mosaicstack/*` packages;
|
|
||||||
- verify `@mosaicstack/mosaic@next` resolves to the computed version.
|
|
||||||
4. Split image `when` anchors:
|
|
||||||
- `image_build_when` includes `next` and is used by `build-gateway`;
|
|
||||||
- `main_image_build_when` keeps appservice/web on main/tags only.
|
|
||||||
5. Keep gateway next image destinations to `sha-<short>` only; no `latest` on next.
|
|
||||||
|
|
||||||
## Risk controls
|
|
||||||
|
|
||||||
- Auth/registry failures are fatal.
|
|
||||||
- No manual image build/push path is introduced.
|
|
||||||
- No production `latest` tags are touched from `next`.
|
|
||||||
- No `@latest` npm dist-tags are touched from `next`.
|
|
||||||
- All changes live in CI config and docs; no runtime source behavior changes.
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# B2 — Fresh-install skills sync path
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Greenfield wizard on `next` reported:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Skills sync script not found at ~/.config/mosaic/bin/mosaic-sync-skills
|
|
||||||
Skills: install failed
|
|
||||||
```
|
|
||||||
|
|
||||||
## Diagnosis
|
|
||||||
|
|
||||||
The framework install migration removed the legacy `~/.config/mosaic/bin/` directory and now installs framework helper scripts under:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/.config/mosaic/tools/_scripts/
|
|
||||||
```
|
|
||||||
|
|
||||||
`packages/mosaic/src/stages/finalize.ts` still resolved wizard helper scripts from `mosaicHome/bin`, so wizard-selected skills failed even though `mosaic-sync-skills` was present in the current framework layout.
|
|
||||||
|
|
||||||
## Fix
|
|
||||||
|
|
||||||
- Resolve framework helper scripts through `tools/_scripts/<name>` first.
|
|
||||||
- Keep a legacy `bin/<name>` fallback for pre-migration installs.
|
|
||||||
- Point missing-script warnings at the current `tools/_scripts` layout.
|
|
||||||
- Update the finalize skills test fixture to model the fresh framework layout.
|
|
||||||
- Update framework README examples from legacy `bin/` helper paths to `tools/_scripts/`.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- Unit: `pnpm --filter @mosaicstack/mosaic test -- finalize-skills`
|
|
||||||
- Gates: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build`
|
|
||||||
- Fresh path: ran `packages/mosaic/framework/install.sh` with a temp `MOSAIC_HOME` and `MOSAIC_SYNC_ONLY=1`; verified `tools/_scripts/mosaic-sync-skills` exists, legacy `bin/mosaic-sync-skills` does not, and the script installs a selected fake `lint` skill into Mosaic + Pi runtime skill directories.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# B3 — Wizard completion ordering
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The wizard printed the success summary / `Mosaic is ready.` during `finalizeStage`, before the gateway configuration stage had completed its daemon health check. If the gateway health gate later failed, the user could see a success claim followed by a gateway failure.
|
|
||||||
|
|
||||||
## Diagnosis
|
|
||||||
|
|
||||||
`finalizeStage` handled both mutation work and terminal success messaging. Wizard paths then ran `gatewayConfigStage` and `gatewayBootstrapStage` afterward:
|
|
||||||
|
|
||||||
1. finalize writes config, links runtime assets, syncs skills, runs doctor;
|
|
||||||
2. finalize prints `Installation Summary` + `Mosaic is ready.`;
|
|
||||||
3. gateway config starts/waits for daemon health;
|
|
||||||
4. gateway bootstrap runs.
|
|
||||||
|
|
||||||
The summary needed to be deferred until after the gateway readiness gates.
|
|
||||||
|
|
||||||
## Fix
|
|
||||||
|
|
||||||
- `finalizeStage` now returns a `showSummary()` callback and supports `deferSummary`.
|
|
||||||
- Wizard/quick-start paths call finalize with `deferSummary: true`.
|
|
||||||
- `showSummary()` is called only after gateway config reports ready and bootstrap completes, or immediately when the caller explicitly skips gateway setup.
|
|
||||||
- If gateway health/config reports not ready, the wizard returns/aborts without printing the success summary.
|
|
||||||
- Folded in adjacent runtime install hint fix for Pi: `curl -fsSL https://pi.dev/install.sh | sh`.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- Added unified-wizard coverage for summary-after-health and no-summary-on-health-failure.
|
|
||||||
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard finalize-skills`
|
|
||||||
- `pnpm format:check`
|
|
||||||
- `pnpm typecheck`
|
|
||||||
- `pnpm lint`
|
|
||||||
- `pnpm build`
|
|
||||||
- `pnpm test`
|
|
||||||
- Codex code review: approve.
|
|
||||||
- Codex security review: one low finding on the requested Pi `curl | sh` install hint; no security finding in the wizard completion-ordering change.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# B4 — Wizard step deduplication
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Greenfield wizard testing showed completed wizard steps could be executed again after the menu marked them `[done]`. In practice this made the Providers/API-key flow and Skills flow appear twice in one wizard run.
|
|
||||||
|
|
||||||
There was a second related API-key duplication path: when the Providers step was completed with no key, `gatewayConfigStage` still prompted for `ANTHROPIC_API_KEY` during Finish because it only skipped the gateway API-key prompt when `providerKey` was non-empty.
|
|
||||||
|
|
||||||
## Diagnosis
|
|
||||||
|
|
||||||
- `runMenuLoop` labeled completed sections with `[done]`, but still dispatched the selected step again if the user selected that row.
|
|
||||||
- Quick Start ran Providers and Skills but did not mark those sections complete in `completedSections`.
|
|
||||||
- `runFinishPath`/`quickStartPath` defaulted `providerType` to `none` for gateway config, which made it impossible for `gatewayConfigStage` to distinguish:
|
|
||||||
- provider step completed and user intentionally skipped the key, vs.
|
|
||||||
- provider step was never run.
|
|
||||||
|
|
||||||
## Fix
|
|
||||||
|
|
||||||
- Added a shared menu section key helper and a completed-step guard in `runMenuLoop`.
|
|
||||||
- Completed menu steps now log a skip message instead of re-running their stage.
|
|
||||||
- Quick Start marks Providers and Skills complete after running them.
|
|
||||||
- Finish/Quick Start now pass `state.providerType` as-is to gateway config instead of defaulting to `none`.
|
|
||||||
- `gatewayConfigStage` treats `providerType: 'none'` as an explicit completed provider setup with no key and skips the second gateway API-key prompt.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- Added unified wizard regression coverage asserting repeated Providers/Skills menu selections only execute each stage once.
|
|
||||||
- Added gateway config coverage asserting `providerType: 'none'` does not prompt for a gateway API key and writes no API key env var.
|
|
||||||
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard gateway-config`
|
|
||||||
- `pnpm format:check`
|
|
||||||
- `pnpm typecheck`
|
|
||||||
- `pnpm lint`
|
|
||||||
- `pnpm build`
|
|
||||||
- `pnpm test`
|
|
||||||
- Codex code review: approve.
|
|
||||||
- Codex security review: no findings.
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# FED-M3-10 — Federation M3 Integration Tests
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Add single-gateway gateway integration tests for M3 acceptance #6 and #7.
|
|
||||||
|
|
||||||
## Branch / base
|
|
||||||
|
|
||||||
- Branch: `feat/federation-m3-integration`
|
|
||||||
- Base: `origin/next` (`838701bd` after M3-06/#683 merge)
|
|
||||||
- PR base when unblocked: `next`
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Real PostgreSQL via `@mosaicstack/db`.
|
|
||||||
- Mocked TLS context / Fastify request shim for `FederationAuthGuard`.
|
|
||||||
- Direct controller calls using the real M3 route contract: `POST /api/federation/v1/list/:resource` with body `{ limit?, cursor? }`.
|
|
||||||
- Gated by `FEDERATED_INTEGRATION=1`.
|
|
||||||
- No federation harness dependency.
|
|
||||||
|
|
||||||
## Fixture notes
|
|
||||||
|
|
||||||
Aligned with the B2 seed design vocabulary:
|
|
||||||
|
|
||||||
- `tasks` visibility uses personal `projects` + `missions` chain.
|
|
||||||
- `notes` are `mission_tasks.notes`; the integration suite asserts subject-only note visibility on an authorized mission.
|
|
||||||
- Seed includes a second user and unauthorized team/project tasks to prove exclusion from the max-row-cap list result.
|
|
||||||
- Grants/peers are direct DB fixtures; cert auth still runs through `FederationAuthGuard` using real X.509 certs generated by existing test helpers.
|
|
||||||
|
|
||||||
## Current implementation
|
|
||||||
|
|
||||||
Added `apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts` covering:
|
|
||||||
|
|
||||||
1. M3 #6 — cert missing Mosaic OIDs returns 401 federation `unauthorized` envelope.
|
|
||||||
2. M3 #6 — valid cert whose grant row is `revoked` returns 403 federation `forbidden` envelope.
|
|
||||||
3. M3 #7 — active grant with `max_rows_per_query: 2` caps `list tasks`, returns `_truncated` + `nextCursor`, source-tags rows, and excludes other-user / unauthorized-team tasks.
|
|
||||||
4. Cross-user notes invariant — subject can list their own `mission_tasks.notes` row while another user's note on the same authorized mission is excluded.
|
|
||||||
5. Unsupported-resource invariant — `list widgets` fails closed with a federation `scope_violation` envelope.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/types build` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/db build` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/storage build` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/brain build` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/queue build` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/config build` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/auth build` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS skipped when `FEDERATED_INTEGRATION` unset (5 skipped).
|
|
||||||
- `FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS (5 tests) after local `docker compose up -d postgres` + `pnpm --filter @mosaicstack/db db:push`.
|
|
||||||
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
|
|
||||||
- `pnpm format:check` — PASS.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS; approve, no findings.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS; risk level none, no findings.
|
|
||||||
|
|
||||||
## Push / PR
|
|
||||||
|
|
||||||
- #683 landed in `next`; branch rebased onto `origin/next` before push.
|
|
||||||
- CI is serialized; run queue guard before push.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Installer `--next` fast npm lane — 2026-06-25
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
Flip `tools/install.sh --next` from source-build-first to fast npm `@next` first, with source fallback.
|
|
||||||
|
|
||||||
## Registry reality check
|
|
||||||
|
|
||||||
Gitea npm registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`
|
|
||||||
|
|
||||||
Verified before implementation:
|
|
||||||
|
|
||||||
- `@mosaicstack/mosaic@next` resolves to `0.0.49-next.1633`.
|
|
||||||
- `@mosaicstack/gateway@next` resolves to `0.0.7-next.1633`.
|
|
||||||
- `@mosaicstack/gateway` dist-tags include `latest: 0.0.6` and `next: 0.0.7-next.1633`.
|
|
||||||
- `apps/gateway/package.json` is non-private and has Gitea npm `publishConfig`.
|
|
||||||
|
|
||||||
Conclusion: the installer can fast-install both CLI and gateway npm packages for `--next`. The gateway Docker `gateway:sha-<short>` remains the deployment/harness artifact; the npm gateway package is valid for the installer global package path.
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
- `--next` with no explicit ref:
|
|
||||||
1. framework archive from `next`;
|
|
||||||
2. resolve `@mosaicstack/gateway@next` and `@mosaicstack/mosaic@next`;
|
|
||||||
3. require both resolved versions to share the same `next.<pipeline>` suffix;
|
|
||||||
4. install the exact resolved package versions;
|
|
||||||
5. set `MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1` so wizard does not overwrite the prerelease gateway;
|
|
||||||
6. if either package is missing/unreachable/mismatched/fails, fall back to existing source build at `next`.
|
|
||||||
- `--dev` remains pure source build.
|
|
||||||
- explicit `--ref` / `MOSAIC_REF` still wins over `--next` and uses the source path for that exact ref.
|
|
||||||
|
|
||||||
## Install detail
|
|
||||||
|
|
||||||
The installer writes the scoped npmrc mapping (`@mosaicstack:registry=...`) and then runs npm install without overriding npm's default registry. Passing `--registry=<gitea>` to `npm install` forces public transitive dependencies (for example `@anthropic-ai/sdk`) to resolve from Gitea and breaks the fast path; the scoped npmrc mapping is the correct split-registry behavior.
|
|
||||||
|
|
||||||
## Verification notes
|
|
||||||
|
|
||||||
- Added `tools/install-next-lane.test.sh` with a fake npm/source harness for exact-version fast install, registry failure source fallback, explicit-ref precedence, and mismatched suffix warning.
|
|
||||||
- Wired the installer harness into `pnpm test` via `pnpm run test:installer`.
|
|
||||||
- Real temp-prefix fast install succeeded with `@mosaicstack/[email protected]` and `@mosaicstack/[email protected]`.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# Scratchpad — installer `--next` lane
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Add a prerelease installer lane for the permanent `next` integration branch.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- `tools/install.sh`
|
|
||||||
- README/install documentation
|
|
||||||
- Follow-up design note for future npm `@next` prerelease publishing
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Add `--next` and `MOSAIC_NEXT=1` as source-build shorthand for `next`.
|
|
||||||
2. Preserve explicit ref precedence: `MOSAIC_REF` and `--ref` win over `--next`.
|
|
||||||
3. Update installer source display/help text.
|
|
||||||
4. Document three lanes:
|
|
||||||
- stable npm `@latest`
|
|
||||||
- prerelease `--next`
|
|
||||||
- contributor `--dev --ref X`
|
|
||||||
5. Run shell and repo gates locally, then hold before push/PR until runner serialization greenlight.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `bash -n tools/install.sh` — pass.
|
|
||||||
- `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable tools/install.sh` — pass.
|
|
||||||
- `bash tools/install.sh --check --framework --next` — source display shows `ref: next, --next prerelease lane`.
|
|
||||||
- `bash tools/install.sh --check --cli --next --ref feature-x` — source display shows explicit ref wins.
|
|
||||||
- `MOSAIC_NEXT=1 MOSAIC_REF=feature-env bash tools/install.sh --check --cli` — source display shows explicit env ref wins.
|
|
||||||
- `pnpm install --frozen-lockfile --prefer-offline --store-dir /home/jarvis/.local/share/pnpm/store` — pass (local override for repo `.npmrc` CI store path).
|
|
||||||
- `pnpm typecheck` — pass (41 successful tasks).
|
|
||||||
- `pnpm lint` — pass (23 successful tasks).
|
|
||||||
- `pnpm format:check` — pass.
|
|
||||||
- `bash tools/e2e-install-test.sh` — attempted; current baseline fails during gateway health after stable registry install because Valkey is unavailable in the clean container. The `tools/install.sh --yes --no-auto-launch` stage itself completed before the downstream gateway verification failure.
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# PR merge squash message field
|
||||||
|
|
||||||
|
- **Charter:** `/home/hermes/agent-work/CHARTER-PRMERGE-MESSAGE-FIELD.md`
|
||||||
|
- **Owner:** `be-coder-08`
|
||||||
|
- **Branch:** `fix/pr-merge-message-field`
|
||||||
|
- **Base:** remote `main` / local `origin/main` at `85d2108e4ed15c744ad3b87a5b629e7b2d39405a`
|
||||||
|
- **Estate:** HOMELAB tooling shared by HOMELAB and USC
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genuine multi-author PRs retain non-poster branch authors without weakening hardcoded squash behavior.
|
||||||
|
|
||||||
|
## Binding requirements
|
||||||
|
|
||||||
|
1. `Do` remains hardcoded to `squash`; no provider/repository default may select merge style.
|
||||||
|
2. A verified trailer uses a PR commit's linked `author.login` and that same commit's author email. No `/users/{login}` primary-email lookup occurs. Recorded rationale: this asks only what the provider can answer.
|
||||||
|
3. A commit with `author.login` null blocks before merge, prints both the null provider fact and commit email fact, and names the escalation principal.
|
||||||
|
4. The BLOCK arm must be observed firing; a normal canonical single-author API payload remains explicit squash plus its reviewed `head_commit_id`.
|
||||||
|
5. Every provider mutation is read back from the provider; no real PR is merged during tests.
|
||||||
|
|
||||||
|
## Derived interface decisions
|
||||||
|
|
||||||
|
- Add `--co-author-trailers` rather than accepting arbitrary message text. The wrapper enumerates PR commits and constructs trailers, making an unchecked `Co-authored-by` line unexpressible.
|
||||||
|
- Require `--escalate-to PRINCIPAL` with `--co-author-trailers`, so the BLOCK diagnostic always names a principal rather than a generic role.
|
||||||
|
- Do not expose `MergeTitleField` separately. When trailers exist, set it from the provider PR title and set `MergeMessageField` only to construction-generated trailers. This preserves one provider source for the title and avoids an unrelated caller-controlled degree of freedom.
|
||||||
|
- Preserve first-commit order and emit one trailer per distinct non-poster `author.login`, using that first linked commit's own email.
|
||||||
|
|
||||||
|
## Canonical delivery plan
|
||||||
|
|
||||||
|
1. Port the capability into the installed source of truth, `packages/mosaic/framework/tools/git/pr-merge.sh`; do not retain `infra/fleet/tools/git` as a second copy.
|
||||||
|
2. Preserve canonical `--expect-head`, exact head branch/repository/SHA queue inspection, Gitea atomic head pinning, GitHub `--match-head-commit`, and delete-after-merge semantics.
|
||||||
|
3. Do not port the deployed-only `--skip-queue-guard` bypass. Add the focused harness to the canonical framework-shell suite and re-establish RED/GREEN on the packaged baseline.
|
||||||
|
4. Deliver through a reviewed package release followed by `mosaic update` with its default framework reseed. The installer snapshots, manifest-syncs framework-owned `tools/**`, and rolls back on failure.
|
||||||
|
5. Before either estate relies on the change, require installed/package hash equality, `MergeMessageField` presence, and a green focused harness. Release/reseed ownership is currently unassigned and blocks activation after source merge.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- RED against the byte-identical deployed baseline (`sha256 08a65e8584c5…`): rc 1 with eight named failures. The wrapper rejected `--co-author-trailers`; the null-login path emitted none of the required BLOCK facts/principal; and both verified/ordinary API paths failed the stdin-config credential assertion (ordinary path exposed the fixture token through curl argv). Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-red.log`.
|
||||||
|
- GREEN on the deployed-baseline candidate: verified linked multi-author payload, null-login BLOCK, required named principal, explicit squash, stdin-config token transport, and absence of `/users` lookup all passed. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-green.log`.
|
||||||
|
- RED against canonical packaged baseline `c581ef48…`: rc 1 with 32 assertions. It rejects the new option, and the first harness version did not satisfy canonical head branch/repository/SHA metadata. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-packaged-baseline-red.log`. The port adapts the fixture rather than weakening canonical head controls.
|
||||||
|
- Provider capability probe against `git.mosaicstack.dev`: authenticated `be-coder-08` POST to deliberately nonexistent PR `2147483647` with both message fields returned JSON HTTP 404; the unauthenticated same request returned JSON HTTP 401 (not the charter's predicted 403). The authenticated-vs-unauthenticated differential proves write authorization resolved while no mergeable subject existed. `tl-mosaic` ruled the literal non-load-bearing: preserve the observed 404/401 pair and do not manufacture a 403 case. No cause was inferred and no real PR was targeted.
|
||||||
|
- Provider-generated trailer behavior is not treated as exclusive or absent. The wrapper's VERIFIED/BLOCK decision binds each requested non-poster trailer to commit `author.login` plus that commit's email; it does not assume `MergeMessageField` is the squash's only trailer source. The poster is omitted from the constructed list because the resulting squash author already records the poster; any additional provider-generated trailer is outside this change's unmeasured mechanism.
|
||||||
|
- An early candidate SHA-256 `5de32876990e4f26920448cb3220cc7f1146d558b4dd2bc1ee1a2abee2f2cbe6` passed the initial harness, then author-side review found credential-fallback and argv-exposure defects. The live deployed wrapper was atomically restored to baseline SHA-256 `08a65e8584c52c6d41ea1c686f8b95585c21e4b37320a2447eba09359a0e02c1`; the remediated candidate remains only in the worktree.
|
||||||
|
|
||||||
|
## Remediation and current review state
|
||||||
|
|
||||||
|
1. Token and Basic Auth now use stdin curl configuration, not argv. PR title, contributor email, and the JSON payload also remain out of child argv.
|
||||||
|
2. Each credential attempt binds commit inspection and merge. A token failure during either inspection or mutation causes Basic fallback to repeat inspection before mutation; the payload pins the inspected `head_commit_id`.
|
||||||
|
3. Focused tests cover token-resolution fail-closed behavior, both HTTP-401 fallback seams, metadata/credential argv absence, null-login BLOCK, explicit squash, canonical reviewed-head binding, unchanged ordinary payload, and retained log-safe provider diagnostics. Token-resolution RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-token-resolution-red.log`.
|
||||||
|
4. Codex review rounds 3–5 requested retained provider error text, log-safe provider diagnostics, fail-closed credential fallback, stable value-option parsing, and PR-title trailer-injection prevention. These are remediated with regression assertions. A post-remediation independent review is still required.
|
||||||
|
5. **Accepted linkage limitation:** `author.login` resolution proves that the commit address maps to a registered provider account. It does not prove that the named principal authored the commit because Git author metadata is self-asserted. This gate checks attribution linkage, not authorship; commit signing is out of scope and currently unadopted. Coordinators explicitly ruled that this does not add a third state.
|
||||||
|
6. Codex's sandbox could not execute the harness because its checkout was read-only; that environmental limitation is recorded separately from host-side test results.
|
||||||
|
|
||||||
|
## Disposable provider fixture acceptance
|
||||||
|
|
||||||
|
- Use a retained scratch repository only, with two branch authors and `author != committer` on at least one commit.
|
||||||
|
- Arm A supplies a message-field trailer for one non-poster; record whether that value lands without forcing the partial-pair result into under-specified `APPENDS`/`REPLACES` labels. Demonstrate an absence control.
|
||||||
|
- Arm B includes a registered trailer for a different non-poster on a branch commit; record whether it survives or drops. Verify identity through an existing commit whose `author.login` resolves and demonstrate an absence control.
|
||||||
|
- Parse landed trailers key-agnostically with `^[A-Za-z-]+-[Bb]y:` and record generated poster pair presence/absence plus resulting poster attribution.
|
||||||
|
- Record `/users/<login>` status and raw email only as non-gating estate telemetry. Never read `active`, `visibility`, or any profile field as an identity gate.
|
||||||
|
- Use distinct principals: poster `be-coder-08`, merger `Mos`, Arm A `be-coder-07`, and Arm B `be-coder-06`. Capture every trailer-shaped line verbatim and in order. Zero trailer lines means the generator did not fire and the run is `VOID`, not evidence that either arm dropped.
|
||||||
|
- Report the same read-back evidence to `mos-claude` on socket `default` and `tl-mosaic` on socket `mosaic-fleet`. Report values rather than mechanism inferences and stop on any poster-attribution regression.
|
||||||
|
|
||||||
|
## Fixture preflight
|
||||||
|
|
||||||
|
- Retained public repository: `mosaicstack/prmerge-trailer-fixture`; PR `#1`, posted by `be-coder-08` and reserved for merge by `Mos`.
|
||||||
|
- Existing `mosaicstack/stack` commits resolve `be-coder-07` and `be-coder-06` through `author.login`; exact addresses are `[email protected]` and `[email protected]`.
|
||||||
|
- Non-gating HOMELAB telemetry for authenticated reader `be-coder-08`: `/api/v1/users/be-coder-06` returned HTTP 200 with raw `email` value `[email protected]`.
|
||||||
|
- Provider preflight showed PR commit enumeration is newest-first. A new RED test proved that deriving `head_commit_id` from the final array element selected the wrong commit. The candidate now reads `.head.sha` from the authenticated PR endpoint before enumeration, verifies it appears in the commit set, and atomically pins that SHA in the explicit squash payload. RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-head-order-red.log`.
|
||||||
|
- Fixture PR head `f6ba6e5105031fa21f5ff7bd8e4379d99c16e1de` has `author.login=be-coder-07`, `committer.login=be-coder-08`, and branch-message trailer `Co-authored-by: be-coder-06 <[email protected]>`.
|
||||||
|
|
||||||
|
## Fixture result
|
||||||
|
|
||||||
|
- `Mos` merged retained fixture PR `#1` through staged candidate SHA-256 `60e779a85fd13b729d859ea7c986d1e9b1641b97991611329226c1b3113ffb6e`; resulting squash commit: `3f550715d9bc716426fd355a65fe997b3a90fa7d` with one parent.
|
||||||
|
- Provider read-back: poster/commit author `be-coder-08`, committer/merger `Mos`. The run is non-void.
|
||||||
|
- Trailer-shaped lines, verbatim and in order:
|
||||||
|
1. `Co-authored-by: be-coder-07 <[email protected]>`
|
||||||
|
2. `Co-authored-by: be-coder-08 <[email protected]>`
|
||||||
|
- Arm A supplied field value (`be-coder-07`) landed. Arm B branch trailer (`be-coder-06`) dropped. Both fabricated absence controls remained absent. No `Co-committed-by:` line landed.
|
||||||
|
- The candidate payload construction explicitly excludes the poster and supplied only the Arm A `be-coder-07` line. Therefore the landed poster line was provider-generated, not candidate-composed. The raw result supports `FIELD LANDS`, `BRANCH DROPS`, and `POSTER GENERATED`; it does not support a claim that candidate code supplied the poster. Evidence: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-fixture-readback.log` and the retained provider object.
|
||||||
|
- Retained fixture PR `#2` measured the N=2 shape needed by `#1030`: supplied `be-coder-07` then `be-coder-06`; both landed in that order, followed by the provider-generated poster line. No truncation or dedup occurred at N=2. Resulting squash: `39db9d13aed0…`.
|
||||||
|
|
||||||
|
## Current hold point
|
||||||
|
|
||||||
|
PR `mosaicstack/stack#1066` is open. Its first frozen head `f4b162fa…` was terminal-green in Woodpecker `mosaic` pipeline `#2225`, but that evidence becomes stale when the canonical port moves the head. The deployed wrapper remains baseline `08a65e85…`; no manual copy will occur. Canonical port tests, commit amendment, rebase, one guarded force-with-lease, exact-head CI, and new independent review remain. Even after source merge, activation remains blocked on an assigned package-release/reseed owner and installed-byte read-back.
|
||||||
|
|
||||||
|
## Security review 96 remediation
|
||||||
|
|
||||||
|
Exact reviewed predecessor head: `1ceb11058f64dd7f4a817ceb2124f980a1c4dd23`.
|
||||||
|
|
||||||
|
RED-first focused harness produced 10 named failures: all curl calls lacked size/time/connect bounds; raw ESC email reached mutation; oversized and stalled curl failures were discarded and reached mutation; nonempty Basic output with resolver rc 91 authorized mutation.
|
||||||
|
|
||||||
|
Security remediation:
|
||||||
|
|
||||||
|
- Removed the cross-principal HTTP-401 Basic fallback. Both inspection-401 and merge-401 paths now refuse without Basic resolution or mutation; `get_gitea_basic_auth` references in the merge subject are 0.
|
||||||
|
- Applied `--max-filesize`, `--max-time`, and `--connect-timeout` to all 3/3 provider curl sites and fail closed on curl transport rc at all 3/3 sites.
|
||||||
|
- Required linked email bytes to be ASCII and printable before constructing `MergeMessageField`; guarded construction sites 1/1.
|
||||||
|
|
||||||
|
GREEN: message-field, exact-head, empty-UID/API, queue branch/repository/SHA, bash syntax, ShellCheck, and diff check pass. R7 total-removal mutants went RED: email guard 3 rows; bound switches 1 row; transport-rc guards 4 rows; HTTP-401 refusal 3 rows. R7 bound: mutants prove total removal only; explicit denominators above prove site coverage.
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
# WebUI Fleet Bridge Planning Scratchpad
|
|
||||||
|
|
||||||
**Mode:** Task 0 docs-only decision PR authorized; Task 1 and all executable/runtime work remain blocked.
|
|
||||||
**Owner:** Velma
|
|
||||||
**Opened:** 2026-08-09
|
|
||||||
**Scope:** One enrolled Agent Host launching one native `mosaic claude` OAuth session and streaming it into `apps/web` through `apps/gateway`.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Turn the approved direction in `jarvis-brain/docs/scratchpads/MOSAIC-WEBUI-FLEET-BRIDGE.md` into a test-first implementation plan while preserving Fred's harness-home/launcher contract and Scooby's greenfield safety findings.
|
|
||||||
|
|
||||||
## Source reconciliation
|
|
||||||
|
|
||||||
- Current `origin/main`: `b0f7d26dd9c14d91eaaefc35d6c9fd6618a0bd92`.
|
|
||||||
- Current `origin/next`: `4df478cdd150fdf8d52ea109f02ade5d85017acd`.
|
|
||||||
- Branches currently diverge (`main` has 11 unique commits; `next` has 13). `next` contains local-tier Redis fix #689; `main` contains later fleet/shell fixes.
|
|
||||||
- Fred's three-root harness-home design and promotion stack are not yet fully present on either baseline.
|
|
||||||
- Therefore neither current SHA is an acceptable implementation pin. Code may begin only from a Fred-certified reconciled SHA containing the required launcher/home contract and safe Gateway startup prerequisites.
|
|
||||||
- The deployed `mosaic.woltje.com` v0.0.20 UI remains reference-only.
|
|
||||||
|
|
||||||
## Verified seams
|
|
||||||
|
|
||||||
- Current browser chat uses an in-process Pi SDK session.
|
|
||||||
- `AgentRuntimeProvider` supports list/tree/stream/send/attach/detach/terminate, but not create/start.
|
|
||||||
- `InteractionController` enrolls an already-existing runtime session; it cannot launch one.
|
|
||||||
- Hermes is the only runtime provider registered in Gateway.
|
|
||||||
- Tmux streaming is explicitly unsupported and remains out of scope.
|
|
||||||
- `mosaic claude` is the authoritative launcher and accepts Claude's machine-facing stream-json flags.
|
|
||||||
- Installed discovery version: Claude Code 2.1.226. Target Distrobox version must be independently pinned and certified.
|
|
||||||
|
|
||||||
## Non-negotiable dependencies
|
|
||||||
|
|
||||||
1. Fred approves the machine-facing launcher/seat-home contract before code.
|
|
||||||
2. No WebUI/Gateway direct read of lease broker state, daemon socket, or state files (F-V3).
|
|
||||||
3. No provider OAuth token leaves the Agent Host.
|
|
||||||
4. No local Gateway/Web startup around the KBN/database hold.
|
|
||||||
5. Greenfield work runs in a Debian Distrobox with an isolated home.
|
|
||||||
6. The initial plan PR targets `next`; Fred binds D1–D15 on its exact head before issue/PRD/tracker completion or implementation work.
|
|
||||||
|
|
||||||
## Reproducible evidence
|
|
||||||
|
|
||||||
Run from a clean Stack clone:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git fetch origin main next
|
|
||||||
git rev-parse origin/main origin/next
|
|
||||||
git rev-list --left-right --count origin/main...origin/next
|
|
||||||
rg -n "interface AgentRuntimeProvider|createSession|streamEvents|terminate" \
|
|
||||||
packages/types/src/agent packages/agent/src apps/gateway/src/agent
|
|
||||||
rg -n "AgentService\.prompt|interaction_sessions|createRuntimeTerminationApproval" \
|
|
||||||
apps/gateway/src packages/db/src/schema.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Primary inspected source seams:
|
|
||||||
|
|
||||||
- `packages/types/src/agent/agent-runtime-provider.ts`
|
|
||||||
- `packages/agent/src/{runtime-provider-registry,hermes-runtime-provider,matrix-native-runtime-provider,tmux-fleet-runtime-provider}.ts`
|
|
||||||
- `apps/gateway/src/agent/{runtime-provider-registry.service,interaction.controller,durable-session.repository,durable-session.service}.ts`
|
|
||||||
- `apps/gateway/src/chat/chat.gateway.ts`
|
|
||||||
- `packages/mosaic/src/commands/{launch,interaction}.ts`
|
|
||||||
- `packages/mosaic/src/fleet/generated-env-boundary.ts`
|
|
||||||
- `packages/db/src/schema.ts`
|
|
||||||
- `apps/web/src/app/(dashboard)/chat/page.tsx`
|
|
||||||
|
|
||||||
Planning-only investigation transcripts are local and intentionally uncommitted:
|
|
||||||
|
|
||||||
- `/tmp/velma-plan-stack-surface.txt`
|
|
||||||
- `/tmp/velma-plan-structure.txt`
|
|
||||||
- `/tmp/velma-plan-scooby.txt`
|
|
||||||
- `/tmp/velma-plan-runtime-contract.txt`
|
|
||||||
|
|
||||||
## Source findings that constrain the design
|
|
||||||
|
|
||||||
- `interaction_sessions.id` is the stable primary key; there are no create/policy/enrollment/state columns.
|
|
||||||
- `interaction_outbox` has a unique `(session_id, idempotency_key)` index and only `pending | processing | delivered`.
|
|
||||||
- Baseline `DurableSessionRepository.create()` can replace provider/runtime identity for the same owner; M1 must remove that implicit mutation.
|
|
||||||
- Baseline termination approval is Redis-backed and currently consumes separately from PostgreSQL; M1 therefore needs durable authorization acceptance before destructive token deletion/dispatch.
|
|
||||||
- Existing interaction HTTP base is `/api/interaction/:agentName`; the plan extends it rather than inventing a second route family.
|
|
||||||
- Baseline `launch.ts`/lease launcher still use ambient lookup/literal interpreters. Section 4.1 is non-binding consumer input to Fred's W-F design; W-F's final resolved-launch contract must exist in the certified base before Velma can certify it.
|
|
||||||
- Root `pnpm test` is not KBN-safe: it includes PGlite migration and framework-shell/lease-broker suites.
|
|
||||||
|
|
||||||
## Independent draft review
|
|
||||||
|
|
||||||
Seven adversarial review rounds found and drove explicit fixes for:
|
|
||||||
|
|
||||||
- a candidate resolved-seat consumer descriptor and threat model, now explicitly non-binding input to Fred's W-F-owned launcher design;
|
|
||||||
- one active launch per stable conversation, exact pending/failed encodings, durable pre-dispatch reservation, CAS activation, and crash lookup without a migration;
|
|
||||||
- generation-bound enrollment, command/event revalidation, `SIGHUP` config epochs, and stale-epoch rejection;
|
|
||||||
- Redis/PostgreSQL exact-stop crash safety via non-destructive verify, durable authorization acceptance, atomic claim/`GETDEL`, and same-operation status reconciliation;
|
|
||||||
- sequence gaps/reorder limits, deterministic UUIDv5 completion, and Gateway restart fail-closed behavior;
|
|
||||||
- shared streaming redaction before host ring/transport and again before Gateway persistence/browser;
|
|
||||||
- D2/D12-selected path-free provenance and a separate safe browser DTO—never raw or hashed path strings;
|
|
||||||
- exact F-V3 boundary: bridge has no broker API, while Fred's sealed launcher may enforce broker policy internally;
|
|
||||||
- migration-free focused tests in Tasks 1–8, with live OAuth, repository transaction, Gateway/Web, and Playwright restricted to the Fred/Scooby-certified Task 9 path;
|
|
||||||
- candidate private/public commitment and artifact-binding mechanisms that W-F may accept, simplify, replace, or defer;
|
|
||||||
- JCS event-digest recomputation and equal/different duplicate handling in both accepted and future-buffer states;
|
|
||||||
- a safe browser presentation DTO for host/workspace/seat/persona labels, readiness, and connection state;
|
|
||||||
- explicit `OnApplicationBootstrap` create/stop recovery enumeration with no auto-launch;
|
|
||||||
- all POSIX/Windows/UNC/file/tilde path classes in the streaming redactor and definitive failed-stop response semantics;
|
|
||||||
- the full 14-column canonical task schema, fake-only Task 5 repository tests, migration-free certified Task 9 DB test, server-owned operation correlations/routes, and exact merged-SHA smoke;
|
|
||||||
- per-commit independent review, queue guards, exact-head PR review, squash merge, exact merged-next SHA/CI wait, worktree-bound smoke, issue-state readback, and reviewed tracker-closure PR with its own merged-next CI;
|
|
||||||
- a private DB URL loader captured/exported per session without recording or echoing the credential;
|
|
||||||
- an attached Task 9 implementation branch, provider-filtered exact issue-state readback, and machine-verified smoke JSON binding source/worktree/deployed SHA;
|
|
||||||
- a capped, duplicate-key-rejecting, exact-key/type smoke schema so report extensions or JSON boolean/float coercion cannot smuggle data or fake child-count evidence.
|
|
||||||
|
|
||||||
The plan remains deliberately **decision-PR-ready, not implementation-ready**. Fred authorized only Task 0's initial two-document PR on `next`. He must still return every Section 2 value, replace all `[FRED-GATE]` entries, certify storage/startup, and provide the W-F-dependent `IMPLEMENTATION_BASE_SHA` before Task 0 closes or Task 1 starts.
|
|
||||||
|
|
||||||
## Current status
|
|
||||||
|
|
||||||
- Gitea principal verified as `velma`; helper and API wrapper resolution are fail-closed and correct.
|
|
||||||
- Fred authorized the initial Task 0 docs-only PR in `comms/20260809T094952Z__from-fred__ec0e85.md` and confirmed its `next` lane/W-F descriptor corrections in `comms/20260809T095437Z__from-fred__4ee79d.md`.
|
|
||||||
- Provisional decisions: D1/D8/D9/D10/D13/D14 approved; D4 tool labels exactly `{Read, Grep, Glob}`; D5/D12 provisional; D11 remains a single-operator seam; D2/D3/D6/D7 and `IMPLEMENTATION_BASE_SHA` are `BLOCKED-ON-W-F`.
|
|
||||||
- Awaiting exact plan-PR-head review and Fred's complete D1–D15 binding contract.
|
|
||||||
- No source code, database, deployment, or live runtime changes made.
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
# WebUI Phase P — P2 Data + Auth Scratchpad
|
|
||||||
|
|
||||||
**Task ID:** WEBUI-P2
|
|
||||||
**Tracking ref:** stacked on PR #1143 (`feat/webui-p1-vite-skeleton`); no separate issue specified in the author brief
|
|
||||||
**Branch:** `feat/webui-p2-data-auth`
|
|
||||||
**Started:** 2026-08-09
|
|
||||||
**Role:** P2 author worker (must not modify `docs/TASKS.md`)
|
|
||||||
|
|
||||||
## Original tasking
|
|
||||||
|
|
||||||
> Read `/home/jwoltje/briefs/P2-brief.md` and execute it fully in `/home/jwoltje/src/stack-p1` on branch `feat/webui-p2-data-auth`. Run every listed verification gate, commit locally only, and do not push.
|
|
||||||
|
|
||||||
Container path mapping:
|
|
||||||
|
|
||||||
- Brief: `/home/jwoltje/distrobox-homes/mosaic-dev/briefs/P2-brief.md`
|
|
||||||
- Repo: `/home/jwoltje/distrobox-homes/mosaic-dev/src/stack-p1`
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Make the P1 Vite SPA authenticate against the Mosaic Gateway by same-origin relative paths, replace guest-route placeholders with real login/register/SSO callback pages, and enforce guest/authenticated route guards without modifying the parallel Next app tree except for unavoidable shared-library import fixes.
|
|
||||||
|
|
||||||
## Scope and invariants
|
|
||||||
|
|
||||||
- All SPA HTTP and Socket.IO access remains origin-relative (`/api/...`, `/api/auth/...`, `/chat`).
|
|
||||||
- No `NEXT_PUBLIC_*`, `VITE_*` origin setting, or hard-coded `http://localhost:14242` under `apps/web/src/`.
|
|
||||||
- Verify the Gateway BetterAuth mount path from source before choosing auth-client configuration.
|
|
||||||
- Delete the legacy static SSO-provider discovery module and test; runtime `/api/sso/providers` is canonical.
|
|
||||||
- Preserve the Next build while adding React Router guest pages and session guards.
|
|
||||||
- Never push; stage named files only; do not touch the modified `.mosaic/orchestrator/session.lock`.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Inspect P1 SPA structure, shared libraries, legacy Next auth pages/components, Gateway auth mount, and current test setup.
|
|
||||||
2. Add/adjust tests first for relative API behavior, auth-client origin configuration, relative Socket.IO singleton behavior, and all guard session states; run focused tests and capture expected RED failures.
|
|
||||||
3. Implement shared-library relative networking and remove the legacy SSO-provider module/imports.
|
|
||||||
4. Port login, register, and provider callback pages to React Router and wire routes.
|
|
||||||
5. Implement session-aware guest/auth guards and satisfy focused tests.
|
|
||||||
6. Run focused tests, all brief verification gates, invariant searches, and an independent code/security review; remediate and re-run affected gates.
|
|
||||||
7. Update this scratchpad with evidence, stage named files only, and commit locally.
|
|
||||||
|
|
||||||
## Testing strategy
|
|
||||||
|
|
||||||
- TDD is required because this increment changes authentication/session behavior.
|
|
||||||
- Primary situational evidence: jsdom router guard state tests plus guest-page/auth-flow contract tests already present or added as needed.
|
|
||||||
- Baseline gates: web tests, Vite build, Next build, typecheck, lint, and root format check exactly as listed in the brief.
|
|
||||||
- Browser automation is not required by the brief for P2; if used, it will be headless only.
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
No explicit user token cap was provided. Working soft cap: **30K tokens**, derived from a multi-file auth/frontend increment with tests, dual builds, review, and remediation. Keep implementation within the brief; no unrelated refactors or dependencies.
|
|
||||||
|
|
||||||
## Progress / evidence
|
|
||||||
|
|
||||||
- [x] Loaded mission protocol, active MVP manifest/scratchpad, top-level tasks, PRD, relevant frontend/auth/testing/type/review guides, and matching skills.
|
|
||||||
- [x] Resolved host paths to the Distrobox-mapped repo and brief.
|
|
||||||
- [x] Confirmed branch `feat/webui-p2-data-auth`, stacked at P1 commit `068d0f9b`.
|
|
||||||
- [x] Source and Gateway mount inspection complete.
|
|
||||||
- [x] RED tests observed.
|
|
||||||
- [x] Implementation complete.
|
|
||||||
- [x] Independent review complete and findings remediated.
|
|
||||||
- [x] Verification gates complete.
|
|
||||||
- [x] Local implementation commit created: `46d68e1f`.
|
|
||||||
|
|
||||||
### Source decisions
|
|
||||||
|
|
||||||
- Gateway source mounts BetterAuth at `/api/auth/` in `apps/gateway/src/auth/auth.controller.ts`; `packages/auth/src/auth.ts` configures `basePath: '/api/auth'`.
|
|
||||||
- BetterAuth 1.5.5 defaults its browser client to `/api/auth` when `baseURL` is omitted. `src/lib/auth-client.ts` therefore omits `baseURL`, preserving same-origin behavior without encoding any gateway origin.
|
|
||||||
- The only `src/app/` edit is the transitional Next provider redirect page. Deleting `src/lib/sso-providers.ts` required that mechanical consumer migration; it now uses the same runtime `/api/sso/providers` discovery and callback sanitizer as the SPA.
|
|
||||||
- GuestGuard pending behavior intentionally follows the approved brief: if no session object exists (including pending), render `<Outlet />`; only a present session redirects to `/chat`. AuthGuard alone renders the specified pending treatment.
|
|
||||||
|
|
||||||
### TDD evidence
|
|
||||||
|
|
||||||
- Initial focused run: 9 expected failures, proving absolute API/auth/socket origins, P1 guest placeholders, and missing guard redirects/loading behavior.
|
|
||||||
- After implementation: focused contract suite 15/15 passed.
|
|
||||||
- Security remediation RED: `%2F%0A%2F%2Fevil.example` reached `signIn.oauth2` as an external-normalizing callback target before the fix.
|
|
||||||
- Security remediation GREEN: shared `resolveAuthCallbackURL` unit suite 9/9 plus SPA callback regression 1/1 passed; both SPA and Next consumers use the shared helper.
|
|
||||||
|
|
||||||
### Independent review
|
|
||||||
|
|
||||||
- Primary Codex wrappers could not run because `jq` is absent; direct Codex fallback then failed authentication with HTTP 401. No review result was claimed from those attempts.
|
|
||||||
- Independent Claude Sonnet code review found no implementation-scope blocker, one pending-state UX suggestion, and one pre-existing `api.ts` type-assertion suggestion. The pending-state suggestion was rejected because it contradicts the brief's explicit GuestGuard contract; the API cleanup is outside P2's preserve-contract scope.
|
|
||||||
- Independent Claude Sonnet security review found a high-severity control-character open redirect in the callback prefix check shared by the newly ported SPA logic and transitional Next consumer.
|
|
||||||
- Remediation centralized WHATWG URL parsing plus exact current-origin comparison in `src/lib/auth-redirect.ts`, returns only path/search/hash, and added the RED-first regression above.
|
|
||||||
- Fresh code re-review: `approve`, 0 blockers, 0 should-fix findings.
|
|
||||||
- Fresh security re-review: `low`, 0 critical/high/medium/low findings.
|
|
||||||
|
|
||||||
### Documentation checklist disposition
|
|
||||||
|
|
||||||
- `docs/PRD.md` exists and P2 aligns to FR-8 / AC-7 authentication requirements.
|
|
||||||
- No Gateway endpoint, DTO, permission, or API schema changed; existing `/api/auth/*` and `/api/sso/providers` contracts are consumed unchanged, so OpenAPI/API-index updates are not applicable.
|
|
||||||
- Route paths were already present in the P1 route table; P2 replaces placeholders without changing site-map navigation, so `docs/SITEMAP.md` is unchanged.
|
|
||||||
- User/admin auth behavior is parity with the still-live Next implementation, not a new workflow. Implementation decisions, failure behavior, testing, and migration compatibility are documented here; no publishing action is in scope.
|
|
||||||
- Documentation remains in-repo; no generated publishing output was created.
|
|
||||||
|
|
||||||
### Final verification evidence
|
|
||||||
|
|
||||||
Run fresh after remediation from the locations required by the brief:
|
|
||||||
|
|
||||||
- `apps/web: pnpm test` — PASS: `Test Files 9 passed (9)`; `Tests 27 passed (27)`.
|
|
||||||
- `apps/web: pnpm build:vite` — PASS: `✓ 113 modules transformed`; `✓ built in 565ms`.
|
|
||||||
- `apps/web: pnpm build` — PASS: `✓ Compiled successfully in 4.6s`; 10/10 static pages generated; dynamic provider/project routes retained.
|
|
||||||
- `apps/web: pnpm typecheck` — PASS: `tsc --noEmit` exited 0.
|
|
||||||
- `apps/web: pnpm lint` — PASS: `eslint src` exited 0.
|
|
||||||
- Root `pnpm format:check` — PASS: `All matched files use Prettier code style!`.
|
|
||||||
- Invariant scan — PASS: no `NEXT_PUBLIC_*`, `VITE_*`, `GATEWAY_URL`, `http://localhost:14242`, or `sso-providers` references under `apps/web/src`.
|
|
||||||
- `git diff --check` — PASS.
|
|
||||||
- All six required gates above were repeated successfully after implementation commit `46d68e1f`, proving the exact committed source tree.
|
|
||||||
|
|
||||||
## Risks / blockers
|
|
||||||
|
|
||||||
- `.mosaic/orchestrator/session.lock` is modified by the active harness and must remain unstaged.
|
|
||||||
- P2 changes shared `src/lib/` modules consumed by both Vite and Next, so the Next build is a required compatibility gate.
|
|
||||||
|
|
||||||
## Remediation 1 — independent Node 26 verification failure
|
|
||||||
|
|
||||||
**Correction received:** 2026-08-09
|
|
||||||
|
|
||||||
The orchestrator rejected the P2 verification claim after an independent run under Node 26.4.0 produced 2 failed redirect tests and 2 unhandled errors. React Router passed jsdom's realm-local `AbortSignal` to Node 26's native undici `Request`, which rejects non-native signals. Production guard behavior is correct and must not change.
|
|
||||||
|
|
||||||
### Remediation constraints
|
|
||||||
|
|
||||||
- Preserve both redirect assertions and all `guards.tsx` behavior; no skips, weakening, or test deletion.
|
|
||||||
- Add the smallest Vitest environment repair so global `AbortController` / `AbortSignal` are constructors accepted by native undici `Request`.
|
|
||||||
- Run all six required gates, commit named files locally without pushing, leave `.mosaic/orchestrator/session.lock` untouched, then run `apps/web: pnpm test` as the final worktree action.
|
|
||||||
- Completion requires zero failed tests, zero test errors, and zero unhandled rejections.
|
|
||||||
|
|
||||||
### Remediation plan
|
|
||||||
|
|
||||||
1. Reproduce under Node 26.4.0 if an ephemeral matching runtime is available.
|
|
||||||
2. Add a Vitest `setupFiles` module deriving Node-native abort constructors from `node:util` and register it in `apps/web/vitest.config.ts`.
|
|
||||||
3. Add a direct regression assertion that a global controller's signal is accepted by native `Request`, while retaining the existing redirect behavior tests unchanged.
|
|
||||||
4. Run focused Node 22 and Node 26 tests, independent review, all required gates, and local commit(s).
|
|
||||||
5. After every edit/commit/status check is complete, run `pnpm test` from `apps/web` as the last command.
|
|
||||||
|
|
||||||
### Remediation implementation and evidence
|
|
||||||
|
|
||||||
- Reproduced under ephemeral Node `v26.4.0`: `Test Files 1 failed (1)`, `Tests 2 failed | 4 passed (6)`, `Errors 2 errors`, with the exact undici `AbortSignal` realm rejection from the remediation brief.
|
|
||||||
- Added `apps/web/src/test/setup.ts`, registered through `vitest.config.ts#setupFiles`. It derives Node-native abort constructors from the built-in `node:util.transferableAbortController()` and aligns both `globalThis` and jsdom `window`; it does not replace `Request`, `Response`, or `fetch`.
|
|
||||||
- Explicitly pinned Vitest `isolate: true` so the test-only global constructors cannot leak between test-file environments.
|
|
||||||
- Added `src/test/setup.spec.ts`, which proves a global controller signal is accepted by Node's native `Request` and that abort propagation remains functional.
|
|
||||||
- Existing `guards.spec.tsx` and production `guards.tsx` remain unchanged.
|
|
||||||
- Focused Node 26.4.0 remediation run: `Test Files 2 passed (2)` and `Tests 7 passed (7)`, with zero errors/unhandled rejections.
|
|
||||||
- Preliminary full Node 26.4.0 run: `Test Files 9 passed (9)` and `Tests 27 passed (27)`, with zero errors/unhandled rejections before the direct setup regression was added.
|
|
||||||
- No dependency was added; `node:util` is a Node built-in.
|
|
||||||
|
|
||||||
### Remediation independent review
|
|
||||||
|
|
||||||
- First code review: approve, 0 blockers, 0 should-fix; suggested a direct Request regression and version-neutral comment.
|
|
||||||
- First security/integrity review: low risk; suggested pinning test isolation explicitly.
|
|
||||||
- All suggestions were applied.
|
|
||||||
- Fresh code re-review: approve, 0 blockers, 0 should-fix, no findings.
|
|
||||||
- Fresh security/integrity re-review: low risk, 0 findings.
|
|
||||||
|
|
||||||
### Remediation pre-commit gate evidence
|
|
||||||
|
|
||||||
- Node 26.4.0 `pnpm test`: `Test Files 10 passed (10)`; `Tests 28 passed (28)`; zero errors and zero unhandled rejections.
|
|
||||||
- `pnpm typecheck`: `tsc --noEmit` exited 0.
|
|
||||||
- `pnpm build:vite`: `✓ 113 modules transformed`; `✓ built in 960ms`.
|
|
||||||
- `pnpm build` (Next): `✓ Compiled successfully in 4.8s`; static pages generated 10/10.
|
|
||||||
- `pnpm lint`: `eslint src` exited 0.
|
|
||||||
- Root `pnpm format:check`: `All matched files use Prettier code style!`.
|
|
||||||
- Final post-commit non-test gates and final-action Node 26 `pnpm test` remain required before reporting completion.
|
|
||||||
|
|
||||||
### Remediation committed verification
|
|
||||||
|
|
||||||
- Implementation commit: `e16c08aa` (`test(web): align jsdom abort signals with Node`).
|
|
||||||
- Post-commit `pnpm typecheck`: `tsc --noEmit` exited 0.
|
|
||||||
- Post-commit `pnpm build:vite`: `✓ 113 modules transformed`; `✓ built in 323ms`.
|
|
||||||
- Post-commit `pnpm build` (Next): `✓ Compiled successfully in 4.6s`; static pages generated 10/10.
|
|
||||||
- Post-commit `pnpm lint`: `eslint src` exited 0.
|
|
||||||
- Post-commit root `pnpm format:check`: `All matched files use Prettier code style!`.
|
|
||||||
- Final Node 26 `pnpm test` will be the last worktree action and its verbatim output will be reported to the orchestrator.
|
|
||||||
+1
-2
@@ -10,8 +10,7 @@
|
|||||||
"clean:generated": "node scripts/clean-generated.mjs",
|
"clean:generated": "node scripts/clean-generated.mjs",
|
||||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||||
"test:checkout": "node --test scripts/*.test.mjs",
|
"test:checkout": "node --test scripts/*.test.mjs",
|
||||||
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
|
"test": "pnpm test:checkout && turbo run test",
|
||||||
"test:installer": "bash tools/install-next-lane.test.sh",
|
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"prepare": "node scripts/install-hooks.mjs"
|
"prepare": "node scripts/install-hooks.mjs"
|
||||||
|
|||||||
@@ -11,37 +11,9 @@ import { join } from 'node:path';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||||
import { createConfigService } from '../../src/config/config-service.js';
|
import { createConfigService } from '../../src/config/config-service.js';
|
||||||
import type { SelectOption } from '../../src/prompter/interface.js';
|
|
||||||
import type { MenuSection, WizardState } from '../../src/types.js';
|
|
||||||
|
|
||||||
const gatewayConfigMock = vi.fn();
|
const gatewayConfigMock = vi.fn();
|
||||||
const gatewayBootstrapMock = vi.fn();
|
const gatewayBootstrapMock = vi.fn();
|
||||||
const providerSetupMock = vi.fn();
|
|
||||||
const skillsSelectMock = vi.fn();
|
|
||||||
|
|
||||||
class SequencedMenuPrompter extends HeadlessPrompter {
|
|
||||||
constructor(
|
|
||||||
answers: Record<string, string | boolean | string[]>,
|
|
||||||
private readonly menuChoices: string[],
|
|
||||||
) {
|
|
||||||
super(answers);
|
|
||||||
}
|
|
||||||
|
|
||||||
override async select<T>(opts: {
|
|
||||||
message: string;
|
|
||||||
options: SelectOption<T>[];
|
|
||||||
initialValue?: T;
|
|
||||||
}): Promise<T> {
|
|
||||||
if (opts.message === 'What would you like to configure?') {
|
|
||||||
const next = this.menuChoices.shift();
|
|
||||||
if (!next) throw new Error('No queued menu choice left');
|
|
||||||
const match = opts.options.find((o) => String(o.value) === next);
|
|
||||||
if (!match) throw new Error(`Queued menu choice not available: ${next}`);
|
|
||||||
return match.value;
|
|
||||||
}
|
|
||||||
return super.select(opts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock('../../src/stages/gateway-config.js', () => ({
|
vi.mock('../../src/stages/gateway-config.js', () => ({
|
||||||
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
|
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
|
||||||
@@ -51,14 +23,6 @@ vi.mock('../../src/stages/gateway-bootstrap.js', () => ({
|
|||||||
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
|
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../src/stages/provider-setup.js', () => ({
|
|
||||||
providerSetupStage: (...args: unknown[]) => providerSetupMock(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../src/stages/skills-select.js', () => ({
|
|
||||||
skillsSelectStage: (...args: unknown[]) => skillsSelectMock(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Import AFTER the mocks so runWizard picks up the mocked stage modules.
|
// Import AFTER the mocks so runWizard picks up the mocked stage modules.
|
||||||
import { runWizard } from '../../src/wizard.js';
|
import { runWizard } from '../../src/wizard.js';
|
||||||
|
|
||||||
@@ -80,16 +44,6 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
|||||||
}
|
}
|
||||||
gatewayConfigMock.mockReset();
|
gatewayConfigMock.mockReset();
|
||||||
gatewayBootstrapMock.mockReset();
|
gatewayBootstrapMock.mockReset();
|
||||||
providerSetupMock.mockReset();
|
|
||||||
skillsSelectMock.mockReset();
|
|
||||||
providerSetupMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
|
|
||||||
state.providerType = 'none';
|
|
||||||
state.completedSections?.add('providers' satisfies MenuSection);
|
|
||||||
});
|
|
||||||
skillsSelectMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
|
|
||||||
state.selectedSkills = [];
|
|
||||||
state.completedSections?.add('skills' satisfies MenuSection);
|
|
||||||
});
|
|
||||||
// Pretend we're on an interactive TTY so the wizard's headless-abort
|
// Pretend we're on an interactive TTY so the wizard's headless-abort
|
||||||
// branch does not call `process.exit(1)` during these tests.
|
// branch does not call `process.exit(1)` during these tests.
|
||||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||||
@@ -144,12 +98,8 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
|||||||
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
|
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prints the success summary only after gateway health succeeds', async () => {
|
it('does not invoke bootstrap when config stage reports not ready', async () => {
|
||||||
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
|
gatewayConfigMock.mockResolvedValue({ ready: false });
|
||||||
p.log('Gateway is healthy.');
|
|
||||||
return { ready: true, host: 'localhost', port: 14242 };
|
|
||||||
});
|
|
||||||
gatewayBootstrapMock.mockResolvedValue({ completed: true });
|
|
||||||
|
|
||||||
const prompter = new HeadlessPrompter({
|
const prompter = new HeadlessPrompter({
|
||||||
'Installation mode': 'quick',
|
'Installation mode': 'quick',
|
||||||
@@ -168,43 +118,6 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
|||||||
skipGatewayNpmInstall: true,
|
skipGatewayNpmInstall: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const logs = prompter.getLogs();
|
|
||||||
const healthIndex = logs.findIndex((line) => line.includes('Gateway is healthy.'));
|
|
||||||
const summaryIndex = logs.findIndex((line) => line.includes('Installation Summary'));
|
|
||||||
const readyIndex = logs.findIndex((line) => line.includes('Mosaic is ready.'));
|
|
||||||
|
|
||||||
expect(healthIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(summaryIndex).toBeGreaterThan(healthIndex);
|
|
||||||
expect(readyIndex).toBeGreaterThan(summaryIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not claim success when gateway health reports not ready', async () => {
|
|
||||||
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
|
|
||||||
p.warn('Gateway did not become healthy within 30 seconds.');
|
|
||||||
return { ready: false };
|
|
||||||
});
|
|
||||||
|
|
||||||
const prompter = new HeadlessPrompter({
|
|
||||||
'Installation mode': 'quick',
|
|
||||||
'What name should agents use?': 'TestBot',
|
|
||||||
'Communication style': 'direct',
|
|
||||||
'Your name': 'Tester',
|
|
||||||
'Your pronouns': 'They/Them',
|
|
||||||
'Your timezone': 'UTC',
|
|
||||||
});
|
|
||||||
|
|
||||||
await runWizard({
|
|
||||||
mosaicHome: tmpDir,
|
|
||||||
sourceDir: tmpDir,
|
|
||||||
prompter,
|
|
||||||
configService: createConfigService(tmpDir, tmpDir),
|
|
||||||
skipGatewayNpmInstall: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const logs = prompter.getLogs();
|
|
||||||
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
|
|
||||||
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
|
|
||||||
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
|
|
||||||
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
||||||
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -230,34 +143,4 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
|||||||
expect(gatewayConfigMock).not.toHaveBeenCalled();
|
expect(gatewayConfigMock).not.toHaveBeenCalled();
|
||||||
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not re-run completed provider or skills menu steps', async () => {
|
|
||||||
const prompter = new SequencedMenuPrompter(
|
|
||||||
{
|
|
||||||
'What name should agents use?': 'TestBot',
|
|
||||||
'Communication style': 'direct',
|
|
||||||
'Your name': 'Tester',
|
|
||||||
'Your pronouns': 'They/Them',
|
|
||||||
'Your timezone': 'UTC',
|
|
||||||
},
|
|
||||||
['providers', 'providers', 'skills', 'skills', 'finish'],
|
|
||||||
);
|
|
||||||
|
|
||||||
await runWizard({
|
|
||||||
mosaicHome: tmpDir,
|
|
||||||
sourceDir: tmpDir,
|
|
||||||
prompter,
|
|
||||||
configService: createConfigService(tmpDir, tmpDir),
|
|
||||||
skipGateway: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(providerSetupMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(skillsSelectMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(prompter.getLogs()).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.stringContaining('Providers [done] is already complete; skipping.'),
|
|
||||||
expect.stringContaining('Skills [done] is already complete; skipping.'),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user