Compare commits
10 Commits
e3d98d7390
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
| 193331544d | |||
| 495f73bfdb | |||
| b96cc7982a | |||
| 0883fb91ec | |||
| 56787fabf1 | |||
| 940ae3cc41 | |||
| c25a551c28 | |||
| 94d6538061 | |||
| a3c1ab923c | |||
| 838701bde2 |
@@ -5,5 +5,3 @@ pnpm-lock.yaml
|
||||
**/drizzle
|
||||
**/.next
|
||||
.claude/
|
||||
docs/tess/TASKS.md
|
||||
docs/scratchpads/
|
||||
|
||||
@@ -7,14 +7,7 @@ variables:
|
||||
- &enable_pnpm 'corepack enable'
|
||||
|
||||
when:
|
||||
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
|
||||
# push CI is restricted to protected branches (main) so a feature-branch push no
|
||||
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
|
||||
# CI load on the storage-constrained runner with zero loss of gating (branch
|
||||
# protection requires no push/ci status context; main still gets full push CI).
|
||||
- event: [pull_request, manual]
|
||||
- event: push
|
||||
branch: main
|
||||
- event: [push, pull_request, manual]
|
||||
|
||||
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build, publish npm packages, and push Docker images
|
||||
# Runs only on main branch push/tag
|
||||
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
||||
|
||||
variables:
|
||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||
@@ -23,9 +23,21 @@ variables:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.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:
|
||||
- branch: [main]
|
||||
- branch: [main, next]
|
||||
event: [push, manual, tag]
|
||||
|
||||
steps:
|
||||
@@ -103,6 +115,84 @@ steps:
|
||||
depends_on:
|
||||
- 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
|
||||
# publish-npmjs:
|
||||
# image: *node_image
|
||||
@@ -134,8 +224,17 @@ steps:
|
||||
- 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}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; 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"
|
||||
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
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||
@@ -146,7 +245,7 @@ steps:
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
when: *main_image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
@@ -172,7 +271,7 @@ steps:
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
when: *main_image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
|
||||
14
README.md
14
README.md
@@ -30,6 +30,16 @@ This installs both components:
|
||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||
| **@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:
|
||||
|
||||
```bash
|
||||
@@ -336,7 +346,9 @@ 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 --framework # Framework only (skip npm CLI)
|
||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||
bash tools/install.sh --next # Prerelease lane: source build from next
|
||||
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 --no-auto-launch # Skip auto-launch of wizard
|
||||
```
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* 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,9 +1,11 @@
|
||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
|
||||
import { sql, type Db } from '@mosaicstack/db';
|
||||
import { createQueue } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { ProviderService } from '../agent/provider.service.js';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { AdminGuard } from './admin.guard.js';
|
||||
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
||||
|
||||
@@ -14,13 +16,16 @@ export class AdminHealthController {
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async check(): Promise<HealthStatusDto> {
|
||||
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
|
||||
|
||||
const sessions = this.agentService.listAllSessionsForSystem();
|
||||
const sessions = this.agentService.listSessions();
|
||||
const providers = this.providerService.listProviders();
|
||||
|
||||
const allOk = database.status === 'ok' && cache.status === 'ok';
|
||||
@@ -55,6 +60,14 @@ export class AdminHealthController {
|
||||
}
|
||||
|
||||
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 handle = createQueue();
|
||||
try {
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AgentService, type AgentSession } from '../agent.service.js';
|
||||
import type { ActorTenantScope } from '../../auth/session-scope.js';
|
||||
|
||||
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-user', tenantId: 'owner-tenant' };
|
||||
const FOREIGN_SCOPE: ActorTenantScope = { userId: 'foreign-user', tenantId: 'foreign-tenant' };
|
||||
|
||||
type AgentServiceInternals = {
|
||||
sessions: Map<string, AgentSession>;
|
||||
creating: Map<string, Promise<AgentSession>>;
|
||||
};
|
||||
|
||||
function makeService(): AgentService {
|
||||
return new AgentService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ available: false } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
null,
|
||||
null,
|
||||
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||
);
|
||||
}
|
||||
|
||||
function internals(service: AgentService): AgentServiceInternals {
|
||||
return service as unknown as AgentServiceInternals;
|
||||
}
|
||||
|
||||
function makeSession(scope: ActorTenantScope = OWNER_SCOPE): AgentSession {
|
||||
return {
|
||||
id: CONVERSATION_ID,
|
||||
provider: 'test-provider',
|
||||
modelId: 'test-model',
|
||||
piSession: {
|
||||
thinkingLevel: 'off',
|
||||
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
|
||||
setThinkingLevel: vi.fn(),
|
||||
abort: vi.fn().mockResolvedValue(undefined),
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
getSessionStats: vi.fn(),
|
||||
getContextUsage: vi.fn(),
|
||||
} as unknown as AgentSession['piSession'],
|
||||
listeners: new Set(),
|
||||
unsubscribe: vi.fn(),
|
||||
createdAt: Date.now(),
|
||||
promptCount: 0,
|
||||
channels: new Set(),
|
||||
skillPromptAdditions: [],
|
||||
sandboxDir: '/tmp/tess-session-ownership-test',
|
||||
allowedTools: null,
|
||||
userId: scope.userId,
|
||||
tenantId: scope.tenantId,
|
||||
metrics: {
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
modelSwitches: 0,
|
||||
messageCount: 0,
|
||||
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentService owner/tenant scope enforcement', () => {
|
||||
it('allows owner-scoped operations and rejects foreign scopes for seeded sessions', async () => {
|
||||
const service = makeService();
|
||||
const session = makeSession();
|
||||
internals(service).sessions.set(CONVERSATION_ID, session);
|
||||
|
||||
expect(service.getSession(CONVERSATION_ID, OWNER_SCOPE)).toBe(session);
|
||||
expect(service.getSession(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
|
||||
expect(service.getSessionInfo(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
|
||||
expect(service.listSessions(OWNER_SCOPE)).toHaveLength(1);
|
||||
expect(service.listSessions(FOREIGN_SCOPE)).toEqual([]);
|
||||
|
||||
service.addChannel(CONVERSATION_ID, 'websocket:owner', OWNER_SCOPE);
|
||||
expect(session.channels.has('websocket:owner')).toBe(true);
|
||||
expect(() => service.addChannel(CONVERSATION_ID, 'websocket:foreign', FOREIGN_SCOPE)).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(() => service.removeChannel(CONVERSATION_ID, 'websocket:owner', FOREIGN_SCOPE)).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
service.updateSessionModel(CONVERSATION_ID, 'foreign-model', FOREIGN_SCOPE),
|
||||
).toThrow(ForbiddenException);
|
||||
service.updateSessionModel(CONVERSATION_ID, 'owner-model', OWNER_SCOPE);
|
||||
expect(session.modelId).toBe('owner-model');
|
||||
|
||||
expect(() =>
|
||||
service.applyAgentConfig(CONVERSATION_ID, 'agent-foreign', 'Foreign Agent', FOREIGN_SCOPE),
|
||||
).toThrow(ForbiddenException);
|
||||
service.applyAgentConfig(CONVERSATION_ID, 'agent-owner', 'Owner Agent', OWNER_SCOPE);
|
||||
expect(session.agentConfigId).toBe('agent-owner');
|
||||
|
||||
expect(() => service.onEvent(CONVERSATION_ID, vi.fn(), FOREIGN_SCOPE)).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
const cleanup = service.onEvent(CONVERSATION_ID, vi.fn(), OWNER_SCOPE);
|
||||
cleanup();
|
||||
|
||||
await expect(
|
||||
service.prompt(CONVERSATION_ID, 'foreign prompt', FOREIGN_SCOPE),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
|
||||
expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt');
|
||||
|
||||
await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(true);
|
||||
|
||||
await service.destroySession(CONVERSATION_ID, OWNER_SCOPE);
|
||||
expect(session.piSession.dispose).toHaveBeenCalled();
|
||||
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it('checks owner/tenant scope before returning an in-flight session creation', async () => {
|
||||
const service = makeService();
|
||||
const session = makeSession();
|
||||
internals(service).creating.set(CONVERSATION_ID, Promise.resolve(session));
|
||||
|
||||
await expect(
|
||||
service.createSession(CONVERSATION_ID, {
|
||||
userId: FOREIGN_SCOPE.userId,
|
||||
tenantId: FOREIGN_SCOPE.tenantId,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
await expect(
|
||||
service.createSession(CONVERSATION_ID, {
|
||||
userId: OWNER_SCOPE.userId,
|
||||
tenantId: OWNER_SCOPE.tenantId,
|
||||
}),
|
||||
).resolves.toBe(session);
|
||||
});
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
|
||||
vi.mock('../../commands/command-executor.service.js', () => ({
|
||||
CommandExecutorService: class CommandExecutorService {},
|
||||
}));
|
||||
vi.mock('../routing/routing-engine.service.js', () => ({
|
||||
RoutingEngineService: class RoutingEngineService {},
|
||||
}));
|
||||
|
||||
import { SessionsController } from '../sessions.controller.js';
|
||||
import { ChatController } from '../../chat/chat.controller.js';
|
||||
import { ChatGateway } from '../../chat/chat.gateway.js';
|
||||
import type { AgentSession } from '../agent.service.js';
|
||||
import type { SessionInfoDto } from '../session.dto.js';
|
||||
|
||||
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
|
||||
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
|
||||
const CONVERSATION_ID = '11111111-1111-4111-8111-111111111111';
|
||||
|
||||
function makeSessionInfo(overrides?: Partial<SessionInfoDto>): SessionInfoDto {
|
||||
return {
|
||||
id: CONVERSATION_ID,
|
||||
provider: 'test-provider',
|
||||
modelId: 'test-model',
|
||||
createdAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
||||
promptCount: 0,
|
||||
channels: [],
|
||||
durationMs: 0,
|
||||
metrics: {
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
modelSwitches: 0,
|
||||
messageCount: 0,
|
||||
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAgentSession(owner = USER_A): AgentSession {
|
||||
return {
|
||||
id: CONVERSATION_ID,
|
||||
provider: 'test-provider',
|
||||
modelId: 'test-model',
|
||||
piSession: {
|
||||
thinkingLevel: 'off',
|
||||
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
|
||||
setThinkingLevel: vi.fn(),
|
||||
abort: vi.fn().mockResolvedValue(undefined),
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
getSessionStats: vi.fn(),
|
||||
getContextUsage: vi.fn(),
|
||||
} as unknown as AgentSession['piSession'],
|
||||
listeners: new Set(),
|
||||
unsubscribe: vi.fn(),
|
||||
createdAt: Date.now(),
|
||||
promptCount: 0,
|
||||
channels: new Set(),
|
||||
skillPromptAdditions: [],
|
||||
sandboxDir: '/tmp',
|
||||
allowedTools: null,
|
||||
userId: owner.id,
|
||||
tenantId: owner.tenantId,
|
||||
metrics: {
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
modelSwitches: 0,
|
||||
messageCount: 0,
|
||||
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeScopedAgentService() {
|
||||
const foreign = makeAgentSession(USER_A);
|
||||
return {
|
||||
listSessions: vi.fn((scope?: { userId: string; tenantId?: string }) =>
|
||||
scope?.userId === USER_B.id ? [] : [makeSessionInfo({ id: foreign.id })],
|
||||
),
|
||||
getSessionInfo: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
|
||||
scope?.userId === USER_B.id ? undefined : makeSessionInfo({ id: foreign.id }),
|
||||
),
|
||||
destroySession: vi.fn(),
|
||||
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
|
||||
scope?.userId === USER_B.id ? undefined : foreign,
|
||||
),
|
||||
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
|
||||
onEvent: vi.fn(() => vi.fn()),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
recordMessage: vi.fn(),
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
|
||||
it('requires explicit owner+tenant scope on protected session operations', () => {
|
||||
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
|
||||
|
||||
expect(source).toContain('getSession(sessionId: string, scope: ActorTenantScope)');
|
||||
expect(source).toContain('listSessions(scope: ActorTenantScope)');
|
||||
expect(source).toContain('getSessionInfo(sessionId: string, scope: ActorTenantScope)');
|
||||
expect(source).toContain(
|
||||
'addChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
|
||||
);
|
||||
expect(source).toContain(
|
||||
'removeChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
|
||||
);
|
||||
expect(source).toContain(
|
||||
'async prompt(sessionId: string, message: string, scope: ActorTenantScope)',
|
||||
);
|
||||
expect(source).toContain('scope: ActorTenantScope,');
|
||||
expect(source).toContain('async destroySession(sessionId: string, scope: ActorTenantScope)');
|
||||
expect(source).not.toContain('scope?: ActorTenantScope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
|
||||
it('lists only sessions owned by the authenticated owner+tenant scope', () => {
|
||||
const agentService = makeScopedAgentService();
|
||||
const controller = new SessionsController(agentService as never);
|
||||
|
||||
expect(controller.list(USER_B)).toEqual({ sessions: [], total: 0 });
|
||||
expect(agentService.listSessions).toHaveBeenCalledWith({
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reveal another owner/tenant session by guessed id', () => {
|
||||
const agentService = makeScopedAgentService();
|
||||
const controller = new SessionsController(agentService as never);
|
||||
|
||||
expect(() => controller.findOne(CONVERSATION_ID, USER_B)).toThrow(NotFoundException);
|
||||
expect(agentService.getSessionInfo).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not terminate another owner/tenant session by guessed id', async () => {
|
||||
const agentService = makeScopedAgentService();
|
||||
const controller = new SessionsController(agentService as never);
|
||||
|
||||
await expect(controller.destroy(CONVERSATION_ID, USER_B)).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
expect(agentService.destroySession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
|
||||
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
|
||||
const agentService = makeScopedAgentService();
|
||||
const controller = new ChatController(agentService as never);
|
||||
|
||||
await expect(
|
||||
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
|
||||
function makeGateway(agentService = makeScopedAgentService()) {
|
||||
const brain = {
|
||||
conversations: {
|
||||
findById: vi.fn().mockResolvedValue(undefined),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
findMessages: vi.fn().mockResolvedValue([]),
|
||||
addMessage: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) };
|
||||
const commandExecutor = { execute: vi.fn() };
|
||||
const routingEngine = {
|
||||
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
|
||||
};
|
||||
const gateway = new ChatGateway(
|
||||
agentService as never,
|
||||
{} as never,
|
||||
brain as never,
|
||||
commandRegistry as never,
|
||||
commandExecutor as never,
|
||||
routingEngine as never,
|
||||
);
|
||||
return { gateway, agentService };
|
||||
}
|
||||
|
||||
function makeSocket() {
|
||||
return {
|
||||
id: 'socket-b',
|
||||
connected: true,
|
||||
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
|
||||
emit: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
|
||||
const { gateway, agentService } = makeGateway();
|
||||
const socket = makeSocket();
|
||||
|
||||
await gateway.handleMessage(socket as never, {
|
||||
conversationId: CONVERSATION_ID,
|
||||
content: 'attach to foreign session',
|
||||
});
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect(agentService.onEvent).not.toHaveBeenCalled();
|
||||
expect(agentService.addChannel).not.toHaveBeenCalled();
|
||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||
expect(socket.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mutate thinking level on another owner/tenant session', () => {
|
||||
const { gateway, agentService } = makeGateway();
|
||||
const socket = makeSocket();
|
||||
|
||||
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect(socket.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
|
||||
const { gateway, agentService } = makeGateway();
|
||||
const socket = makeSocket();
|
||||
|
||||
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect(socket.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
Optional,
|
||||
type OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common';
|
||||
import {
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
@@ -35,7 +28,6 @@ import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
import { PreferencesService } from '../preferences/preferences.service.js';
|
||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
|
||||
/** A single message from DB conversation history, used for context injection. */
|
||||
export interface ConversationHistoryMessage {
|
||||
@@ -76,8 +68,6 @@ export interface AgentSessionOptions {
|
||||
agentConfigId?: string;
|
||||
/** ID of the user who owns this session. Used for preferences and system override lookups. */
|
||||
userId?: string;
|
||||
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
|
||||
tenantId?: string;
|
||||
/**
|
||||
* Prior conversation messages to inject as context when resuming a session.
|
||||
* These messages are formatted and prepended to the system prompt so the
|
||||
@@ -104,8 +94,6 @@ export interface AgentSession {
|
||||
allowedTools: string[] | null;
|
||||
/** User ID that owns this session, used for preference lookups. */
|
||||
userId?: string;
|
||||
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
|
||||
tenantId?: string;
|
||||
/** Agent config ID applied to this session, if any (M5-001). */
|
||||
agentConfigId?: string;
|
||||
/** Human-readable agent name applied to this session, if any (M5-001). */
|
||||
@@ -186,20 +174,12 @@ export class AgentService implements OnModuleDestroy {
|
||||
.filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
async createSession(sessionId: string, options: AgentSessionOptions): Promise<AgentSession> {
|
||||
const scope = this.scopeFromOptions(options);
|
||||
async createSession(sessionId: string, options?: AgentSessionOptions): Promise<AgentSession> {
|
||||
const existing = this.sessions.get(sessionId);
|
||||
if (existing) {
|
||||
this.assertSessionScope(existing, scope);
|
||||
return existing;
|
||||
}
|
||||
if (existing) return existing;
|
||||
|
||||
const inflight = this.creating.get(sessionId);
|
||||
if (inflight) {
|
||||
const session = await inflight;
|
||||
this.assertSessionScope(session, scope);
|
||||
return session;
|
||||
}
|
||||
if (inflight) return inflight;
|
||||
|
||||
const promise = this.doCreateSession(sessionId, options).finally(() => {
|
||||
this.creating.delete(sessionId);
|
||||
@@ -362,7 +342,6 @@ export class AgentService implements OnModuleDestroy {
|
||||
sandboxDir,
|
||||
allowedTools,
|
||||
userId: mergedOptions?.userId,
|
||||
tenantId: this.tenantIdFor(mergedOptions?.userId, mergedOptions?.tenantId),
|
||||
agentConfigId: mergedOptions?.agentConfigId,
|
||||
agentName: resolvedAgentName,
|
||||
metrics: {
|
||||
@@ -494,70 +473,38 @@ export class AgentService implements OnModuleDestroy {
|
||||
return this.providerService.getDefaultModel() ?? null;
|
||||
}
|
||||
|
||||
getSession(sessionId: string, scope: ActorTenantScope): AgentSession | undefined {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || !this.sessionMatchesScope(session, scope)) return undefined;
|
||||
return session;
|
||||
getSession(sessionId: string): AgentSession | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
listSessions(scope: ActorTenantScope): SessionInfoDto[] {
|
||||
listSessions(): SessionInfoDto[] {
|
||||
const now = Date.now();
|
||||
return Array.from(this.sessions.values())
|
||||
.filter((s) => this.sessionMatchesScope(s, scope))
|
||||
.map((s) => this.toSessionInfo(s, now));
|
||||
return Array.from(this.sessions.values()).map((s) => ({
|
||||
id: s.id,
|
||||
provider: s.provider,
|
||||
modelId: s.modelId,
|
||||
...(s.agentName ? { agentName: s.agentName } : {}),
|
||||
createdAt: new Date(s.createdAt).toISOString(),
|
||||
promptCount: s.promptCount,
|
||||
channels: Array.from(s.channels),
|
||||
durationMs: now - s.createdAt,
|
||||
metrics: { ...s.metrics },
|
||||
}));
|
||||
}
|
||||
|
||||
listAllSessionsForSystem(): SessionInfoDto[] {
|
||||
const now = Date.now();
|
||||
return Array.from(this.sessions.values()).map((s) => this.toSessionInfo(s, now));
|
||||
}
|
||||
|
||||
getSessionInfo(sessionId: string, scope: ActorTenantScope): SessionInfoDto | undefined {
|
||||
getSessionInfo(sessionId: string): SessionInfoDto | undefined {
|
||||
const s = this.sessions.get(sessionId);
|
||||
if (!s || !this.sessionMatchesScope(s, scope)) return undefined;
|
||||
return this.toSessionInfo(s);
|
||||
}
|
||||
|
||||
private scopeFromOptions(options: AgentSessionOptions): ActorTenantScope {
|
||||
if (!options.userId) {
|
||||
throw new ForbiddenException('Session owner scope is required');
|
||||
}
|
||||
if (!s) return undefined;
|
||||
return {
|
||||
userId: options.userId,
|
||||
tenantId: this.tenantIdFor(options.userId, options.tenantId) ?? options.userId,
|
||||
};
|
||||
}
|
||||
|
||||
private tenantIdFor(
|
||||
userId: string | undefined,
|
||||
tenantId: string | undefined,
|
||||
): string | undefined {
|
||||
return tenantId ?? userId;
|
||||
}
|
||||
|
||||
private sessionMatchesScope(session: AgentSession, scope: ActorTenantScope): boolean {
|
||||
return (
|
||||
session.userId === scope.userId && (session.tenantId ?? session.userId) === scope.tenantId
|
||||
);
|
||||
}
|
||||
|
||||
private assertSessionScope(session: AgentSession, scope: ActorTenantScope): void {
|
||||
if (!this.sessionMatchesScope(session, scope)) {
|
||||
throw new ForbiddenException('Session does not belong to the current owner/tenant scope');
|
||||
}
|
||||
}
|
||||
|
||||
private toSessionInfo(session: AgentSession, now = Date.now()): SessionInfoDto {
|
||||
return {
|
||||
id: session.id,
|
||||
provider: session.provider,
|
||||
modelId: session.modelId,
|
||||
...(session.agentName ? { agentName: session.agentName } : {}),
|
||||
createdAt: new Date(session.createdAt).toISOString(),
|
||||
promptCount: session.promptCount,
|
||||
channels: Array.from(session.channels),
|
||||
durationMs: now - session.createdAt,
|
||||
metrics: { ...session.metrics },
|
||||
id: s.id,
|
||||
provider: s.provider,
|
||||
modelId: s.modelId,
|
||||
...(s.agentName ? { agentName: s.agentName } : {}),
|
||||
createdAt: new Date(s.createdAt).toISOString(),
|
||||
promptCount: s.promptCount,
|
||||
channels: Array.from(s.channels),
|
||||
durationMs: Date.now() - s.createdAt,
|
||||
metrics: { ...s.metrics },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -606,10 +553,9 @@ export class AgentService implements OnModuleDestroy {
|
||||
* not reconstructed — the model is used on the next createSession call for
|
||||
* the same conversationId when the session is torn down or a new one is created.
|
||||
*/
|
||||
updateSessionModel(sessionId: string, modelId: string, scope: ActorTenantScope): void {
|
||||
updateSessionModel(sessionId: string, modelId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.assertSessionScope(session, scope);
|
||||
const prev = session.modelId;
|
||||
session.modelId = modelId;
|
||||
this.recordModelSwitch(sessionId);
|
||||
@@ -626,51 +572,48 @@ export class AgentService implements OnModuleDestroy {
|
||||
sessionId: string,
|
||||
agentConfigId: string,
|
||||
agentName: string,
|
||||
scope: ActorTenantScope,
|
||||
modelId?: string,
|
||||
): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.assertSessionScope(session, scope);
|
||||
session.agentConfigId = agentConfigId;
|
||||
session.agentName = agentName;
|
||||
if (modelId) {
|
||||
this.updateSessionModel(sessionId, modelId, scope);
|
||||
this.updateSessionModel(sessionId, modelId);
|
||||
}
|
||||
this.logger.log(
|
||||
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
|
||||
);
|
||||
}
|
||||
|
||||
addChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
|
||||
addChannel(sessionId: string, channel: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.assertSessionScope(session, scope);
|
||||
session.channels.add(channel);
|
||||
if (session) {
|
||||
session.channels.add(channel);
|
||||
}
|
||||
}
|
||||
|
||||
removeChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
|
||||
removeChannel(sessionId: string, channel: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.assertSessionScope(session, scope);
|
||||
session.channels.delete(channel);
|
||||
if (session) {
|
||||
session.channels.delete(channel);
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void> {
|
||||
async prompt(sessionId: string, message: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`No agent session found: ${sessionId}`);
|
||||
}
|
||||
this.assertSessionScope(session, scope);
|
||||
session.promptCount += 1;
|
||||
|
||||
// Prepend session-scoped system override if present (renew TTL on each turn)
|
||||
let effectiveMessage = message;
|
||||
if (this.systemOverride) {
|
||||
const override = await this.systemOverride.get(sessionId, scope);
|
||||
const override = await this.systemOverride.get(sessionId);
|
||||
if (override) {
|
||||
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
|
||||
await this.systemOverride.renew(sessionId, scope);
|
||||
await this.systemOverride.renew(sessionId);
|
||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||
}
|
||||
}
|
||||
@@ -686,28 +629,16 @@ export class AgentService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
onEvent(
|
||||
sessionId: string,
|
||||
listener: (event: AgentSessionEvent) => void,
|
||||
scope: ActorTenantScope,
|
||||
): () => void {
|
||||
onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`No agent session found: ${sessionId}`);
|
||||
}
|
||||
this.assertSessionScope(session, scope);
|
||||
session.listeners.add(listener);
|
||||
return () => session.listeners.delete(listener);
|
||||
}
|
||||
|
||||
async destroySession(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.assertSessionScope(session, scope);
|
||||
await this.destroySessionForSystem(sessionId);
|
||||
}
|
||||
|
||||
private async destroySessionForSystem(sessionId: string): Promise<void> {
|
||||
async destroySession(sessionId: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.logger.log(`Destroying agent session ${sessionId}`);
|
||||
@@ -736,7 +667,7 @@ export class AgentService implements OnModuleDestroy {
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
this.logger.log('Shutting down all agent sessions');
|
||||
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySessionForSystem(id));
|
||||
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id));
|
||||
const results = await Promise.allSettled(stops);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||
import { AgentService } from './agent.service.js';
|
||||
|
||||
@Controller('api/sessions')
|
||||
@@ -20,24 +18,23 @@ export class SessionsController {
|
||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthenticatedUserLike) {
|
||||
const sessions = this.agentService.listSessions(scopeFromUser(user));
|
||||
list() {
|
||||
const sessions = this.agentService.listSessions();
|
||||
return { sessions, total: sessions.length };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
|
||||
const info = this.agentService.getSessionInfo(id, scopeFromUser(user));
|
||||
findOne(@Param('id') id: string) {
|
||||
const info = this.agentService.getSessionInfo(id);
|
||||
if (!info) throw new NotFoundException('Session not found');
|
||||
return info;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async destroy(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
|
||||
const scope = scopeFromUser(user);
|
||||
const info = this.agentService.getSessionInfo(id, scope);
|
||||
async destroy(@Param('id') id: string) {
|
||||
const info = this.agentService.getSessionInfo(id);
|
||||
if (!info) throw new NotFoundException('Session not found');
|
||||
await this.agentService.destroySession(id, scope);
|
||||
await this.agentService.destroySession(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export interface AuthenticatedUserLike {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
teamId?: string | null;
|
||||
organizationId?: string | null;
|
||||
orgId?: string | null;
|
||||
}
|
||||
|
||||
export interface ActorTenantScope {
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the immutable server-derived scope used for Tess session operations.
|
||||
* Current Mosaic auth is user-scoped; future org/team claims can populate one
|
||||
* of the tenant fields without allowing clients to choose another tenant.
|
||||
*/
|
||||
export function scopeFromUser(user: AuthenticatedUserLike): ActorTenantScope {
|
||||
return {
|
||||
userId: user.id,
|
||||
tenantId: user.tenantId ?? user.teamId ?? user.organizationId ?? user.orgId ?? user.id,
|
||||
};
|
||||
}
|
||||
@@ -12,8 +12,7 @@ describe('Chat controller source hardening', () => {
|
||||
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
||||
|
||||
expect(source).toContain('@UseGuards(AuthGuard)');
|
||||
expect(source).toContain('@CurrentUser() user: AuthenticatedUserLike');
|
||||
expect(source).toContain('const scope = scopeFromUser(user);');
|
||||
expect(source).toContain('@CurrentUser() user: { id: string }');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,10 +3,8 @@ import {
|
||||
Post,
|
||||
Body,
|
||||
Logger,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Inject,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
@@ -15,7 +13,6 @@ import { Throttle } from '@nestjs/throttler';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ChatRequestDto } from './chat.dto.js';
|
||||
|
||||
@@ -35,23 +32,16 @@ export class ChatController {
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
async chat(
|
||||
@Body() body: ChatRequestDto,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@CurrentUser() user: { id: string },
|
||||
): Promise<ChatResponse> {
|
||||
const conversationId = body.conversationId ?? uuid();
|
||||
const scope = scopeFromUser(user);
|
||||
|
||||
try {
|
||||
let agentSession = this.agentService.getSession(conversationId, scope);
|
||||
let agentSession = this.agentService.getSession(conversationId);
|
||||
if (!agentSession) {
|
||||
agentSession = await this.agentService.createSession(conversationId, {
|
||||
userId: scope.userId,
|
||||
tenantId: scope.tenantId,
|
||||
});
|
||||
agentSession = await this.agentService.createSession(conversationId);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ForbiddenException) {
|
||||
throw new NotFoundException('Session not found');
|
||||
}
|
||||
this.logger.error(
|
||||
`Session creation failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
@@ -70,27 +60,20 @@ export class ChatController {
|
||||
reject(new Error('Agent response timed out'));
|
||||
}, 120_000);
|
||||
|
||||
const cleanup = this.agentService.onEvent(
|
||||
conversationId,
|
||||
(event: AgentSessionEvent) => {
|
||||
if (
|
||||
event.type === 'message_update' &&
|
||||
event.assistantMessageEvent.type === 'text_delta'
|
||||
) {
|
||||
responseText += event.assistantMessageEvent.delta;
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
scope,
|
||||
);
|
||||
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
||||
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
|
||||
responseText += event.assistantMessageEvent.delta;
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await this.agentService.prompt(conversationId, body.content, scope);
|
||||
await this.agentService.prompt(conversationId, body.content);
|
||||
await done;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||
import { ChatGateway } from './chat.gateway.js';
|
||||
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'gc',
|
||||
conversationId: 'conversation-1',
|
||||
approvalId: 'approval-1',
|
||||
};
|
||||
|
||||
function buildGateway(commandExecutor: {
|
||||
execute: ReturnType<typeof vi.fn>;
|
||||
createApproval: ReturnType<typeof vi.fn>;
|
||||
}): ChatGateway {
|
||||
return new ChatGateway(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
commandExecutor as never,
|
||||
{} as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ChatGateway command approval ingress', () => {
|
||||
it('passes the client approval ID through to command execution while deriving the actor server-side', async (): Promise<void> => {
|
||||
const commandExecutor = {
|
||||
execute: vi.fn().mockResolvedValue({ ...payload, success: true }),
|
||||
createApproval: vi.fn(),
|
||||
};
|
||||
const gateway = buildGateway(commandExecutor);
|
||||
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||
|
||||
await gateway.handleCommandExecute(client as never, payload);
|
||||
|
||||
expect(commandExecutor.execute).toHaveBeenCalledWith(payload, {
|
||||
userId: 'admin-1',
|
||||
tenantId: 'admin-1',
|
||||
});
|
||||
expect(client.emit).toHaveBeenCalledWith(
|
||||
'command:result',
|
||||
expect.objectContaining({ success: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('issues a durable approval only for the authenticated actor', async (): Promise<void> => {
|
||||
const commandExecutor = {
|
||||
execute: vi.fn(),
|
||||
createApproval: vi.fn().mockResolvedValue({
|
||||
approvalId: 'approval-1',
|
||||
expiresAt: '2026-07-12T00:05:00.000Z',
|
||||
}),
|
||||
};
|
||||
const gateway = buildGateway(commandExecutor);
|
||||
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||
|
||||
await gateway.handleCommandApproval(client as never, {
|
||||
command: 'gc',
|
||||
conversationId: 'conversation-1',
|
||||
});
|
||||
|
||||
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
|
||||
{ command: 'gc', conversationId: 'conversation-1' },
|
||||
{ userId: 'admin-1', tenantId: 'admin-1' },
|
||||
);
|
||||
expect(client.emit).toHaveBeenCalledWith('command:approval', {
|
||||
command: 'gc',
|
||||
conversationId: 'conversation-1',
|
||||
success: true,
|
||||
approvalId: 'approval-1',
|
||||
expiresAt: '2026-07-12T00:05:00.000Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,6 @@ import type { Auth } from '@mosaicstack/auth';
|
||||
import type { Brain } from '@mosaicstack/brain';
|
||||
import type {
|
||||
SetThinkingPayload,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandPayload,
|
||||
SystemReloadPayload,
|
||||
RoutingDecisionInfo,
|
||||
@@ -23,11 +22,6 @@ import type {
|
||||
} from '@mosaicstack/types';
|
||||
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import {
|
||||
scopeFromUser,
|
||||
type ActorTenantScope,
|
||||
type AuthenticatedUserLike,
|
||||
} from '../auth/session-scope.js';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||
@@ -46,8 +40,6 @@ interface ClientSession {
|
||||
toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>;
|
||||
/** Tool calls in-flight (started but not ended yet). */
|
||||
pendingToolCalls: Map<string, { toolName: string; args: unknown }>;
|
||||
/** Server-derived owner/tenant scope for this socket's conversation attachment. */
|
||||
scope: ActorTenantScope;
|
||||
/** Last routing decision made for this session (M4-008) */
|
||||
lastRoutingDecision?: RoutingDecisionInfo;
|
||||
}
|
||||
@@ -105,48 +97,25 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
const session = this.clientSessions.get(client.id);
|
||||
if (session) {
|
||||
session.cleanup();
|
||||
this.agentService.removeChannel(
|
||||
session.conversationId,
|
||||
`websocket:${client.id}`,
|
||||
session.scope,
|
||||
);
|
||||
this.agentService.removeChannel(session.conversationId, `websocket:${client.id}`);
|
||||
this.clientSessions.delete(client.id);
|
||||
}
|
||||
}
|
||||
|
||||
private getClientScope(client: Socket): ActorTenantScope | null {
|
||||
const user = client.data.user as AuthenticatedUserLike | undefined;
|
||||
if (!user?.id) return null;
|
||||
return scopeFromUser(user);
|
||||
}
|
||||
|
||||
private modelOverrideKey(conversationId: string, scope: ActorTenantScope): string {
|
||||
return `${scope.tenantId}:${scope.userId}:${conversationId}`;
|
||||
}
|
||||
|
||||
private scopesEqual(a: ActorTenantScope, b: ActorTenantScope): boolean {
|
||||
return a.userId === b.userId && a.tenantId === b.tenantId;
|
||||
}
|
||||
|
||||
@SubscribeMessage('message')
|
||||
async handleMessage(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: ChatSocketMessageDto,
|
||||
): Promise<void> {
|
||||
const conversationId = data.conversationId ?? uuid();
|
||||
const scope = this.getClientScope(client);
|
||||
if (!scope) {
|
||||
client.emit('error', { conversationId, error: 'Authenticated user scope is required.' });
|
||||
return;
|
||||
}
|
||||
const userId = scope.userId;
|
||||
const userId = (client.data.user as { id: string } | undefined)?.id;
|
||||
|
||||
this.logger.log(`Message from ${client.id} in conversation ${conversationId}`);
|
||||
|
||||
// Ensure agent session exists for this conversation
|
||||
let sessionRoutingDecision: RoutingDecisionInfo | undefined;
|
||||
try {
|
||||
let agentSession = this.agentService.getSession(conversationId, scope);
|
||||
let agentSession = this.agentService.getSession(conversationId);
|
||||
if (!agentSession) {
|
||||
// When resuming an existing conversation, load prior messages to inject as context (M1-004)
|
||||
const conversationHistory = await this.loadConversationHistory(conversationId, userId);
|
||||
@@ -166,7 +135,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
let resolvedProvider = data.provider;
|
||||
let resolvedModelId = data.modelId;
|
||||
|
||||
const modelOverride = modelOverrides.get(this.modelOverrideKey(conversationId, scope));
|
||||
const modelOverride = modelOverrides.get(conversationId);
|
||||
if (modelOverride) {
|
||||
// /model override bypasses routing engine (M4-007)
|
||||
resolvedModelId = modelOverride;
|
||||
@@ -203,7 +172,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
modelId: resolvedModelId,
|
||||
agentConfigId: data.agentId,
|
||||
userId,
|
||||
tenantId: scope.tenantId,
|
||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||
});
|
||||
|
||||
@@ -264,13 +232,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
}
|
||||
|
||||
// Subscribe to agent events and relay to client
|
||||
const cleanup = this.agentService.onEvent(
|
||||
conversationId,
|
||||
(event: AgentSessionEvent) => {
|
||||
this.relayEvent(client, conversationId, event);
|
||||
},
|
||||
scope,
|
||||
);
|
||||
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
||||
this.relayEvent(client, conversationId, event);
|
||||
});
|
||||
|
||||
// Preserve routing decision from the existing client session if we didn't get a new one
|
||||
const prevClientSession = this.clientSessions.get(client.id);
|
||||
@@ -282,17 +246,16 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
assistantText: '',
|
||||
toolCalls: [],
|
||||
pendingToolCalls: new Map(),
|
||||
scope,
|
||||
lastRoutingDecision: routingDecisionToStore,
|
||||
});
|
||||
|
||||
// Track channel connection
|
||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`, scope);
|
||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
||||
|
||||
// Send session info so the client knows the model/provider (M4-008: include routing decision)
|
||||
// Include agentName when a named agent config is active (M5-001)
|
||||
{
|
||||
const agentSession = this.agentService.getSession(conversationId, scope);
|
||||
const agentSession = this.agentService.getSession(conversationId);
|
||||
if (agentSession) {
|
||||
const piSession = agentSession.piSession;
|
||||
client.emit('session:info', {
|
||||
@@ -312,7 +275,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
|
||||
// Dispatch to agent
|
||||
try {
|
||||
await this.agentService.prompt(conversationId, data.content, scope);
|
||||
await this.agentService.prompt(conversationId, data.content);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
|
||||
@@ -330,16 +293,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: SetThinkingPayload,
|
||||
): void {
|
||||
const scope = this.getClientScope(client);
|
||||
if (!scope) {
|
||||
client.emit('error', {
|
||||
conversationId: data.conversationId,
|
||||
error: 'Authenticated user scope is required.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const session = this.agentService.getSession(data.conversationId, scope);
|
||||
const session = this.agentService.getSession(data.conversationId);
|
||||
if (!session) {
|
||||
client.emit('error', {
|
||||
conversationId: data.conversationId,
|
||||
@@ -380,13 +334,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
const conversationId = data.conversationId;
|
||||
this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`);
|
||||
|
||||
const scope = this.getClientScope(client);
|
||||
if (!scope) {
|
||||
client.emit('error', { conversationId, error: 'Authenticated user scope is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = this.agentService.getSession(conversationId, scope);
|
||||
const session = this.agentService.getSession(conversationId);
|
||||
if (!session) {
|
||||
client.emit('error', {
|
||||
conversationId,
|
||||
@@ -415,45 +363,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: SlashCommandPayload,
|
||||
): Promise<void> {
|
||||
const scope = this.getClientScope(client);
|
||||
if (!scope) {
|
||||
client.emit('command:result', {
|
||||
command: payload.command,
|
||||
conversationId: payload.conversationId,
|
||||
success: false,
|
||||
message: 'Authenticated user scope is required.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.commandExecutor.execute(payload, scope);
|
||||
const userId = (client.data.user as { id: string } | undefined)?.id ?? 'unknown';
|
||||
const result = await this.commandExecutor.execute(payload, userId);
|
||||
client.emit('command:result', result);
|
||||
}
|
||||
|
||||
@SubscribeMessage('command:approve')
|
||||
async handleCommandApproval(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: SlashCommandPayload,
|
||||
): Promise<void> {
|
||||
const scope = this.getClientScope(client);
|
||||
const approval = scope ? await this.commandExecutor.createApproval(payload, scope) : null;
|
||||
const result: SlashCommandApprovalResultPayload = approval
|
||||
? {
|
||||
command: payload.command,
|
||||
conversationId: payload.conversationId,
|
||||
success: true,
|
||||
approvalId: approval.approvalId,
|
||||
expiresAt: approval.expiresAt,
|
||||
}
|
||||
: {
|
||||
command: payload.command,
|
||||
conversationId: payload.conversationId,
|
||||
success: false,
|
||||
message: 'Not authorized to approve this command.',
|
||||
};
|
||||
client.emit('command:approval', result);
|
||||
}
|
||||
|
||||
broadcastReload(payload: SystemReloadPayload): void {
|
||||
this.server.emit('system:reload', payload);
|
||||
this.logger.log('Broadcasted system:reload to all connected clients');
|
||||
@@ -466,23 +380,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
* M5-005: Emits session:info to clients subscribed to this conversation when a model is set.
|
||||
* M5-007: Records a model switch in session metrics.
|
||||
*/
|
||||
setModelOverride(
|
||||
conversationId: string,
|
||||
modelName: string | null,
|
||||
scope: ActorTenantScope,
|
||||
): void {
|
||||
const key = this.modelOverrideKey(conversationId, scope);
|
||||
setModelOverride(conversationId: string, modelName: string | null): void {
|
||||
if (modelName) {
|
||||
modelOverrides.set(key, modelName);
|
||||
modelOverrides.set(conversationId, modelName);
|
||||
this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`);
|
||||
|
||||
// M5-002: Update the live session's modelId so session:info reflects the new model immediately
|
||||
this.agentService.updateSessionModel(conversationId, modelName, scope);
|
||||
this.agentService.updateSessionModel(conversationId, modelName);
|
||||
|
||||
// M5-005: Broadcast session:info to all clients subscribed to this conversation
|
||||
this.broadcastSessionInfo(conversationId, scope);
|
||||
this.broadcastSessionInfo(conversationId);
|
||||
} else {
|
||||
modelOverrides.delete(key);
|
||||
modelOverrides.delete(conversationId);
|
||||
this.logger.log(`Model override cleared: conversation=${conversationId}`);
|
||||
}
|
||||
}
|
||||
@@ -490,8 +399,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
/**
|
||||
* Return the active model override for a conversation, or undefined if none.
|
||||
*/
|
||||
getModelOverride(conversationId: string, scope: ActorTenantScope): string | undefined {
|
||||
return modelOverrides.get(this.modelOverrideKey(conversationId, scope));
|
||||
getModelOverride(conversationId: string): string | undefined {
|
||||
return modelOverrides.get(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -500,10 +409,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
*/
|
||||
broadcastSessionInfo(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo },
|
||||
): void {
|
||||
const agentSession = this.agentService.getSession(conversationId, scope);
|
||||
const agentSession = this.agentService.getSession(conversationId);
|
||||
if (!agentSession) return;
|
||||
|
||||
const piSession = agentSession.piSession;
|
||||
@@ -520,7 +428,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
|
||||
// Emit to all clients currently subscribed to this conversation
|
||||
for (const [clientId, session] of this.clientSessions) {
|
||||
if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) {
|
||||
if (session.conversationId === conversationId) {
|
||||
const socket = this.server.sockets.sockets.get(clientId);
|
||||
if (socket?.connected) {
|
||||
socket.emit('session:info', payload);
|
||||
@@ -642,10 +550,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
|
||||
case 'agent_end': {
|
||||
// Gather usage stats from the Pi session
|
||||
const activeClientSession = this.clientSessions.get(client.id);
|
||||
const agentSession = activeClientSession
|
||||
? this.agentService.getSession(conversationId, activeClientSession.scope)
|
||||
: undefined;
|
||||
const agentSession = this.agentService.getSession(conversationId);
|
||||
const piSession = agentSession?.piSession;
|
||||
const stats = piSession?.getSessionStats();
|
||||
const contextUsage = piSession?.getContextUsage();
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
|
||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||
|
||||
const adminCommand: CommandDef = {
|
||||
name: 'gc',
|
||||
description: 'GC',
|
||||
aliases: [],
|
||||
scope: 'admin',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
};
|
||||
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
||||
|
||||
function createService(role: string): CommandAuthorizationService {
|
||||
const entries = new Map<string, string>();
|
||||
const db = {
|
||||
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
|
||||
};
|
||||
const redis = {
|
||||
get: async (key: string) => entries.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
entries.set(key, value);
|
||||
},
|
||||
del: async (key: string) => Number(entries.delete(key)),
|
||||
};
|
||||
return new CommandAuthorizationService(db as never, redis);
|
||||
}
|
||||
|
||||
describe('CommandAuthorizationService', () => {
|
||||
it('consumes one exact actor-bound approval once', async (): Promise<void> => {
|
||||
const service = createService('admin');
|
||||
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
|
||||
expect(approval).not.toBeNull();
|
||||
expect(
|
||||
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an approval when the structured action is mutated', async (): Promise<void> => {
|
||||
const service = createService('admin');
|
||||
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
|
||||
const mutated = { ...payload, conversationId: 'other-conversation' };
|
||||
expect(approval).not.toBeNull();
|
||||
expect(
|
||||
(await service.authorize(adminCommand, mutated, 'admin-1', approval!.approvalId)).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('denies an admin command to a member before approval is considered', async (): Promise<void> => {
|
||||
const service = createService('member');
|
||||
const approval = await service.createApproval(adminCommand, payload, 'member-1');
|
||||
expect(approval).toBeNull();
|
||||
expect(
|
||||
(await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { eq, users as usersTable, type Db } from '@mosaicstack/db';
|
||||
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
|
||||
export type CommandRole = 'admin' | 'member' | 'viewer';
|
||||
|
||||
export interface CommandApproval {
|
||||
approvalId: string;
|
||||
actionDigest: string;
|
||||
actorId: string;
|
||||
command: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface CommandAuthorizationResult {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CommandAuthorizationService {
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(COMMANDS_REDIS)
|
||||
private readonly redis: {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string, ...args: string[]): Promise<unknown>;
|
||||
del(key: string): Promise<number>;
|
||||
},
|
||||
) {}
|
||||
|
||||
async authorize(
|
||||
command: CommandDef,
|
||||
payload: SlashCommandPayload,
|
||||
actorId: string,
|
||||
approvalId?: string,
|
||||
): Promise<CommandAuthorizationResult> {
|
||||
const role = await this.resolveRole(actorId);
|
||||
if (!role || !this.hasScope(role, command.scope)) {
|
||||
return { allowed: false, reason: 'not authorized for this command scope' };
|
||||
}
|
||||
if (command.scope !== 'admin') return { allowed: true };
|
||||
if (!approvalId) return { allowed: false, reason: 'durable approval is required' };
|
||||
const actionDigest = this.actionDigest(command.name, payload);
|
||||
const approved = await this.consumeApproval(approvalId, actorId, actionDigest);
|
||||
return approved
|
||||
? { allowed: true }
|
||||
: {
|
||||
allowed: false,
|
||||
reason: 'approval is invalid, expired, replayed, or does not match this action',
|
||||
};
|
||||
}
|
||||
|
||||
async createApproval(
|
||||
command: CommandDef,
|
||||
payload: SlashCommandPayload,
|
||||
actorId: string,
|
||||
): Promise<CommandApproval | null> {
|
||||
const role = await this.resolveRole(actorId);
|
||||
if (!role || command.scope !== 'admin' || !this.hasScope(role, command.scope)) return null;
|
||||
|
||||
const approvalId = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
|
||||
const approval: CommandApproval = {
|
||||
approvalId,
|
||||
actionDigest: this.actionDigest(command.name, payload),
|
||||
actorId,
|
||||
command: command.name,
|
||||
expiresAt,
|
||||
};
|
||||
await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300');
|
||||
return approval;
|
||||
}
|
||||
|
||||
private async resolveRole(actorId: string): Promise<CommandRole | null> {
|
||||
const [user] = await this.db
|
||||
.select({ role: usersTable.role })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, actorId))
|
||||
.limit(1);
|
||||
const role = user?.role;
|
||||
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
|
||||
}
|
||||
|
||||
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
|
||||
if (role === 'admin') return true;
|
||||
return role === 'member' && (scope === 'core' || scope === 'agent');
|
||||
}
|
||||
|
||||
private async consumeApproval(
|
||||
approvalId: string,
|
||||
actorId: string,
|
||||
actionDigest: string,
|
||||
): Promise<boolean> {
|
||||
const key = this.key(approvalId);
|
||||
const encoded = await this.redis.get(key);
|
||||
if (!encoded) return false;
|
||||
const parsed: unknown = JSON.parse(encoded);
|
||||
if (
|
||||
!this.isApproval(parsed) ||
|
||||
parsed.actorId !== actorId ||
|
||||
parsed.actionDigest !== actionDigest ||
|
||||
Date.parse(parsed.expiresAt) <= Date.now()
|
||||
)
|
||||
return false;
|
||||
return (await this.redis.del(key)) === 1;
|
||||
}
|
||||
|
||||
private actionDigest(command: string, payload: SlashCommandPayload): string {
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
command,
|
||||
args: payload.args?.trim() ?? '',
|
||||
conversationId: payload.conversationId,
|
||||
}),
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
private isApproval(value: unknown): value is CommandApproval {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'approvalId' in value &&
|
||||
'actionDigest' in value &&
|
||||
'actorId' in value &&
|
||||
'expiresAt' in value
|
||||
);
|
||||
}
|
||||
|
||||
private key(approvalId: string): string {
|
||||
return `tess:command-approval:${approvalId}`;
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,6 @@ function buildService(): CommandExecutorService {
|
||||
describe('CommandExecutorService — P8-012 commands', () => {
|
||||
let service: CommandExecutorService;
|
||||
const userId = 'user-123';
|
||||
const userScope = { userId, tenantId: userId };
|
||||
const conversationId = 'conv-456';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -100,7 +99,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /provider login — missing provider name
|
||||
it('/provider login with no provider name returns usage error', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Usage: /provider login');
|
||||
expect(result.command).toBe('provider');
|
||||
@@ -113,7 +112,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
args: 'login anthropic',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('provider');
|
||||
expect(result.message).toContain('anthropic');
|
||||
@@ -139,7 +138,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /provider with no args — returns usage
|
||||
it('/provider with no args returns usage message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Usage: /provider');
|
||||
});
|
||||
@@ -147,7 +146,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /provider list
|
||||
it('/provider list returns success', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('provider');
|
||||
});
|
||||
@@ -155,7 +154,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /provider logout with no name — usage error
|
||||
it('/provider logout with no name returns error', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Usage: /provider logout');
|
||||
});
|
||||
@@ -167,7 +166,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
args: 'unknown',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Unknown subcommand');
|
||||
});
|
||||
@@ -175,7 +174,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /mission status
|
||||
it('/mission status returns stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('mission');
|
||||
expect(result.message).toContain('Mission status');
|
||||
@@ -184,7 +183,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /mission with no args
|
||||
it('/mission with no args returns status stub', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'mission', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Mission status');
|
||||
});
|
||||
@@ -196,7 +195,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
args: 'set my-mission-123',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('my-mission-123');
|
||||
});
|
||||
@@ -204,7 +203,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /agent list
|
||||
it('/agent list returns stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('agent');
|
||||
expect(result.message).toContain('agent');
|
||||
@@ -213,7 +212,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /agent with no args
|
||||
it('/agent with no args returns usage', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'agent', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Usage: /agent');
|
||||
});
|
||||
@@ -225,7 +224,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
args: 'my-agent-id',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('my-agent-id');
|
||||
});
|
||||
@@ -233,7 +232,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /prdy
|
||||
it('/prdy returns PRD wizard message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('prdy');
|
||||
expect(result.message).toContain('mosaic prdy');
|
||||
@@ -242,7 +241,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /tools
|
||||
it('/tools returns tools stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'tools', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('tools');
|
||||
expect(result.message).toContain('tools');
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
|
||||
const registry = {
|
||||
getManifest: vi.fn(() => ({
|
||||
version: 1,
|
||||
commands: [
|
||||
{
|
||||
name: 'gc',
|
||||
description: 'System-wide garbage collection',
|
||||
aliases: [],
|
||||
scope: 'admin' as const,
|
||||
execution: 'socket' as const,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
skills: [],
|
||||
})),
|
||||
};
|
||||
|
||||
const sessionGc = {
|
||||
sweepOrphans: vi.fn().mockResolvedValue({ orphanedSessions: 1, totalCleaned: [], duration: 1 }),
|
||||
};
|
||||
|
||||
const scope = (userId: string) => ({ userId, tenantId: 'tenant-1' });
|
||||
|
||||
const authorization = {
|
||||
authorize: vi.fn((_command: unknown, _payload: unknown, actorId: string) =>
|
||||
Promise.resolve(
|
||||
actorId === 'member-1'
|
||||
? { allowed: false, reason: 'durable approval is required' }
|
||||
: { allowed: false, reason: 'not authorized for this command scope' },
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
registry as never,
|
||||
{ getSession: vi.fn() } as never,
|
||||
{ clear: vi.fn(), set: vi.fn() } as never,
|
||||
sessionGc as never,
|
||||
{ set: vi.fn() } as never,
|
||||
{ agents: {} } as never,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
authorizationService as never,
|
||||
);
|
||||
}
|
||||
|
||||
function createDurableAuthorization(): CommandAuthorizationService {
|
||||
const entries = new Map<string, string>();
|
||||
const db = {
|
||||
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }) }),
|
||||
};
|
||||
const redis = {
|
||||
get: async (key: string) => entries.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
entries.set(key, value);
|
||||
},
|
||||
del: async (key: string) => Number(entries.delete(key)),
|
||||
};
|
||||
return new CommandAuthorizationService(db as never, redis);
|
||||
}
|
||||
|
||||
describe('TESS-M1-SEC-001 command authorization abuse cases', () => {
|
||||
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
||||
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('denies a forged admin identity and does not execute a system-wide command', async (): Promise<void> => {
|
||||
const result = await buildExecutor().execute(payload, scope('admin-forged-by-client'));
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('not authorized');
|
||||
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies a privileged command without a server-bound durable approval', async (): Promise<void> => {
|
||||
const result = await buildExecutor().execute(payload, scope('member-1'));
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('approval');
|
||||
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('executes an admin command only after a valid durable approval is issued and supplied', async (): Promise<void> => {
|
||||
const executor = buildExecutor(createDurableAuthorization());
|
||||
|
||||
const adminScope = scope('admin-1');
|
||||
const denied = await executor.execute(payload, adminScope);
|
||||
const approval = await executor.createApproval(payload, adminScope);
|
||||
const approved = await executor.execute(
|
||||
{ ...payload, approvalId: approval?.approvalId },
|
||||
adminScope,
|
||||
);
|
||||
|
||||
expect(denied.success).toBe(false);
|
||||
expect(denied.message).toContain('approval');
|
||||
expect(approval).not.toBeNull();
|
||||
expect(approved.success).toBe(true);
|
||||
expect(sessionGc.sweepOrphans).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import type { QueueHandle } from '@mosaicstack/queue';
|
||||
import type { Brain } from '@mosaicstack/brain';
|
||||
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
@@ -11,7 +10,6 @@ import { ReloadService } from '../reload/reload.service.js';
|
||||
import { McpClientService } from '../mcp-client/mcp-client.service.js';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -23,7 +21,10 @@ export class CommandExecutorService {
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
||||
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
|
||||
@Optional()
|
||||
@Inject(COMMANDS_REDIS)
|
||||
private readonly redis: QueueHandle['redis'] | null,
|
||||
@Inject(BRAIN) private readonly brain: Brain,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ReloadService))
|
||||
@@ -34,17 +35,10 @@ export class CommandExecutorService {
|
||||
@Optional()
|
||||
@Inject(McpClientService)
|
||||
private readonly mcpClient: McpClientService | null,
|
||||
@Optional()
|
||||
@Inject(CommandAuthorizationService)
|
||||
private readonly authorization: CommandAuthorizationService | null = null,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
payload: SlashCommandPayload,
|
||||
scope: ActorTenantScope,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
|
||||
const { command, args, conversationId } = payload;
|
||||
const userId = scope.userId;
|
||||
|
||||
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
||||
if (!def) {
|
||||
@@ -56,24 +50,14 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
|
||||
const authorization = await this.authorization?.authorize(
|
||||
def,
|
||||
payload,
|
||||
userId,
|
||||
payload.approvalId,
|
||||
);
|
||||
if (authorization && !authorization.allowed) {
|
||||
return { command, conversationId, success: false, message: authorization.reason };
|
||||
}
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'model':
|
||||
return await this.handleModel(args ?? null, conversationId, scope);
|
||||
return await this.handleModel(args ?? null, conversationId);
|
||||
case 'thinking':
|
||||
return await this.handleThinking(args ?? null, conversationId);
|
||||
case 'system':
|
||||
return await this.handleSystem(args ?? null, conversationId, scope);
|
||||
return await this.handleSystem(args ?? null, conversationId);
|
||||
case 'new':
|
||||
return {
|
||||
command,
|
||||
@@ -113,7 +97,7 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
case 'agent':
|
||||
return await this.handleAgent(args ?? null, conversationId, scope);
|
||||
return await this.handleAgent(args ?? null, conversationId, userId);
|
||||
case 'provider':
|
||||
return await this.handleProvider(args ?? null, userId, conversationId);
|
||||
case 'mission':
|
||||
@@ -162,22 +146,13 @@ export class CommandExecutorService {
|
||||
}
|
||||
}
|
||||
|
||||
async createApproval(payload: SlashCommandPayload, scope: ActorTenantScope) {
|
||||
const def = this.registry
|
||||
.getManifest()
|
||||
.commands.find((command) => command.name === payload.command);
|
||||
if (!def || !this.authorization) return null;
|
||||
return this.authorization.createApproval(def, payload, scope.userId);
|
||||
}
|
||||
|
||||
private async handleModel(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!args || args.trim().length === 0) {
|
||||
// Show current override or usage hint
|
||||
const currentOverride = this.chatGateway?.getModelOverride(conversationId, scope);
|
||||
const currentOverride = this.chatGateway?.getModelOverride(conversationId);
|
||||
if (currentOverride) {
|
||||
return {
|
||||
command: 'model',
|
||||
@@ -199,7 +174,7 @@ export class CommandExecutorService {
|
||||
|
||||
// /model clear removes the override and re-enables automatic routing
|
||||
if (modelName === 'clear') {
|
||||
this.chatGateway?.setModelOverride(conversationId, null, scope);
|
||||
this.chatGateway?.setModelOverride(conversationId, null);
|
||||
return {
|
||||
command: 'model',
|
||||
conversationId,
|
||||
@@ -209,9 +184,9 @@ export class CommandExecutorService {
|
||||
}
|
||||
|
||||
// Set the sticky per-session override (M4-007)
|
||||
this.chatGateway?.setModelOverride(conversationId, modelName, scope);
|
||||
this.chatGateway?.setModelOverride(conversationId, modelName);
|
||||
|
||||
const session = this.agentService.getSession(conversationId, scope);
|
||||
const session = this.agentService.getSession(conversationId);
|
||||
if (!session) {
|
||||
return {
|
||||
command: 'model',
|
||||
@@ -252,11 +227,10 @@ export class CommandExecutorService {
|
||||
private async handleSystem(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!args || args.trim().length === 0) {
|
||||
// Clear the override when called with no args
|
||||
await this.systemOverride.clear(conversationId, scope);
|
||||
await this.systemOverride.clear(conversationId);
|
||||
return {
|
||||
command: 'system',
|
||||
conversationId,
|
||||
@@ -265,7 +239,7 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
|
||||
await this.systemOverride.set(conversationId, args.trim(), scope);
|
||||
await this.systemOverride.set(conversationId, args.trim());
|
||||
return {
|
||||
command: 'system',
|
||||
conversationId,
|
||||
@@ -277,9 +251,8 @@ export class CommandExecutorService {
|
||||
private async handleAgent(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
userId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
const userId = scope.userId;
|
||||
if (!args) {
|
||||
return {
|
||||
command: 'agent',
|
||||
@@ -368,14 +341,11 @@ export class CommandExecutorService {
|
||||
conversationId,
|
||||
agentConfig.id,
|
||||
agentConfig.name,
|
||||
scope,
|
||||
agentConfig.model ?? undefined,
|
||||
);
|
||||
|
||||
// Broadcast updated session:info so TUI TopBar reflects new agent/model
|
||||
this.chatGateway?.broadcastSessionInfo(conversationId, scope, {
|
||||
agentName: agentConfig.name,
|
||||
});
|
||||
this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name });
|
||||
|
||||
this.logger.log(
|
||||
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
|
||||
@@ -436,14 +406,16 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
const pollToken = crypto.randomUUID();
|
||||
const key = `mosaic:auth:poll:${pollToken}`;
|
||||
// Store pending state in Valkey (TTL 5 minutes)
|
||||
await this.redis.set(
|
||||
key,
|
||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||
'EX',
|
||||
300,
|
||||
);
|
||||
const pollKey = `mosaic:auth:poll:${pollToken}`;
|
||||
if (this.redis) {
|
||||
// Store pending state in Valkey (TTL 5 minutes)
|
||||
await this.redis.set(
|
||||
pollKey,
|
||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||
'EX',
|
||||
300,
|
||||
);
|
||||
}
|
||||
// In production this would construct an OAuth URL
|
||||
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
|
||||
return {
|
||||
|
||||
@@ -159,7 +159,6 @@ describe('CommandExecutorService — integration', () => {
|
||||
let registry: CommandRegistryService;
|
||||
let executor: CommandExecutorService;
|
||||
const userId = 'user-integ-001';
|
||||
const userScope = { userId, tenantId: userId };
|
||||
const conversationId = 'conv-integ-001';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -171,7 +170,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// Unknown command returns error
|
||||
it('unknown command returns success:false with descriptive message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'nonexistent', conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('nonexistent');
|
||||
expect(result.command).toBe('nonexistent');
|
||||
@@ -180,7 +179,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg)
|
||||
it('/gc calls SessionGCService.sweepOrphans without arguments', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'gc', conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('GC sweep complete');
|
||||
@@ -191,8 +190,8 @@ describe('CommandExecutorService — integration', () => {
|
||||
it('/system with text calls SystemOverrideService.set', async () => {
|
||||
const override = 'You are a helpful assistant.';
|
||||
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('override set');
|
||||
});
|
||||
@@ -200,8 +199,8 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /system with no args clears the override
|
||||
it('/system with no args calls SystemOverrideService.clear', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'system', conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('cleared');
|
||||
});
|
||||
@@ -213,7 +212,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
args: 'claude-3-opus',
|
||||
conversationId,
|
||||
};
|
||||
const result = await executor.execute(payload, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('model');
|
||||
expect(result.message).toContain('claude-3-opus');
|
||||
@@ -222,7 +221,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /thinking with valid level returns success
|
||||
it('/thinking with valid level returns success', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('high');
|
||||
});
|
||||
@@ -230,7 +229,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /thinking with invalid level returns usage message
|
||||
it('/thinking with invalid level returns usage message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Usage:');
|
||||
});
|
||||
@@ -238,7 +237,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /new command returns success
|
||||
it('/new returns success', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'new', conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('new');
|
||||
});
|
||||
@@ -246,7 +245,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /reload without reloadService returns failure
|
||||
it('/reload without ReloadService returns failure', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'reload', conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('ReloadService');
|
||||
});
|
||||
@@ -256,7 +255,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
for (const cmd of stubCommands) {
|
||||
it(`/${cmd} returns success (stub)`, async () => {
|
||||
const payload: SlashCommandPayload = { command: cmd, conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe(cmd);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
||||
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 { GCModule } from '../gc/gc.module.js';
|
||||
import { ReloadModule } from '../reload/reload.module.js';
|
||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
@@ -15,25 +16,32 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
providers: [
|
||||
{
|
||||
provide: COMMANDS_QUEUE_HANDLE,
|
||||
useFactory: (): QueueHandle => {
|
||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||
// 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();
|
||||
},
|
||||
inject: [MOSAIC_CONFIG],
|
||||
},
|
||||
{
|
||||
provide: COMMANDS_REDIS,
|
||||
useFactory: (handle: QueueHandle) => handle.redis,
|
||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||
inject: [COMMANDS_QUEUE_HANDLE],
|
||||
},
|
||||
CommandRegistryService,
|
||||
CommandAuthorizationService,
|
||||
CommandExecutorService,
|
||||
],
|
||||
exports: [CommandRegistryService, CommandExecutorService],
|
||||
})
|
||||
export class CommandsModule implements OnApplicationShutdown {
|
||||
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(COMMANDS_QUEUE_HANDLE)
|
||||
private readonly handle: QueueHandle | null,
|
||||
) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle.close().catch(() => {});
|
||||
await this.handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { EnrollmentController } from './enrollment.controller.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
import { FederationController } from './federation.controller.js';
|
||||
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
||||
import { GetController } from './server/verbs/get.controller.js';
|
||||
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
|
||||
import { GrantsService } from './grants.service.js';
|
||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
||||
@@ -12,7 +14,13 @@ import { ListController } from './server/verbs/list.controller.js';
|
||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
||||
controllers: [
|
||||
EnrollmentController,
|
||||
FederationController,
|
||||
CapabilitiesController,
|
||||
ListController,
|
||||
GetController,
|
||||
],
|
||||
providers: [
|
||||
AdminGuard,
|
||||
CaService,
|
||||
@@ -23,6 +31,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
FederationGetQueryService,
|
||||
],
|
||||
exports: [
|
||||
CaService,
|
||||
@@ -33,6 +42,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
FederationGetQueryService,
|
||||
],
|
||||
})
|
||||
export class FederationModule {}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
311
apps/gateway/src/federation/server/verbs/get-query.service.ts
Normal file
311
apps/gateway/src/federation/server/verbs/get-query.service.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
}
|
||||
100
apps/gateway/src/federation/server/verbs/get.controller.ts
Normal file
100
apps/gateway/src/federation/server/verbs/get.controller.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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,5 +1,7 @@
|
||||
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
|
||||
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
|
||||
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 { REDIS } from './gc.tokens.js';
|
||||
|
||||
@@ -9,13 +11,17 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||
providers: [
|
||||
{
|
||||
provide: GC_QUEUE_HANDLE,
|
||||
useFactory: (): QueueHandle => {
|
||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||
// 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();
|
||||
},
|
||||
inject: [MOSAIC_CONFIG],
|
||||
},
|
||||
{
|
||||
provide: REDIS,
|
||||
useFactory: (handle: QueueHandle) => handle.redis,
|
||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||
inject: [GC_QUEUE_HANDLE],
|
||||
},
|
||||
SessionGCService,
|
||||
@@ -23,9 +29,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||
exports: [SessionGCService],
|
||||
})
|
||||
export class GCModule implements OnApplicationShutdown {
|
||||
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(GC_QUEUE_HANDLE)
|
||||
private readonly handle: QueueHandle | null,
|
||||
) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle.close().catch(() => {});
|
||||
await this.handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger, Optional, type OnModuleInit } from '@nestjs/common';
|
||||
import type { QueueHandle } from '@mosaicstack/queue';
|
||||
import type { LogService } from '@mosaicstack/log';
|
||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||
@@ -32,11 +32,21 @@ export class SessionGCService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SessionGCService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||
// On Local tier there is no Redis — the GC module provides null for this token.
|
||||
// NOTE: if a future feature stores Redis-backed state on Local tier, this guard
|
||||
// would silently skip GC for those keys. Revisit when that happens.
|
||||
@Optional()
|
||||
@Inject(REDIS)
|
||||
private readonly redis: QueueHandle['redis'] | null,
|
||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (!this.redis) {
|
||||
// Local tier: no Valkey — skip cold-start GC entirely (correct no-op).
|
||||
this.logger.log('SessionGCService: Valkey GC skipped on local tier (no Redis configured)');
|
||||
return;
|
||||
}
|
||||
// Fire-and-forget: run full GC asynchronously so it does not block the
|
||||
// NestJS bootstrap chain. Cold-start GC typically takes 100–500 ms
|
||||
// depending on Valkey key count; deferring it removes that latency from
|
||||
@@ -60,8 +70,10 @@ export class SessionGCService implements OnModuleInit {
|
||||
* 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
|
||||
* duration, which can cause latency spikes under production key volumes.
|
||||
* Returns empty array when Redis is not available (Local tier).
|
||||
*/
|
||||
private async scanKeys(pattern: string): Promise<string[]> {
|
||||
if (!this.redis) return [];
|
||||
const collected: string[] = [];
|
||||
let cursor = '0';
|
||||
do {
|
||||
@@ -78,12 +90,14 @@ export class SessionGCService implements OnModuleInit {
|
||||
async collect(sessionId: string): Promise<GCResult> {
|
||||
const result: GCResult = { sessionId, cleaned: {} };
|
||||
|
||||
// 1. Valkey: delete all session-scoped keys
|
||||
const pattern = `mosaic:session:${sessionId}:*`;
|
||||
const valkeyKeys = await this.scanKeys(pattern);
|
||||
if (valkeyKeys.length > 0) {
|
||||
await this.redis.del(...valkeyKeys);
|
||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||
// 1. Valkey: delete all session-scoped keys (skipped on Local tier)
|
||||
if (this.redis) {
|
||||
const pattern = `mosaic:session:${sessionId}:*`;
|
||||
const valkeyKeys = await this.scanKeys(pattern);
|
||||
if (valkeyKeys.length > 0) {
|
||||
await this.redis.del(...valkeyKeys);
|
||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. PG: demote hot-tier agent_logs for this session to warm
|
||||
@@ -106,6 +120,7 @@ export class SessionGCService implements OnModuleInit {
|
||||
const cleaned: GCResult[] = [];
|
||||
|
||||
// 1. Find all session-scoped Valkey keys (non-blocking SCAN)
|
||||
// Returns empty on Local tier — no Valkey session keys exist there.
|
||||
const allSessionKeys = await this.scanKeys('mosaic:session:*');
|
||||
|
||||
// Extract unique session IDs from keys
|
||||
@@ -136,11 +151,15 @@ export class SessionGCService implements OnModuleInit {
|
||||
*/
|
||||
async fullCollect(): Promise<FullGCResult> {
|
||||
const start = Date.now();
|
||||
let valkeyKeysCount = 0;
|
||||
|
||||
// 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN)
|
||||
const sessionKeys = await this.scanKeys('mosaic:session:*');
|
||||
if (sessionKeys.length > 0) {
|
||||
await this.redis.del(...sessionKeys);
|
||||
if (this.redis) {
|
||||
// 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN)
|
||||
const sessionKeys = await this.scanKeys('mosaic:session:*');
|
||||
if (sessionKeys.length > 0) {
|
||||
await this.redis.del(...sessionKeys);
|
||||
}
|
||||
valkeyKeysCount = sessionKeys.length;
|
||||
}
|
||||
|
||||
// 2. NOTE: channel keys are NOT collected on cold start
|
||||
@@ -154,7 +173,7 @@ export class SessionGCService implements OnModuleInit {
|
||||
const jobsPurged = 0;
|
||||
|
||||
return {
|
||||
valkeyKeys: sessionKeys.length,
|
||||
valkeyKeys: valkeyKeysCount,
|
||||
logsDemoted,
|
||||
jobsPurged,
|
||||
tempFilesRemoved: 0,
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
|
||||
@Injectable()
|
||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CronService.name);
|
||||
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
|
||||
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
|
||||
|
||||
constructor(
|
||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||
@@ -28,6 +28,16 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// On Local tier BullMQ is disabled — skip all job scheduling.
|
||||
// NOTE: this means summarization, tier management, and Valkey GC jobs do not
|
||||
// run on Local installs. For a single-user local install this is acceptable.
|
||||
// If periodic background work is needed on Local in the future, add a
|
||||
// setInterval-based scheduler here.
|
||||
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 tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||
const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am
|
||||
@@ -42,7 +52,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
||||
await this.summarization.runSummarization();
|
||||
});
|
||||
this.registeredWorkers.push(summarizationWorker);
|
||||
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
|
||||
|
||||
// M6-005: Tier management repeatable job
|
||||
await this.queueService.addRepeatableJob(
|
||||
@@ -54,14 +64,14 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
||||
await this.summarization.runTierManagement();
|
||||
});
|
||||
this.registeredWorkers.push(tierWorker);
|
||||
if (tierWorker) this.registeredWorkers.push(tierWorker);
|
||||
|
||||
// M6-004: GC repeatable job
|
||||
await this.queueService.addRepeatableJob(QUEUE_GC, 'session-gc', {}, gcSchedule);
|
||||
const gcWorker = this.queueService.registerWorker(QUEUE_GC, async () => {
|
||||
await this.sessionGC.sweepOrphans();
|
||||
});
|
||||
this.registeredWorkers.push(gcWorker);
|
||||
if (gcWorker) this.registeredWorkers.push(gcWorker);
|
||||
|
||||
this.logger.log(
|
||||
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`,
|
||||
|
||||
@@ -3,11 +3,7 @@ import { Logger } from '@nestjs/common';
|
||||
import { fromNodeHeaders } from 'better-auth/node';
|
||||
import type { Auth } from '@mosaicstack/auth';
|
||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import {
|
||||
createMcpActorContext,
|
||||
deriveMcpToolScopesForUser,
|
||||
type McpService,
|
||||
} from './mcp.service.js';
|
||||
import type { McpService } from './mcp.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
|
||||
/**
|
||||
@@ -71,25 +67,14 @@ async function handleMcpRequest(
|
||||
return;
|
||||
}
|
||||
|
||||
const authUser = result.user as {
|
||||
id: string;
|
||||
role?: string | null;
|
||||
tenantId?: string | null;
|
||||
organizationId?: string | null;
|
||||
};
|
||||
const actor = createMcpActorContext({
|
||||
userId: authUser.id,
|
||||
role: authUser.role,
|
||||
tenantId: authUser.tenantId ?? authUser.organizationId ?? undefined,
|
||||
scopes: deriveMcpToolScopesForUser({ role: authUser.role }),
|
||||
});
|
||||
const userId = result.user.id;
|
||||
|
||||
// ─── Session routing ─────────────────────────────────────────────────────
|
||||
const sessionId = req.raw.headers['mcp-session-id'];
|
||||
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
||||
// Existing session request
|
||||
const transport = mcpService.getSession(sessionId, actor);
|
||||
const transport = mcpService.getSession(sessionId);
|
||||
if (!transport) {
|
||||
logger.warn(`MCP session not found: ${sessionId}`);
|
||||
reply.raw.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
@@ -127,10 +112,8 @@ async function handleMcpRequest(
|
||||
}
|
||||
|
||||
// Create new session and handle this initializing request
|
||||
const { transport } = mcpService.createSession(actor);
|
||||
logger.log(
|
||||
`New MCP session created for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
|
||||
);
|
||||
const { transport } = mcpService.createSession(userId);
|
||||
logger.log(`New MCP session created for user ${userId}`);
|
||||
|
||||
await transport.handleRequest(req.raw, reply.raw, body);
|
||||
}
|
||||
|
||||
@@ -1,461 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { z } from 'zod';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import type { Brain } from '@mosaicstack/brain';
|
||||
import type { Memory } from '@mosaicstack/memory';
|
||||
import type { EmbeddingService } from '../memory/embedding.service.js';
|
||||
import type { CoordService } from '../coord/coord.service.js';
|
||||
import {
|
||||
assertMcpToolAuthorized,
|
||||
createMcpActorContext,
|
||||
deriveMcpToolScopesForUser,
|
||||
MCP_TOOL_SCOPES,
|
||||
McpService,
|
||||
type McpToolName,
|
||||
} from './mcp.service.js';
|
||||
|
||||
type ToolResult = { content: Array<{ type: 'text'; text: string }> };
|
||||
type ToolHandler = (params: Record<string, unknown>) => Promise<ToolResult>;
|
||||
|
||||
interface CapturedTool {
|
||||
inputSchema: z.ZodType;
|
||||
handler: ToolHandler;
|
||||
}
|
||||
|
||||
function makeCapturingServer(): { server: McpServer; tools: Map<string, CapturedTool> } {
|
||||
const tools = new Map<string, CapturedTool>();
|
||||
const server = {
|
||||
registerTool(name: string, config: { inputSchema: z.ZodType }, handler: ToolHandler): void {
|
||||
tools.set(name, { inputSchema: config.inputSchema, handler });
|
||||
},
|
||||
};
|
||||
return { server: server as unknown as McpServer, tools };
|
||||
}
|
||||
|
||||
function makeService(opts?: {
|
||||
projects?: Array<Record<string, unknown> & { id: string; ownerId?: string | null }>;
|
||||
missions?: Array<
|
||||
Record<string, unknown> & { id: string; projectId?: string | null; userId?: string | null }
|
||||
>;
|
||||
tasks?: Array<
|
||||
Record<string, unknown> & {
|
||||
id: string;
|
||||
projectId?: string | null;
|
||||
missionId?: string | null;
|
||||
status?: string;
|
||||
}
|
||||
>;
|
||||
}) {
|
||||
const projects = opts?.projects ?? [];
|
||||
const missions = opts?.missions ?? [];
|
||||
const tasks = opts?.tasks ?? [];
|
||||
const brain = {
|
||||
projects: {
|
||||
findAll: vi.fn(async () => projects),
|
||||
findById: vi.fn(async (id: string) => projects.find((project) => project.id === id) ?? null),
|
||||
},
|
||||
tasks: {
|
||||
findAll: vi.fn(async () => tasks),
|
||||
findById: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null),
|
||||
findByProject: vi.fn(async (projectId: string) =>
|
||||
tasks.filter((task) => task.projectId === projectId),
|
||||
),
|
||||
findByMission: vi.fn(async (missionId: string) =>
|
||||
tasks.filter((task) => task.missionId === missionId),
|
||||
),
|
||||
findByStatus: vi.fn(async (status: string) => tasks.filter((task) => task.status === status)),
|
||||
create: vi.fn(async (task: Record<string, unknown>) => ({ id: 'task-1', ...task })),
|
||||
update: vi.fn(async (id: string, updates: Record<string, unknown>) => ({ id, ...updates })),
|
||||
},
|
||||
missions: {
|
||||
findAll: vi.fn(async () => missions),
|
||||
findById: vi.fn(async (id: string) => missions.find((mission) => mission.id === id) ?? null),
|
||||
findByProject: vi.fn(async (projectId: string) =>
|
||||
missions.filter((mission) => mission.projectId === projectId),
|
||||
),
|
||||
},
|
||||
conversations: {
|
||||
findAll: vi.fn(async (userId: string) => [{ id: 'conversation-1', userId }]),
|
||||
},
|
||||
} as unknown as Brain;
|
||||
|
||||
const memory = {
|
||||
insights: {
|
||||
searchByEmbedding: vi.fn(async (userId: string) => [{ id: 'insight-1', userId }]),
|
||||
create: vi.fn(async (insight: Record<string, unknown>) => ({ id: 'insight-2', ...insight })),
|
||||
},
|
||||
preferences: {
|
||||
findByUser: vi.fn(async (userId: string) => [{ key: 'theme', userId }]),
|
||||
findByUserAndCategory: vi.fn(async (userId: string, category: string) => [
|
||||
{ key: 'theme', userId, category },
|
||||
]),
|
||||
upsert: vi.fn(async (preference: Record<string, unknown>) => ({
|
||||
id: 'pref-1',
|
||||
...preference,
|
||||
})),
|
||||
},
|
||||
} as unknown as Memory;
|
||||
|
||||
const embeddings = {
|
||||
available: true,
|
||||
embed: vi.fn(async () => [0.1, 0.2, 0.3]),
|
||||
} as unknown as EmbeddingService;
|
||||
|
||||
const coord = {
|
||||
getMissionStatus: vi.fn(async () => null),
|
||||
listTasks: vi.fn(async () => []),
|
||||
getTaskStatus: vi.fn(async () => null),
|
||||
} as unknown as CoordService;
|
||||
|
||||
return {
|
||||
service: new McpService(brain, memory, embeddings, coord),
|
||||
brain: brain as unknown as {
|
||||
conversations: { findAll: ReturnType<typeof vi.fn> };
|
||||
tasks: {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
},
|
||||
memory: memory as unknown as {
|
||||
insights: { searchByEmbedding: ReturnType<typeof vi.fn> };
|
||||
},
|
||||
coord: coord as unknown as {
|
||||
listTasks: ReturnType<typeof vi.fn>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getTool(tools: Map<string, CapturedTool>, name: McpToolName): CapturedTool {
|
||||
const tool = tools.get(name);
|
||||
if (!tool) throw new Error(`Missing captured tool ${name}`);
|
||||
return tool;
|
||||
}
|
||||
|
||||
function makeMemberActor(userId = 'authenticated-user') {
|
||||
return createMcpActorContext({
|
||||
userId,
|
||||
role: 'member',
|
||||
scopes: deriveMcpToolScopesForUser({ role: 'member' }),
|
||||
});
|
||||
}
|
||||
|
||||
function makeAdminActor(userId = 'admin-user', tenantId?: string) {
|
||||
return createMcpActorContext({
|
||||
userId,
|
||||
tenantId,
|
||||
role: 'admin',
|
||||
scopes: deriveMcpToolScopesForUser({ role: 'admin' }),
|
||||
});
|
||||
}
|
||||
|
||||
function makePlatformAdminActor(userId = 'platform-admin-user') {
|
||||
return createMcpActorContext({
|
||||
userId,
|
||||
role: 'platform-admin',
|
||||
scopes: deriveMcpToolScopesForUser({ role: 'platform-admin' }),
|
||||
});
|
||||
}
|
||||
|
||||
describe('MCP actor identity and tool scope enforcement', () => {
|
||||
it('derives immutable actor, tenant, channel, correlation, and explicit tool scopes server-side', () => {
|
||||
const actor = createMcpActorContext({
|
||||
userId: ' user-authenticated ',
|
||||
role: 'member',
|
||||
scopes: deriveMcpToolScopesForUser({ role: 'member' }),
|
||||
});
|
||||
|
||||
expect(actor.userId).toBe('user-authenticated');
|
||||
expect(actor.tenantId).toBe('user:user-authenticated');
|
||||
expect(actor.role).toBe('member');
|
||||
expect(actor.channel).toBe('mcp');
|
||||
expect(actor.correlationId).toMatch(/[0-9a-f-]{36}/i);
|
||||
expect(actor.scopes.has(MCP_TOOL_SCOPES.memory_search)).toBe(true);
|
||||
expect(actor.scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false);
|
||||
expect(
|
||||
deriveMcpToolScopesForUser({ role: 'admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
|
||||
).toBe(false);
|
||||
expect(
|
||||
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
|
||||
const actor = createMcpActorContext({ userId: 'user-authenticated' });
|
||||
|
||||
expect(actor.scopes.size).toBe(0);
|
||||
expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow(
|
||||
'MCP tool scope denied: memory:insight:read',
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed when a tool caller supplies actor or tenant identity fields', () => {
|
||||
const actor = createMcpActorContext({ userId: 'user-authenticated' });
|
||||
|
||||
expect(() =>
|
||||
assertMcpToolAuthorized(actor, 'memory_search', {
|
||||
userId: 'victim-user',
|
||||
query: 'private data',
|
||||
}),
|
||||
).toThrow('MCP caller-controlled identity field is forbidden: userId');
|
||||
|
||||
expect(() =>
|
||||
assertMcpToolAuthorized(actor, 'coord_list_tasks', {
|
||||
tenantId: 'victim-tenant',
|
||||
projectPath: '/tmp/project',
|
||||
}),
|
||||
).toThrow('MCP caller-controlled identity field is forbidden: tenantId');
|
||||
|
||||
expect(() =>
|
||||
assertMcpToolAuthorized(actor, 'brain_create_task', {
|
||||
title: 'forged org',
|
||||
organizationId: 'victim-org',
|
||||
}),
|
||||
).toThrow('MCP caller-controlled identity field is forbidden: organizationId');
|
||||
|
||||
expect(() =>
|
||||
assertMcpToolAuthorized(actor, 'brain_update_task', {
|
||||
title: 'forged team',
|
||||
teamId: 'victim-team',
|
||||
}),
|
||||
).toThrow('MCP caller-controlled identity field is forbidden: teamId');
|
||||
});
|
||||
|
||||
it('fails closed when the server-derived actor lacks the required per-tool scope', () => {
|
||||
const actor = createMcpActorContext({ userId: 'user-authenticated', scopes: [] });
|
||||
|
||||
expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow(
|
||||
'MCP tool scope denied: memory:insight:read',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes caller-controlled userId from memory schemas and never queries victim memory', async () => {
|
||||
const { service, memory } = makeService();
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeMemberActor('authenticated-user');
|
||||
|
||||
service.registerTools(server, actor);
|
||||
const tool = getTool(tools, 'memory_search');
|
||||
|
||||
expect(tool.inputSchema.safeParse({ userId: 'victim-user', query: 'anything' }).success).toBe(
|
||||
false,
|
||||
);
|
||||
await expect(tool.handler({ userId: 'victim-user', query: 'anything' })).rejects.toThrow(
|
||||
'MCP caller-controlled identity field is forbidden: userId',
|
||||
);
|
||||
expect(memory.insights.searchByEmbedding).not.toHaveBeenCalled();
|
||||
|
||||
await tool.handler({ query: 'only my notes' });
|
||||
expect(memory.insights.searchByEmbedding).toHaveBeenCalledWith(
|
||||
'authenticated-user',
|
||||
[0.1, 0.2, 0.3],
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
it('binds conversation listing to the authenticated actor instead of a caller-supplied userId', async () => {
|
||||
const { service, brain } = makeService();
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeMemberActor('authenticated-user');
|
||||
|
||||
service.registerTools(server, actor);
|
||||
const tool = getTool(tools, 'brain_list_conversations');
|
||||
|
||||
expect(tool.inputSchema.safeParse({ userId: 'victim-user' }).success).toBe(false);
|
||||
await expect(tool.handler({ userId: 'victim-user' })).rejects.toThrow(
|
||||
'MCP caller-controlled identity field is forbidden: userId',
|
||||
);
|
||||
expect(brain.conversations.findAll).not.toHaveBeenCalled();
|
||||
|
||||
await tool.handler({});
|
||||
expect(brain.conversations.findAll).toHaveBeenCalledWith('authenticated-user');
|
||||
});
|
||||
|
||||
it('scopes brain project, mission, and task reads to the authenticated actor', async () => {
|
||||
const { service } = makeService({
|
||||
projects: [
|
||||
{ id: 'project-owned', ownerId: 'authenticated-user', name: 'owned' },
|
||||
{ id: 'project-victim', ownerId: 'victim-user', name: 'victim' },
|
||||
],
|
||||
missions: [
|
||||
{ id: 'mission-owned', projectId: 'project-owned' },
|
||||
{ id: 'mission-victim', userId: 'victim-user', projectId: 'project-victim' },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'task-owned-project', projectId: 'project-owned', status: 'not-started' },
|
||||
{ id: 'task-owned-mission', missionId: 'mission-owned', status: 'not-started' },
|
||||
{ id: 'task-victim-project', projectId: 'project-victim', status: 'not-started' },
|
||||
{ id: 'task-unowned', status: 'not-started' },
|
||||
],
|
||||
});
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeMemberActor('authenticated-user');
|
||||
|
||||
service.registerTools(server, actor);
|
||||
|
||||
const projects = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']);
|
||||
|
||||
const missions = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']);
|
||||
|
||||
const tasks = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(tasks.map((task: { id: string }) => task.id)).toEqual([
|
||||
'task-owned-project',
|
||||
'task-owned-mission',
|
||||
]);
|
||||
});
|
||||
|
||||
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
|
||||
const { service } = makeService({
|
||||
projects: [
|
||||
{
|
||||
id: 'project-tenant-a',
|
||||
ownerId: 'other-user-a',
|
||||
teamId: 'tenant-a',
|
||||
name: 'same tenant',
|
||||
},
|
||||
{
|
||||
id: 'project-tenant-b',
|
||||
ownerId: 'other-user-b',
|
||||
teamId: 'tenant-b',
|
||||
name: 'other tenant',
|
||||
},
|
||||
],
|
||||
missions: [
|
||||
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
|
||||
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
|
||||
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
|
||||
],
|
||||
});
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
|
||||
service.registerTools(server, actor);
|
||||
|
||||
const projects = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']);
|
||||
|
||||
const missions = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']);
|
||||
|
||||
const tasks = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']);
|
||||
});
|
||||
|
||||
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
|
||||
const { service, brain } = makeService({
|
||||
projects: [
|
||||
{ id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
|
||||
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
|
||||
],
|
||||
missions: [
|
||||
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
|
||||
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
|
||||
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
|
||||
],
|
||||
});
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
|
||||
service.registerTools(server, actor);
|
||||
|
||||
await expect(
|
||||
getTool(tools, 'brain_create_task').handler({
|
||||
title: 'unscoped tenant write',
|
||||
}),
|
||||
).rejects.toThrow('MCP task scope denied');
|
||||
expect(brain.tasks.create).not.toHaveBeenCalled();
|
||||
|
||||
await expect(
|
||||
getTool(tools, 'brain_create_task').handler({
|
||||
title: 'cross-tenant write',
|
||||
projectId: 'project-tenant-b',
|
||||
}),
|
||||
).rejects.toThrow('MCP task project scope denied');
|
||||
expect(brain.tasks.create).not.toHaveBeenCalled();
|
||||
|
||||
await expect(
|
||||
getTool(tools, 'brain_update_task').handler({
|
||||
id: 'task-tenant-a',
|
||||
projectId: 'project-tenant-b',
|
||||
}),
|
||||
).rejects.toThrow('MCP task project scope denied');
|
||||
expect(brain.tasks.update).not.toHaveBeenCalled();
|
||||
|
||||
const updateResult = await getTool(tools, 'brain_update_task').handler({
|
||||
id: 'task-tenant-b',
|
||||
title: 'cross-tenant update',
|
||||
});
|
||||
expect(updateResult.content[0]!.text).toBe('Task not found: task-tenant-b');
|
||||
expect(brain.tasks.update).not.toHaveBeenCalled();
|
||||
|
||||
await getTool(tools, 'brain_create_task').handler({
|
||||
title: 'same-tenant write',
|
||||
projectId: 'project-tenant-a',
|
||||
});
|
||||
expect(brain.tasks.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ projectId: 'project-tenant-a', title: 'same-tenant write' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps admin-only coordination tools on server-derived paths', async () => {
|
||||
const { service, coord } = makeService();
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const member = makeMemberActor('authenticated-user');
|
||||
const tenantAdmin = makeAdminActor('admin-user');
|
||||
const platformAdmin = makePlatformAdminActor('platform-admin-user');
|
||||
|
||||
service.registerTools(server, member);
|
||||
const memberTool = getTool(tools, 'coord_list_tasks');
|
||||
expect(memberTool.inputSchema.safeParse({ projectPath: '/tmp/victim' }).success).toBe(false);
|
||||
await expect(memberTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
|
||||
|
||||
tools.clear();
|
||||
service.registerTools(server, tenantAdmin);
|
||||
const tenantAdminTool = getTool(tools, 'coord_list_tasks');
|
||||
await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
|
||||
|
||||
tools.clear();
|
||||
service.registerTools(server, platformAdmin);
|
||||
const platformAdminTool = getTool(tools, 'coord_list_tasks');
|
||||
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
|
||||
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
|
||||
});
|
||||
|
||||
it('does not attach a guessed or stale-scope MCP session to another authenticated context', async () => {
|
||||
const { service } = makeService();
|
||||
const owner = makeMemberActor('owner-user');
|
||||
const attacker = makeMemberActor('attacker-user');
|
||||
const admin = makeAdminActor('admin-user');
|
||||
const downgradedAdmin = makeMemberActor('admin-user');
|
||||
|
||||
const { sessionId, transport } = service.createSession(owner);
|
||||
const staleSession = service.createSession(admin);
|
||||
|
||||
expect(service.getSession(sessionId, owner)).toBe(transport);
|
||||
expect(service.getSession(sessionId, attacker)).toBeNull();
|
||||
expect(service.getSession(sessionId, owner)).toBe(transport);
|
||||
expect(service.getSession(staleSession.sessionId, downgradedAdmin)).toBeNull();
|
||||
expect(service.getSession(staleSession.sessionId, admin)).toBe(staleSession.transport);
|
||||
|
||||
await service.onModuleDestroy();
|
||||
});
|
||||
});
|
||||
@@ -10,216 +10,11 @@ import { MEMORY } from '../memory/memory.tokens.js';
|
||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
||||
import { CoordService } from '../coord/coord.service.js';
|
||||
|
||||
export const MCP_CALLER_IDENTITY_FIELDS = [
|
||||
'actorId',
|
||||
'actor',
|
||||
'authenticatedUserId',
|
||||
'channel',
|
||||
'organizationId',
|
||||
'ownerId',
|
||||
'sessionUserId',
|
||||
'teamId',
|
||||
'tenant',
|
||||
'tenantId',
|
||||
'user',
|
||||
'userId',
|
||||
] as const;
|
||||
|
||||
type McpCallerIdentityField = (typeof MCP_CALLER_IDENTITY_FIELDS)[number];
|
||||
|
||||
export const MCP_TOOL_SCOPES = {
|
||||
brain_list_projects: 'brain:project:read',
|
||||
brain_get_project: 'brain:project:read',
|
||||
brain_list_tasks: 'brain:task:read',
|
||||
brain_create_task: 'brain:task:write',
|
||||
brain_update_task: 'brain:task:write',
|
||||
brain_list_missions: 'brain:mission:read',
|
||||
brain_list_conversations: 'brain:conversation:read',
|
||||
memory_search: 'memory:insight:read',
|
||||
memory_get_preferences: 'memory:preference:read',
|
||||
memory_save_preference: 'memory:preference:write',
|
||||
memory_save_insight: 'memory:insight:write',
|
||||
coord_mission_status: 'coord:read',
|
||||
coord_list_tasks: 'coord:read',
|
||||
coord_task_detail: 'coord:read',
|
||||
} as const;
|
||||
|
||||
export type McpToolName = keyof typeof MCP_TOOL_SCOPES;
|
||||
export type McpToolScope = (typeof MCP_TOOL_SCOPES)[McpToolName];
|
||||
|
||||
export interface McpActorContext {
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
role: string;
|
||||
channel: 'mcp';
|
||||
correlationId: string;
|
||||
scopes: ReadonlySet<McpToolScope>;
|
||||
}
|
||||
|
||||
interface SessionEntry {
|
||||
server: McpServer;
|
||||
transport: StreamableHTTPServerTransport;
|
||||
createdAt: Date;
|
||||
actor: McpActorContext;
|
||||
}
|
||||
|
||||
const GLOBAL_ADMIN_MCP_SCOPES = new Set<McpToolScope>(Object.values(MCP_TOOL_SCOPES));
|
||||
const TENANT_ADMIN_MCP_SCOPES = new Set<McpToolScope>([
|
||||
MCP_TOOL_SCOPES.brain_list_projects,
|
||||
MCP_TOOL_SCOPES.brain_get_project,
|
||||
MCP_TOOL_SCOPES.brain_list_tasks,
|
||||
MCP_TOOL_SCOPES.brain_create_task,
|
||||
MCP_TOOL_SCOPES.brain_update_task,
|
||||
MCP_TOOL_SCOPES.brain_list_missions,
|
||||
MCP_TOOL_SCOPES.brain_list_conversations,
|
||||
MCP_TOOL_SCOPES.memory_search,
|
||||
MCP_TOOL_SCOPES.memory_get_preferences,
|
||||
MCP_TOOL_SCOPES.memory_save_preference,
|
||||
MCP_TOOL_SCOPES.memory_save_insight,
|
||||
]);
|
||||
const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
|
||||
MCP_TOOL_SCOPES.brain_list_projects,
|
||||
MCP_TOOL_SCOPES.brain_get_project,
|
||||
MCP_TOOL_SCOPES.brain_list_tasks,
|
||||
MCP_TOOL_SCOPES.brain_list_missions,
|
||||
MCP_TOOL_SCOPES.brain_list_conversations,
|
||||
MCP_TOOL_SCOPES.memory_search,
|
||||
MCP_TOOL_SCOPES.memory_get_preferences,
|
||||
MCP_TOOL_SCOPES.memory_save_preference,
|
||||
MCP_TOOL_SCOPES.memory_save_insight,
|
||||
]);
|
||||
|
||||
export function deriveMcpToolScopesForUser(input: {
|
||||
role?: string | null;
|
||||
}): ReadonlySet<McpToolScope> {
|
||||
if (input.role === 'platform-admin' || input.role === 'super-admin') {
|
||||
return new Set(GLOBAL_ADMIN_MCP_SCOPES);
|
||||
}
|
||||
if (input.role === 'admin') {
|
||||
return new Set(TENANT_ADMIN_MCP_SCOPES);
|
||||
}
|
||||
return new Set(MEMBER_MCP_SCOPES);
|
||||
}
|
||||
|
||||
export function createMcpActorContext(input: {
|
||||
userId: string;
|
||||
tenantId?: string;
|
||||
role?: string | null;
|
||||
scopes?: Iterable<McpToolScope>;
|
||||
correlationId?: string;
|
||||
}): McpActorContext {
|
||||
const userId = input.userId.trim();
|
||||
if (userId.length === 0) {
|
||||
throw new Error('MCP authenticated user is required');
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
tenantId: input.tenantId?.trim() || `user:${userId}`,
|
||||
role: input.role ?? 'member',
|
||||
channel: 'mcp',
|
||||
correlationId: input.correlationId ?? randomUUID(),
|
||||
scopes: new Set(input.scopes ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertNoCallerControlledIdentity(params: unknown): void {
|
||||
if (params === null || typeof params !== 'object') return;
|
||||
|
||||
const keys = new Set(Object.keys(params));
|
||||
const forbidden = MCP_CALLER_IDENTITY_FIELDS.find((field: McpCallerIdentityField) =>
|
||||
keys.has(field),
|
||||
);
|
||||
if (forbidden) {
|
||||
throw new Error(`MCP caller-controlled identity field is forbidden: ${forbidden}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertMcpToolAuthorized(
|
||||
actor: McpActorContext,
|
||||
toolName: McpToolName,
|
||||
params: unknown,
|
||||
): void {
|
||||
assertNoCallerControlledIdentity(params);
|
||||
const requiredScope = MCP_TOOL_SCOPES[toolName];
|
||||
if (!actor.scopes.has(requiredScope)) {
|
||||
throw new Error(`MCP tool scope denied: ${requiredScope}`);
|
||||
}
|
||||
}
|
||||
|
||||
function strictObject<T extends z.ZodRawShape>(shape: T): z.ZodObject<T> {
|
||||
return z.object(shape).strict();
|
||||
}
|
||||
|
||||
type TenantScopedLike = {
|
||||
tenantId?: string | null;
|
||||
organizationId?: string | null;
|
||||
teamId?: string | null;
|
||||
};
|
||||
type ProjectLike = TenantScopedLike & { id: string; ownerId?: string | null };
|
||||
type MissionLike = TenantScopedLike & {
|
||||
id: string;
|
||||
projectId?: string | null;
|
||||
userId?: string | null;
|
||||
};
|
||||
type TaskLike = TenantScopedLike & {
|
||||
projectId?: string | null;
|
||||
missionId?: string | null;
|
||||
userId?: string | null;
|
||||
};
|
||||
|
||||
function isGlobalAdminActor(actor: McpActorContext): boolean {
|
||||
return actor.role === 'platform-admin' || actor.role === 'super-admin';
|
||||
}
|
||||
|
||||
function isTenantAdminActor(actor: McpActorContext): boolean {
|
||||
return actor.role === 'admin';
|
||||
}
|
||||
|
||||
function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean {
|
||||
return (
|
||||
record.tenantId === actor.tenantId ||
|
||||
record.organizationId === actor.tenantId ||
|
||||
record.teamId === actor.tenantId
|
||||
);
|
||||
}
|
||||
|
||||
function filterProjectsForActor<T extends ProjectLike>(actor: McpActorContext, projects: T[]): T[] {
|
||||
if (isGlobalAdminActor(actor)) return projects;
|
||||
return projects.filter(
|
||||
(project) =>
|
||||
project.ownerId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, project)),
|
||||
);
|
||||
}
|
||||
|
||||
function filterMissionsByDirectActorScope<T extends MissionLike>(
|
||||
actor: McpActorContext,
|
||||
missions: T[],
|
||||
): T[] {
|
||||
if (isGlobalAdminActor(actor)) return missions;
|
||||
return missions.filter(
|
||||
(mission) =>
|
||||
mission.userId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, mission)),
|
||||
);
|
||||
}
|
||||
|
||||
function scopesEqual(left: ReadonlySet<McpToolScope>, right: ReadonlySet<McpToolScope>): boolean {
|
||||
if (left.size !== right.size) return false;
|
||||
for (const scope of left) {
|
||||
if (!right.has(scope)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sameActorAuthorization(stored: McpActorContext, current: McpActorContext): boolean {
|
||||
return (
|
||||
stored.userId === current.userId &&
|
||||
stored.tenantId === current.tenantId &&
|
||||
stored.role === current.role &&
|
||||
scopesEqual(stored.scopes, current.scopes)
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -238,18 +33,13 @@ export class McpService implements OnModuleDestroy {
|
||||
* Creates a new MCP session with its own server + transport pair.
|
||||
* Returns the transport for use by the controller.
|
||||
*/
|
||||
createSession(actor: McpActorContext): {
|
||||
sessionId: string;
|
||||
transport: StreamableHTTPServerTransport;
|
||||
} {
|
||||
createSession(userId: string): { sessionId: string; transport: StreamableHTTPServerTransport } {
|
||||
const sessionId = randomUUID();
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => sessionId,
|
||||
onsessioninitialized: (id) => {
|
||||
this.logger.log(
|
||||
`MCP session initialized: ${id} for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
|
||||
);
|
||||
this.logger.log(`MCP session initialized: ${id} for user ${userId}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -258,7 +48,7 @@ export class McpService implements OnModuleDestroy {
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
this.registerTools(server, actor);
|
||||
this.registerTools(server, userId);
|
||||
|
||||
transport.onclose = () => {
|
||||
this.logger.log(`MCP session closed: ${sessionId}`);
|
||||
@@ -271,126 +61,31 @@ export class McpService implements OnModuleDestroy {
|
||||
);
|
||||
});
|
||||
|
||||
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), actor });
|
||||
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), userId });
|
||||
return { sessionId, transport };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the transport for an existing session only when it belongs to the
|
||||
* currently authenticated MCP actor. Guessed or cross-tenant session IDs grant
|
||||
* no authority.
|
||||
* Returns the transport for an existing session, or null if not found.
|
||||
*/
|
||||
getSession(sessionId: string, actor: McpActorContext): StreamableHTTPServerTransport | null {
|
||||
const entry = this.sessions.get(sessionId);
|
||||
if (!entry) return null;
|
||||
if (!sameActorAuthorization(entry.actor, actor)) {
|
||||
this.logger.warn(
|
||||
`MCP session actor or scope mismatch: session=${sessionId} actor=${actor.userId} tenant=${actor.tenantId} role=${actor.role}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return entry.transport;
|
||||
}
|
||||
|
||||
private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise<boolean> {
|
||||
if (isGlobalAdminActor(actor)) return true;
|
||||
const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined;
|
||||
return project ? filterProjectsForActor(actor, [project]).length === 1 : false;
|
||||
}
|
||||
|
||||
private async filterMissionsForActor<T extends MissionLike>(
|
||||
actor: McpActorContext,
|
||||
missions: T[],
|
||||
): Promise<T[]> {
|
||||
if (isGlobalAdminActor(actor)) return missions;
|
||||
|
||||
const projects = (await this.brain.projects.findAll()) as ProjectLike[];
|
||||
const projectIds = new Set(
|
||||
filterProjectsForActor(actor, projects).map((project) => project.id),
|
||||
);
|
||||
|
||||
return missions.filter(
|
||||
(mission) =>
|
||||
filterMissionsByDirectActorScope(actor, [mission]).length === 1 ||
|
||||
(typeof mission.projectId === 'string' && projectIds.has(mission.projectId)),
|
||||
);
|
||||
}
|
||||
|
||||
private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise<boolean> {
|
||||
if (isGlobalAdminActor(actor)) return true;
|
||||
const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined;
|
||||
if (!mission) return false;
|
||||
return (await this.filterMissionsForActor(actor, [mission])).length === 1;
|
||||
}
|
||||
|
||||
private async assertTaskReferencesAuthorized(
|
||||
actor: McpActorContext,
|
||||
refs: { projectId?: string | null; missionId?: string | null },
|
||||
): Promise<void> {
|
||||
if (refs.projectId && !(await this.isProjectAuthorized(actor, refs.projectId))) {
|
||||
throw new Error('MCP task project scope denied');
|
||||
}
|
||||
if (refs.missionId && !(await this.isMissionAuthorized(actor, refs.missionId))) {
|
||||
throw new Error('MCP task mission scope denied');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertTaskCreateScopeAuthorized(
|
||||
actor: McpActorContext,
|
||||
refs: { projectId?: string | null; missionId?: string | null },
|
||||
): Promise<void> {
|
||||
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
|
||||
throw new Error('MCP task scope denied');
|
||||
}
|
||||
await this.assertTaskReferencesAuthorized(actor, refs);
|
||||
}
|
||||
|
||||
private async filterTasksForActor<T extends TaskLike>(
|
||||
actor: McpActorContext,
|
||||
tasks: T[],
|
||||
): Promise<T[]> {
|
||||
if (isGlobalAdminActor(actor)) return tasks;
|
||||
|
||||
const [projects, missions] = await Promise.all([
|
||||
this.brain.projects.findAll(),
|
||||
this.brain.missions.findAll(),
|
||||
]);
|
||||
const projectIds = new Set(
|
||||
filterProjectsForActor(actor, projects as ProjectLike[]).map((project) => project.id),
|
||||
);
|
||||
const missionIds = new Set(
|
||||
(await this.filterMissionsForActor(actor, missions as MissionLike[])).map(
|
||||
(mission) => mission.id,
|
||||
),
|
||||
);
|
||||
|
||||
return tasks.filter(
|
||||
(task) =>
|
||||
task.userId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, task)) ||
|
||||
(typeof task.projectId === 'string' && projectIds.has(task.projectId)) ||
|
||||
(typeof task.missionId === 'string' && missionIds.has(task.missionId)),
|
||||
);
|
||||
getSession(sessionId: string): StreamableHTTPServerTransport | null {
|
||||
return this.sessions.get(sessionId)?.transport ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers all platform tools on the given McpServer instance.
|
||||
*/
|
||||
registerTools(server: McpServer, actor: McpActorContext): void {
|
||||
private registerTools(server: McpServer, _userId: string): void {
|
||||
// ─── Brain: Project tools ────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
'brain_list_projects',
|
||||
{
|
||||
description: 'List all projects in the brain.',
|
||||
inputSchema: strictObject({}),
|
||||
inputSchema: z.object({}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_list_projects', params);
|
||||
const projects = filterProjectsForActor(
|
||||
actor,
|
||||
(await this.brain.projects.findAll()) as ProjectLike[],
|
||||
);
|
||||
async () => {
|
||||
const projects = await this.brain.projects.findAll();
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
|
||||
};
|
||||
@@ -401,21 +96,17 @@ export class McpService implements OnModuleDestroy {
|
||||
'brain_get_project',
|
||||
{
|
||||
description: 'Get a project by ID.',
|
||||
inputSchema: strictObject({
|
||||
inputSchema: z.object({
|
||||
id: z.string().describe('Project ID (UUID)'),
|
||||
}),
|
||||
},
|
||||
async ({ id, ...params }) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_get_project', params);
|
||||
const project = (await this.brain.projects.findById(id)) as ProjectLike | undefined;
|
||||
const authorizedProject = project ? filterProjectsForActor(actor, [project])[0] : undefined;
|
||||
async ({ id }) => {
|
||||
const project = await this.brain.projects.findById(id);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: authorizedProject
|
||||
? JSON.stringify(authorizedProject, null, 2)
|
||||
: `Project not found: ${id}`,
|
||||
text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -428,23 +119,20 @@ export class McpService implements OnModuleDestroy {
|
||||
'brain_list_tasks',
|
||||
{
|
||||
description: 'List tasks, optionally filtered by project, mission, or status.',
|
||||
inputSchema: strictObject({
|
||||
inputSchema: z.object({
|
||||
projectId: z.string().optional().describe('Filter by project ID'),
|
||||
missionId: z.string().optional().describe('Filter by mission ID'),
|
||||
status: z.string().optional().describe('Filter by status'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_list_tasks', params);
|
||||
const { projectId, missionId, status } = params;
|
||||
async ({ projectId, missionId, status }) => {
|
||||
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
let tasks;
|
||||
if (projectId) tasks = await this.brain.tasks.findByProject(projectId);
|
||||
else if (missionId) tasks = await this.brain.tasks.findByMission(missionId);
|
||||
else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus);
|
||||
else tasks = await this.brain.tasks.findAll();
|
||||
const scopedTasks = await this.filterTasksForActor(actor, tasks as TaskLike[]);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(scopedTasks, null, 2) }] };
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -452,7 +140,7 @@ export class McpService implements OnModuleDestroy {
|
||||
'brain_create_task',
|
||||
{
|
||||
description: 'Create a new task in the brain.',
|
||||
inputSchema: strictObject({
|
||||
inputSchema: z.object({
|
||||
title: z.string().describe('Task title'),
|
||||
description: z.string().optional().describe('Task description'),
|
||||
projectId: z.string().optional().describe('Project ID'),
|
||||
@@ -461,8 +149,6 @@ export class McpService implements OnModuleDestroy {
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_create_task', params);
|
||||
await this.assertTaskCreateScopeAuthorized(actor, params);
|
||||
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||
const task = await this.brain.tasks.create({
|
||||
...params,
|
||||
@@ -476,7 +162,7 @@ export class McpService implements OnModuleDestroy {
|
||||
'brain_update_task',
|
||||
{
|
||||
description: 'Update an existing task.',
|
||||
inputSchema: strictObject({
|
||||
inputSchema: z.object({
|
||||
id: z.string().describe('Task ID'),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
@@ -485,17 +171,9 @@ export class McpService implements OnModuleDestroy {
|
||||
.optional()
|
||||
.describe('not-started, in-progress, blocked, done, cancelled'),
|
||||
priority: z.string().optional(),
|
||||
projectId: z.string().optional().describe('Project ID'),
|
||||
missionId: z.string().optional().describe('Mission ID'),
|
||||
}),
|
||||
},
|
||||
async ({ id, ...updates }) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_update_task', updates);
|
||||
const existing = (await this.brain.tasks.findById(id)) as TaskLike | undefined;
|
||||
if (!existing || (await this.filterTasksForActor(actor, [existing])).length === 0) {
|
||||
return { content: [{ type: 'text' as const, text: `Task not found: ${id}` }] };
|
||||
}
|
||||
await this.assertTaskReferencesAuthorized(actor, updates);
|
||||
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||
const task = await this.brain.tasks.update(id, {
|
||||
@@ -520,19 +198,14 @@ export class McpService implements OnModuleDestroy {
|
||||
'brain_list_missions',
|
||||
{
|
||||
description: 'List all missions, optionally filtered by project.',
|
||||
inputSchema: strictObject({
|
||||
inputSchema: z.object({
|
||||
projectId: z.string().optional().describe('Filter by project ID'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_list_missions', params);
|
||||
const { projectId } = params;
|
||||
const missions = await this.filterMissionsForActor(
|
||||
actor,
|
||||
(projectId
|
||||
? await this.brain.missions.findByProject(projectId)
|
||||
: await this.brain.missions.findAll()) as MissionLike[],
|
||||
);
|
||||
async ({ projectId }) => {
|
||||
const missions = projectId
|
||||
? await this.brain.missions.findByProject(projectId)
|
||||
: await this.brain.missions.findAll();
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] };
|
||||
},
|
||||
);
|
||||
@@ -540,12 +213,13 @@ export class McpService implements OnModuleDestroy {
|
||||
server.registerTool(
|
||||
'brain_list_conversations',
|
||||
{
|
||||
description: 'List conversations for the authenticated MCP actor.',
|
||||
inputSchema: strictObject({}),
|
||||
description: 'List conversations for a user.',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_list_conversations', params);
|
||||
const conversations = await this.brain.conversations.findAll(actor.userId);
|
||||
async ({ userId }) => {
|
||||
const conversations = await this.brain.conversations.findAll(userId);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
|
||||
};
|
||||
@@ -558,15 +232,14 @@ export class McpService implements OnModuleDestroy {
|
||||
'memory_search',
|
||||
{
|
||||
description:
|
||||
'Search stored insights and knowledge for the authenticated MCP actor using natural language.',
|
||||
inputSchema: strictObject({
|
||||
'Search across stored insights and knowledge using natural language. Returns semantically similar results.',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID to search memory for'),
|
||||
query: z.string().describe('Natural language search query'),
|
||||
limit: z.number().optional().describe('Max results (default 5)'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'memory_search', params);
|
||||
const { query, limit } = params;
|
||||
async ({ userId, query, limit }) => {
|
||||
if (!this.embeddings.available) {
|
||||
return {
|
||||
content: [
|
||||
@@ -578,11 +251,7 @@ export class McpService implements OnModuleDestroy {
|
||||
};
|
||||
}
|
||||
const embedding = await this.embeddings.embed(query);
|
||||
const results = await this.memory.insights.searchByEmbedding(
|
||||
actor.userId,
|
||||
embedding,
|
||||
limit ?? 5,
|
||||
);
|
||||
const results = await this.memory.insights.searchByEmbedding(userId, embedding, limit ?? 5);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] };
|
||||
},
|
||||
);
|
||||
@@ -590,21 +259,20 @@ export class McpService implements OnModuleDestroy {
|
||||
server.registerTool(
|
||||
'memory_get_preferences',
|
||||
{
|
||||
description: 'Retrieve stored preferences for the authenticated MCP actor.',
|
||||
inputSchema: strictObject({
|
||||
description: 'Retrieve stored preferences for a user.',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID'),
|
||||
category: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Filter by category: communication, coding, workflow, appearance, general'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'memory_get_preferences', params);
|
||||
const { category } = params;
|
||||
async ({ userId, category }) => {
|
||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||
const prefs = category
|
||||
? await this.memory.preferences.findByUserAndCategory(actor.userId, category as Cat)
|
||||
: await this.memory.preferences.findByUser(actor.userId);
|
||||
? await this.memory.preferences.findByUserAndCategory(userId, category as Cat)
|
||||
: await this.memory.preferences.findByUser(userId);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] };
|
||||
},
|
||||
);
|
||||
@@ -613,8 +281,9 @@ export class McpService implements OnModuleDestroy {
|
||||
'memory_save_preference',
|
||||
{
|
||||
description:
|
||||
'Store a learned preference for the authenticated MCP actor (e.g., "prefers tables over paragraphs").',
|
||||
inputSchema: strictObject({
|
||||
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID'),
|
||||
key: z.string().describe('Preference key'),
|
||||
value: z.string().describe('Preference value (JSON string)'),
|
||||
category: z
|
||||
@@ -623,9 +292,7 @@ export class McpService implements OnModuleDestroy {
|
||||
.describe('Category: communication, coding, workflow, appearance, general'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'memory_save_preference', params);
|
||||
const { key, value, category } = params;
|
||||
async ({ userId, key, value, category }) => {
|
||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
@@ -634,7 +301,7 @@ export class McpService implements OnModuleDestroy {
|
||||
parsedValue = value;
|
||||
}
|
||||
const pref = await this.memory.preferences.upsert({
|
||||
userId: actor.userId,
|
||||
userId,
|
||||
key,
|
||||
value: parsedValue,
|
||||
category: (category as Cat) ?? 'general',
|
||||
@@ -648,8 +315,9 @@ export class McpService implements OnModuleDestroy {
|
||||
'memory_save_insight',
|
||||
{
|
||||
description:
|
||||
'Store a learned insight, decision, or knowledge for the authenticated MCP actor.',
|
||||
inputSchema: strictObject({
|
||||
'Store a learned insight, decision, or knowledge extracted from the current interaction.',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID'),
|
||||
content: z.string().describe('The insight or knowledge to store'),
|
||||
category: z
|
||||
.string()
|
||||
@@ -657,13 +325,11 @@ export class McpService implements OnModuleDestroy {
|
||||
.describe('Category: decision, learning, preference, fact, pattern, general'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'memory_save_insight', params);
|
||||
const { content, category } = params;
|
||||
async ({ userId, content, category }) => {
|
||||
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
||||
const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null;
|
||||
const insight = await this.memory.insights.create({
|
||||
userId: actor.userId,
|
||||
userId,
|
||||
content,
|
||||
embedding,
|
||||
source: 'agent',
|
||||
@@ -680,11 +346,16 @@ export class McpService implements OnModuleDestroy {
|
||||
{
|
||||
description:
|
||||
'Get the current orchestration mission status including milestones, tasks, and active session.',
|
||||
inputSchema: strictObject({}),
|
||||
inputSchema: z.object({
|
||||
projectPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Project path. Defaults to gateway working directory.'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'coord_mission_status', params);
|
||||
const status = await this.coordService.getMissionStatus(process.cwd());
|
||||
async ({ projectPath }) => {
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const status = await this.coordService.getMissionStatus(resolvedPath);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -700,11 +371,16 @@ export class McpService implements OnModuleDestroy {
|
||||
'coord_list_tasks',
|
||||
{
|
||||
description: 'List all tasks from the orchestration TASKS.md file.',
|
||||
inputSchema: strictObject({}),
|
||||
inputSchema: z.object({
|
||||
projectPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Project path. Defaults to gateway working directory.'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'coord_list_tasks', params);
|
||||
const tasks = await this.coordService.listTasks(process.cwd());
|
||||
async ({ projectPath }) => {
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const tasks = await this.coordService.listTasks(resolvedPath);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
||||
},
|
||||
);
|
||||
@@ -713,14 +389,17 @@ export class McpService implements OnModuleDestroy {
|
||||
'coord_task_detail',
|
||||
{
|
||||
description: 'Get detailed status for a specific orchestration task.',
|
||||
inputSchema: strictObject({
|
||||
inputSchema: z.object({
|
||||
taskId: z.string().describe('Task ID (e.g. P2-005)'),
|
||||
projectPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Project path. Defaults to gateway working directory.'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'coord_task_detail', params);
|
||||
const { taskId } = params;
|
||||
const detail = await this.coordService.getTaskStatus(process.cwd(), taskId);
|
||||
async ({ taskId, projectPath }) => {
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const detail = await this.coordService.getTaskStatus(resolvedPath, taskId);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
|
||||
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
||||
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
||||
const SESSION_SYSTEM_KEY = (sessionId: string, scope: ActorTenantScope) =>
|
||||
`mosaic:session:${scopedSessionId(sessionId, scope)}:system`;
|
||||
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string, scope: ActorTenantScope) =>
|
||||
`mosaic:session:${scopedSessionId(sessionId, scope)}:system:fragments`;
|
||||
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
|
||||
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>
|
||||
`mosaic:session:${sessionId}:system:fragments`;
|
||||
const SYSTEM_OVERRIDE_TTL_SECONDS = 604800; // 7 days
|
||||
|
||||
interface OverrideFragment {
|
||||
@@ -15,18 +13,56 @@ interface OverrideFragment {
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SystemOverrideService {
|
||||
private readonly logger = new Logger(SystemOverrideService.name);
|
||||
private readonly handle: QueueHandle;
|
||||
interface LocalOverrideEntry {
|
||||
condensed: string;
|
||||
fragments: OverrideFragment[];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.handle = createQueue();
|
||||
@Injectable()
|
||||
export class SystemOverrideService implements OnApplicationShutdown {
|
||||
private readonly logger = new Logger(SystemOverrideService.name);
|
||||
private readonly handle: QueueHandle | null;
|
||||
/**
|
||||
* In-memory fallback used on Local tier (no Redis).
|
||||
* NOTE: state is ephemeral — lost on restart. For Local single-user installs
|
||||
* this is acceptable; system overrides are re-applied at the next session.
|
||||
* This is a deliberate behavior change from the Redis-backed 7-day TTL.
|
||||
*/
|
||||
private readonly localStore = new Map<string, LocalOverrideEntry>();
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {
|
||||
if (this.mosaicConfig?.queue?.type === 'local') {
|
||||
this.handle = null;
|
||||
} else {
|
||||
this.handle = createQueue();
|
||||
}
|
||||
}
|
||||
|
||||
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
// On non-local tiers the constructor opens an ioredis connection; close it
|
||||
// on graceful shutdown to avoid leaking the handle (local tier is null).
|
||||
await this.handle?.close().catch(() => {});
|
||||
}
|
||||
|
||||
async set(sessionId: string, override: string): Promise<void> {
|
||||
if (!this.handle) {
|
||||
// Local tier: in-memory path
|
||||
const entry = this.localStore.get(sessionId) ?? { condensed: '', fragments: [] };
|
||||
entry.fragments.push({ text: override, addedAt: Date.now() });
|
||||
entry.condensed = await this.condenseOverrides(entry.fragments.map((f) => f.text));
|
||||
this.localStore.set(sessionId, entry);
|
||||
this.logger.debug(
|
||||
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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));
|
||||
const fragments: OverrideFragment[] = existing
|
||||
? (JSON.parse(existing) as OverrideFragment[])
|
||||
: [];
|
||||
@@ -41,11 +77,11 @@ export class SystemOverrideService {
|
||||
// Store both: fragments array and condensed result
|
||||
const pipeline = this.handle.redis.pipeline();
|
||||
pipeline.setex(
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
||||
SYSTEM_OVERRIDE_TTL_SECONDS,
|
||||
JSON.stringify(fragments),
|
||||
);
|
||||
pipeline.setex(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
|
||||
pipeline.setex(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
|
||||
await pipeline.exec();
|
||||
|
||||
this.logger.debug(
|
||||
@@ -53,21 +89,33 @@ export class SystemOverrideService {
|
||||
);
|
||||
}
|
||||
|
||||
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
||||
async get(sessionId: string): Promise<string | null> {
|
||||
if (!this.handle) {
|
||||
return this.localStore.get(sessionId)?.condensed ?? null;
|
||||
}
|
||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
|
||||
}
|
||||
|
||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
async renew(sessionId: string): Promise<void> {
|
||||
if (!this.handle) {
|
||||
// Local tier: no TTL to renew; entry persists until restart
|
||||
return;
|
||||
}
|
||||
const pipeline = this.handle.redis.pipeline();
|
||||
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_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
async clear(sessionId: string): Promise<void> {
|
||||
if (!this.handle) {
|
||||
this.localStore.delete(sessionId);
|
||||
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
|
||||
return;
|
||||
}
|
||||
await this.handle.redis.del(
|
||||
SESSION_SYSTEM_KEY(sessionId, scope),
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||
SESSION_SYSTEM_KEY(sessionId),
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
||||
);
|
||||
this.logger.debug(`Cleared system override for session ${sessionId}`);
|
||||
}
|
||||
|
||||
35
apps/gateway/src/queue/queue.service.spec.ts
Normal file
35
apps/gateway/src/queue/queue.service.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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.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,7 +8,9 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
||||
import type { LogService } from '@mosaicstack/log';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -108,21 +110,42 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly connection: ConnectionOptions;
|
||||
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
||||
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
||||
/** False on Local tier — BullMQ/Redis operations become no-ops. */
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(LOG_SERVICE)
|
||||
private readonly logService: LogService | null,
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {
|
||||
this.connection = getConnection();
|
||||
this.enabled = this.mosaicConfig?.queue?.type !== 'local';
|
||||
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 {
|
||||
this.logger.log('QueueService initialised (BullMQ)');
|
||||
if (this.enabled) {
|
||||
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> {
|
||||
await this.closeAll();
|
||||
if (this.enabled) {
|
||||
await this.closeAll();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -131,8 +154,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
|
||||
if (!this.enabled) return null;
|
||||
let queue = this.queues.get(name) as Queue<T> | undefined;
|
||||
if (!queue) {
|
||||
queue = new Queue<T>(name, { connection: this.connection });
|
||||
@@ -144,6 +169,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* Add a BullMQ repeatable job (cron-style).
|
||||
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
||||
* No-op on Local tier.
|
||||
*/
|
||||
async addRepeatableJob<T extends MosaicJobData>(
|
||||
queueName: string,
|
||||
@@ -151,7 +177,13 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
data: T,
|
||||
cronExpression: string,
|
||||
): Promise<void> {
|
||||
const queue = this.getQueue<T>(queueName);
|
||||
if (!this.enabled) {
|
||||
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
|
||||
await (queue as Queue<any>).add(jobName, data, {
|
||||
repeat: { pattern: cronExpression },
|
||||
@@ -165,8 +197,18 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* Register a Worker for the given queue name with error handling and
|
||||
* exponential backoff.
|
||||
* Returns null on Local tier where BullMQ is disabled.
|
||||
*/
|
||||
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
|
||||
registerWorker<T extends MosaicJobData>(
|
||||
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>(
|
||||
queueName,
|
||||
async (job) => {
|
||||
@@ -223,8 +265,12 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/**
|
||||
* Return queue health statistics for all managed queues.
|
||||
* Returns an empty healthy result on Local tier.
|
||||
*/
|
||||
async getHealthStatus(): Promise<QueueHealthStatus> {
|
||||
if (!this.enabled) {
|
||||
return { queues: {}, healthy: true };
|
||||
}
|
||||
const queues: QueueHealthStatus['queues'] = {};
|
||||
let healthy = true;
|
||||
|
||||
@@ -255,8 +301,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* List jobs across all managed queues, optionally filtered by status.
|
||||
* BullMQ jobs are fetched by state type from each queue.
|
||||
* Returns empty array on Local tier.
|
||||
*/
|
||||
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
||||
if (!this.enabled) return [];
|
||||
const jobs: JobDto[] = [];
|
||||
const states: JobStatus[] = status
|
||||
? [status]
|
||||
@@ -283,8 +331,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
|
||||
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
||||
* 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 }> {
|
||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||
const sep = compositeId.lastIndexOf('__');
|
||||
if (sep === -1) {
|
||||
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
|
||||
@@ -316,6 +366,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Pause a queue by name.
|
||||
*/
|
||||
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);
|
||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||
await queue.pause();
|
||||
@@ -327,6 +378,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Resume a paused queue by name.
|
||||
*/
|
||||
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);
|
||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||
await queue.resume();
|
||||
|
||||
@@ -67,11 +67,10 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c
|
||||
|
||||
## Workstreams
|
||||
|
||||
| # | ID | Name | Status | Manifest | Notes |
|
||||
| --- | ---- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
||||
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
|
||||
| W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
||||
| # | ID | Name | Status | Manifest | Notes |
|
||||
| --- | --- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
||||
| W2+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
||||
|
||||
### Likely Additional Workstreams (Not Yet Declared)
|
||||
|
||||
|
||||
96
docs/PRD.md
96
docs/PRD.md
@@ -79,102 +79,6 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
|
||||
|
||||
---
|
||||
|
||||
## Tess Interaction Agent Workstream (TESS)
|
||||
|
||||
### Problem and Objective
|
||||
|
||||
Jason needs one durable, operator-facing Mosaic agent outside Hermes that is reachable through a dedicated Discord channel and CLI, can attach to and operate the Mosaic fleet and transitional Hermes agents, and preserves context across restarts and compaction. Mos remains the coding/general fleet orchestrator; Tess is the complementary human interaction, visibility, control, and migration agent.
|
||||
|
||||
The objective is to ship **Tess** (from _tessera_, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency.
|
||||
|
||||
### Scope
|
||||
|
||||
#### In Scope
|
||||
|
||||
1. `TESS-ARP-001`: A runtime-neutral `AgentRuntimeProvider` contract supporting `listSessions`, `streamSession`, `sendMessage`, `terminate`, `getSessionTree`, `attach`, health, capability discovery, and normalized events/errors.
|
||||
2. `TESS-PI-001`: A long-running Pi-native Tess agent profile/service pinned to GPT-5.6 Sol with high reasoning, explicit tool policy, lifecycle hooks, durable checkpoints, and restart recovery.
|
||||
3. `TESS-DSC-001`: Dedicated Discord channel binding to Tess through the Mosaic gateway, with allowlists/RBAC, thread/reply policy, streaming, attachments, approvals, and correlation IDs.
|
||||
4. `TESS-CLI-001`: `mosaic tess` CLI commands for chat, status, session listing, attach/detach, send/steer/stop, provider health, and recovery.
|
||||
5. `TESS-FLT-001`: Fleet plugin capabilities for roster/status/heartbeat inspection, message delivery, session hierarchy, safe attach, and controlled restart/recovery.
|
||||
6. `TESS-MOS-001`: Explicit Mos coordination boundary and tools: hand off orchestration requests, observe mission/task state, receive results, and never silently compete for orchestration authority.
|
||||
7. `TESS-HRM-001`: Transitional Hermes adapter for profiles/agents, sessions, streaming/messages, Kanban, skills, memory, tools, cron, and health, using capability negotiation and fail-closed unsupported operations.
|
||||
8. `TESS-MEM-001`: Unified memory/retrieval plugin with scoped search/recent/capture/stats, startup context injection, provenance, redaction, namespace isolation, and flat-file/project truth precedence.
|
||||
9. `TESS-STA-001`: Durable agent state, inbox, handoff, compaction-recovery, and resume reconstruction.
|
||||
10. `TESS-PLG-001`: Plugin/tool catalog covering runtime bootstrap, repository/PR workflow, fleet diagnostics, incident-safe read operations, Discord interaction, and extensible MCP/skill discovery.
|
||||
11. `TESS-TRN-001`: Replaceable transport providers: tmux/fleet now, Matrix/native Mosaic transport later, with no Discord/CLI business logic coupled to transport details.
|
||||
12. `TESS-SEC-001`: RBAC, per-operation authorization, explicit approval for destructive/privileged/customer-visible actions, audit events, secret/PII redaction, tenant isolation, and bounded command execution.
|
||||
13. `TESS-SEC-002`: Command execution SHALL enforce declared scope/role server-side; admin/system and destructive operations SHALL require policy-bound durable approval.
|
||||
14. `TESS-SEC-003`: Every session list/read/attach/send/terminate operation SHALL enforce server-derived owner and tenant scope; guessed or client-supplied IDs SHALL grant no authority.
|
||||
15. `TESS-SEC-004`: MCP tools SHALL derive actor/tenant from authenticated context and SHALL NOT accept caller-controlled identity fields.
|
||||
16. `TESS-SEC-005`: Discord plugin ingress SHALL authenticate service identity, enforce guild/channel/user allowlists, propagate correlation/message IDs, and reject replay.
|
||||
17. `TESS-SEC-006`: Secret/PII classification and redaction SHALL occur before persistence and before channel egress, including tool metadata and authentication flows.
|
||||
18. `TESS-SEC-007`: Approvals SHALL be one-time, expiring, actor/tenant-bound, and cryptographically bound to the exact structured action digest.
|
||||
19. `TESS-SEC-008`: Ingress, provider sends, tool side effects, and responses SHALL use durable inbox/outbox/checkpoints and idempotency records for restart-safe replay.
|
||||
20. `TESS-SEC-009`: Garbage collection and retention SHALL be session/tenant scoped unless executed as a separately authorized and audited system-wide job.
|
||||
21. `TESS-OBS-001`: Structured logs, traces, health/readiness, provider latency/errors, session lifecycle, tool audit, and actionable recovery diagnostics.
|
||||
22. `TESS-MIG-001`: Capability inventory and staged Hermes-to-Mosaic migration matrix with coexistence, cutover, rollback, and deprecation gates.
|
||||
|
||||
#### Out of Scope
|
||||
|
||||
1. Replacing Mos as coding/general fleet orchestrator.
|
||||
2. Making Hermes the Mosaic core or coupling Mosaic domain logic to Hermes schemas.
|
||||
3. Migrating every historical chat verbatim; only policy-compliant indexed summaries and user-selected sessions are migrated.
|
||||
4. Unrestricted shell execution from Discord.
|
||||
5. Full web UI parity in the first Tess operational milestone; gateway contracts must remain web-consumable.
|
||||
6. Replacing tmux before Matrix/native transport reaches operational parity.
|
||||
|
||||
### Stakeholder and User Requirements
|
||||
|
||||
- Jason must be able to converse with the same Tess session from Discord and CLI.
|
||||
- Jason must be able to see what is running, stale, blocked, or unhealthy without attaching manually to every session.
|
||||
- Jason must be able to attach to Tess and authorized fleet sessions through supported CLI controls.
|
||||
- Tess must collaborate with Mos and the fleet while preserving a single clear orchestration authority.
|
||||
- The system must migrate useful Hermes/OpenClaw capabilities intentionally, with evidence, instead of copying implementations wholesale.
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
1. **Security:** default-deny provider/tool capabilities, least privilege, no secrets in logs/prompts/commits, Discord user/channel authorization, and auditable approvals.
|
||||
2. **Reliability:** durable inbox/checkpoints; idempotent message handling; reconnect with bounded backoff; no message loss or duplicate execution across gateway restart.
|
||||
3. **Performance:** first acknowledgement within 2 seconds when connected; streamed agent output begins within 5 seconds excluding model/provider delay; status reads return within 2 seconds under nominal local conditions.
|
||||
4. **Observability:** every ingress message and resulting provider/tool operation carries a correlation ID across Discord, gateway, Tess, provider, and audit events.
|
||||
5. **Maintainability:** channel, runtime, transport, memory, and external-agent integrations remain adapter-based with contract tests.
|
||||
6. **Privacy:** only scoped context enters external runtimes; persisted messages/memories follow retention and redaction policy.
|
||||
7. **Portability:** Tess runs through Pi/Mosaic contracts and does not require Hermes to start or serve native Mosaic operations.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
1. `AC-TESS-01`: A dedicated Discord channel and `mosaic tess chat` connect to one durable Tess session and stream responses bidirectionally.
|
||||
2. `AC-TESS-02`: `mosaic tess status|sessions|tree|attach|send|stop` operate against authorized provider capabilities with stable typed outputs and actionable errors.
|
||||
3. `AC-TESS-03`: Tess runs GPT-5.6 Sol at high reasoning and its effective runtime/model/tool policy is visible through status without exposing credentials.
|
||||
4. `AC-TESS-04`: Tess can inspect and message the Mosaic fleet, hand orchestration work to Mos, and demonstrate that Tess does not independently claim Mos-owned orchestration work.
|
||||
5. `AC-TESS-05`: Hermes adapter demonstrates session listing, streaming/message delivery, hierarchy mapping, and at least one approved capability in each of Kanban, skills, memory, tools, and cron—or reports unsupported capabilities fail-closed.
|
||||
6. `AC-TESS-06`: Restart/compaction test preserves session identity, pending inbox, last durable checkpoint, and a resumable handoff without duplicate side effects.
|
||||
7. `AC-TESS-07`: Unauthorized Discord users/channels, cross-tenant access, unsafe tool calls, forged approvals, and sensitive-output cases are denied and audited.
|
||||
8. `AC-TESS-08`: tmux/fleet and Matrix/native transport implementations pass the same provider contract suite; Matrix may remain non-default until readiness gates pass.
|
||||
9. `AC-TESS-09`: Baseline quality gates, unit/integration/contract tests, Discord+CLI E2E, restart/recovery tests, independent code review, and security review are green.
|
||||
10. `AC-TESS-10`: Migration matrix documents every audited Hermes/OpenClaw capability as native, adapted, deferred, or rejected, with cutover and rollback evidence.
|
||||
11. `AC-TESS-11`: User, admin, developer, API/OpenAPI, operations/recovery, and plugin-authoring documentation is current and linked from the sitemap.
|
||||
|
||||
### Constraints, Dependencies, Risks, and Assumptions
|
||||
|
||||
- Dependency: Mosaic gateway remains the single API surface; Pi is the native runtime; Valkey/PostgreSQL provide canonical durable state where required.
|
||||
- Dependency: Discord bot credentials and dedicated channel ID are deployment secrets provisioned outside source control.
|
||||
- Risk: Tess could drift into a second orchestrator. Mitigation: explicit role policy, Mos handoff contract, authority checks, and E2E boundary tests.
|
||||
- Risk: broad Hermes compatibility can freeze legacy semantics into Mosaic. Mitigation: Mosaic-owned normalized contracts and capability negotiation.
|
||||
- Risk: Discord creates a privileged remote-control surface. Mitigation: pairing/allowlists, RBAC, approvals, rate limits, audit, and safe tool classes.
|
||||
- Risk: transcript ingestion can violate privacy or overload memory. Mitigation: scoped opt-in import, redacted summaries, provenance, retention, and deduplication.
|
||||
- Risk: current root filesystem has limited headroom. Mitigation: isolated worktrees, no duplicated dependency installation unless required, and cleanup only after active-lane verification.
|
||||
- `ASSUMPTION:` The public name is **Tess**, because the user requested a name and the tessera/Mosaic relationship is distinctive; config must permit later display-name changes without renaming APIs or storage keys.
|
||||
- `ASSUMPTION:` The dedicated Discord channel ID and final guild policy will be supplied/provisioned during deployment, so implementation uses explicit configuration and fail-fast startup validation.
|
||||
- `ASSUMPTION:` tmux/fleet is the production transport for the first operational milestone; Matrix/native transport is implemented behind the same contract and promoted only after parity/reliability verification.
|
||||
- `ASSUMPTION:` Project/task truth remains in canonical Mosaic/project stores; semantic memory systems are retrieval/mirror layers, not hidden authorities.
|
||||
|
||||
### Testing and Delivery Intent
|
||||
|
||||
Delivery uses five gated milestones: runtime contracts/security; Pi service/state; Discord/CLI; fleet/Hermes/plugin suite; migration/Matrix/recovery/qualification. Every source-code task requires tests, independent review, a PR to `main`, terminal-green CI, and issue/task closure. Production activation additionally requires a clean-host Pi launch, dedicated Discord channel smoke test, CLI attach test, restart/recovery drill, and rollback procedure.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### High-Level System Diagram
|
||||
|
||||
@@ -14,10 +14,9 @@
|
||||
|
||||
## Workstream Rollup
|
||||
|
||||
| id | status | workstream | progress | tasks file | notes |
|
||||
| --- | ----------------- | ---------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
||||
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
|
||||
| id | status | workstream | progress | tasks file | notes |
|
||||
| --- | ----------------- | ------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
||||
|
||||
## Cross-Cutting Tracking
|
||||
|
||||
|
||||
63
docs/design/prerelease-next-dist-tag-pipeline.md
Normal file
63
docs/design/prerelease-next-dist-tag-pipeline.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# 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.
|
||||
@@ -211,6 +211,17 @@ pnpm format:check && pnpm typecheck && pnpm lint
|
||||
|
||||
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
|
||||
|
||||
@@ -175,8 +175,18 @@ Or use the direct URL:
|
||||
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`. Flags for
|
||||
non-interactive use:
|
||||
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`.
|
||||
|
||||
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
|
||||
--yes # Accept all defaults
|
||||
|
||||
38
docs/scratchpads/462-fed-m3-06-get-verb.md
Normal file
38
docs/scratchpads/462-fed-m3-06-get-verb.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 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,33 +0,0 @@
|
||||
# Issue #561 — Bare python on agent hosts
|
||||
|
||||
## Objective
|
||||
|
||||
Make the durable bootstrap/provisioning guidance ensure agent hosts provide a bare `python` command that resolves to Python 3.
|
||||
|
||||
## Scope
|
||||
|
||||
- Add Debian/Ubuntu `python-is-python3` to agent-host prerequisites in bootstrap docs.
|
||||
- Check for actual OS package provisioning scripts and update only if an existing agent-host package install path exists.
|
||||
- Do not touch live host state.
|
||||
- Do not update `docs/TASKS.md`; repo guidance says workers read it but never modify it.
|
||||
|
||||
## Recon
|
||||
|
||||
- Issue #561 confirms repeated `python: command not found` failures from fleet agents that emit `python foo.py`.
|
||||
- `guides/BOOTSTRAP.md` and `packages/mosaic/framework/guides/BOOTSTRAP.md` are the source and packaged framework copies of the bootstrap guide.
|
||||
- Targeted repo sweep found no agent-host Debian package provisioning script. Existing `apt-get install` hits are CI/test helper paths or unrelated deployment docs.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add a host prerequisite section to both bootstrap guide copies.
|
||||
2. Include `python-is-python3` in the Debian/Ubuntu package list with an issue comment.
|
||||
3. Note the non-Debian equivalent as a `/usr/bin/python -> python3` symlink.
|
||||
4. Validate markdown/diff, run shell syntax checks where applicable, run required review, commit, queue guard, and push.
|
||||
|
||||
## Validation Log
|
||||
|
||||
- `rg` recon: no existing agent-host Debian package provisioning script; only CI/test helper `apt-get install` paths and unrelated deployment docs.
|
||||
- `git diff --check`: passed.
|
||||
- `bash -n packages/mosaic/framework/install.sh tools/install.sh packages/mosaic/framework/tools/bootstrap/init-project.sh packages/mosaic/framework/tools/_scripts/mosaic-bootstrap-repo`: passed. No touched shell scripts.
|
||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`: approved, 0 findings.
|
||||
- `pnpm format:check`: initially blocked because `node_modules` was absent and `prettier` was unavailable; `pnpm install --frozen-lockfile` initially hit an invalid `/root` pnpm store path. Reran install with `--store-dir /home/hermes/agent-work/.pnpm-store`, then `pnpm format:check` passed.
|
||||
@@ -1,49 +0,0 @@
|
||||
# #703 Git Wrapper Interactive and Auth Resilience
|
||||
|
||||
## Objective
|
||||
|
||||
Restore the deployed Git wrapper contract: issue-create supports interactive invocation and Gitea mutation behavior tolerates a stale Tea authenticated user by validating current identity and using the existing host-scoped API fallback.
|
||||
|
||||
## Scope
|
||||
|
||||
- `packages/mosaic/framework/tools/git/issue-create.sh`
|
||||
- `packages/mosaic/framework/tools/git/detect-platform.sh`
|
||||
- Git wrapper regression harnesses
|
||||
- This scratchpad
|
||||
|
||||
## Requirements / acceptance evidence
|
||||
|
||||
1. `issue-create -i` and `--interactive` prompt for missing issue fields without exposing credentials.
|
||||
2. Explicit command-line fields retain precedence and do not trigger prompt input.
|
||||
3. Gitea wrapper resolves the current user dynamically from the target host and does not rely on the saved Tea user identity.
|
||||
4. A Tea `GetUserByName` failure falls back to authenticated API creation.
|
||||
5. Existing body-safety, login-resolution, issue-create, and pr-create paths remain green.
|
||||
6. Source framework is re-seeded to deployed `~/.config/mosaic`, then deployed wrappers are verified end to end.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add failing shell regression harness for interactive input and stale Tea user fallback.
|
||||
2. Implement minimal helper and parser changes.
|
||||
3. Run wrapper harnesses, syntax checks, and repository baseline checks.
|
||||
4. Re-seed deployed framework and run live wrapper verification.
|
||||
5. Commit, queue guard, push, open PR, and stop for independent review.
|
||||
|
||||
## Progress
|
||||
|
||||
- Issue #703 filed before code; issue comment records #536 root cause and stale-login trigger.
|
||||
- Deployed wrapper `issue-create.sh -i` reproduced: `Unknown option: -i` (exit 1).
|
||||
- Live Tea mutation did not reproduce `GetUserByName` on this host because the current mosaicstack Tea login is valid. The test harness models the reported stale authenticated-user condition.
|
||||
- Implemented `-i` / `--interactive` prompt collection and a dynamic Tea `/user` validation. A stale Tea identity now selects the existing host-scoped Gitea API fallback before mutation for both issue and PR creation.
|
||||
- Re-seeded the framework with `MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash packages/mosaic/framework/install.sh`. Installed and source wrapper SHA-256 values matched.
|
||||
- Live deployed verification: interactive issue-create opened then closed #704; installed dynamic identity resolved `jason.woltje`.
|
||||
|
||||
## Verification
|
||||
|
||||
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh`
|
||||
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh`
|
||||
- PASS: `packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh`
|
||||
- PASS: `packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh`
|
||||
- PASS: `packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh`
|
||||
- PASS: `bash packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh`
|
||||
- PASS: `bash -n packages/mosaic/framework/tools/git/*.sh`
|
||||
- PASS: Prettier check for this scratchpad
|
||||
82
docs/scratchpads/B1-next-durable-publish-design.md
Normal file
82
docs/scratchpads/B1-next-durable-publish-design.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 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.
|
||||
34
docs/scratchpads/B2-skills-sync-path.md
Normal file
34
docs/scratchpads/B2-skills-sync-path.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 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.
|
||||
36
docs/scratchpads/B3-wizard-gateway-health-order.md
Normal file
36
docs/scratchpads/B3-wizard-gateway-health-order.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 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.
|
||||
36
docs/scratchpads/B4-wizard-step-dedup.md
Normal file
36
docs/scratchpads/B4-wizard-step-dedup.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 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.
|
||||
60
docs/scratchpads/FED-M3-10-integration-tests.md
Normal file
60
docs/scratchpads/FED-M3-10-integration-tests.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 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.
|
||||
40
docs/scratchpads/installer-next-fast-npm-20260625.md
Normal file
40
docs/scratchpads/installer-next-fast-npm-20260625.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 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/gateway@0.0.7-next.1633` and `@mosaicstack/mosaic@0.0.49-next.1633`.
|
||||
35
docs/scratchpads/installer-next-lane-20260624.md
Normal file
35
docs/scratchpads/installer-next-lane-20260624.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 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.
|
||||
@@ -1,58 +0,0 @@
|
||||
# Scratchpad — Tess Interaction Agent
|
||||
|
||||
## 2026-07-12 — Mission intake
|
||||
|
||||
**Objective:** Build a Pi-native GPT-5.6 Sol high-reasoning Mosaic interaction agent, named Tess, as Jason's primary Discord/CLI access point for Mosaic fleet and transitional Hermes capabilities. Tess complements Mos and must not become a competing orchestrator.
|
||||
|
||||
**Issue:** #706
|
||||
|
||||
**Budget:** No explicit cap provided. Original working estimate was 290K implementation/review tokens. That estimate is superseded after six security prerequisite tasks were added; revised arithmetic total is pending because the calculation tool was blocked by runtime consent. Run at most two workers; prefer one implementation lane plus one independent review/discovery lane. Re-estimate after planning approval and each milestone.
|
||||
|
||||
**Evidence gathered:**
|
||||
- Mosaic already provides Pi lifecycle hooks, fleet/tmux sessions, Matrix connector/controller pieces, typed chat events, Discord/Telegram channel plugins, and command/plugin registries.
|
||||
- Current `IProviderAdapter` is an LLM model/completion abstraction, not an external agent/session provider.
|
||||
- Required new seam is `AgentRuntimeProvider`: sessions, stream, message, terminate, hierarchy, attach, health, capabilities.
|
||||
- Recurring cross-runtime needs: unified memory/retrieval, Discord routing/approvals, agent state/inbox/compaction recovery, runtime bootstrap, fleet/incident controls, and GitOps workflow.
|
||||
- Project truth must remain in canonical project/Mosaic stores; semantic memory is retrieval/mirror.
|
||||
|
||||
**Decisions:**
|
||||
1. Name: Tess (tessera). Stable machine key `tess`; display name configurable.
|
||||
2. Mos owns orchestration; Tess delegates Mos-owned work through an explicit coordination contract.
|
||||
3. Gateway owns ingress/auth/routing; Discord and CLI remain thin clients.
|
||||
4. tmux/fleet ships first behind an adapter; Matrix/native Mosaic is the forward transport.
|
||||
5. Hermes integration is transitional and capability-negotiated; unsupported operations fail closed.
|
||||
6. No unrestricted Discord shell. Privileged/destructive/customer-visible actions require authorization and approval.
|
||||
|
||||
**Plan:**
|
||||
1. Land requirements/architecture/task graph.
|
||||
2. Deliver runtime contracts and security model.
|
||||
3. Deliver durable Pi service/state.
|
||||
4. Deliver Discord and CLI.
|
||||
5. Deliver fleet/Mos/Hermes/memory/tool plugins.
|
||||
6. Deliver Matrix/native transport, migration matrix, recovery, docs, and qualification.
|
||||
|
||||
**Progress:** Issue #706 created. PRD/manifest/tasks initialized on clean branch `feat/tess-interaction-agent` from `origin/main`.
|
||||
|
||||
**Risks:** 14 GB root filesystem headroom; active fleet lanes; broad migration scope; Discord privilege boundary; possible duplicate orchestration authority.
|
||||
|
||||
## 2026-07-12 — Independent planning and threat review
|
||||
|
||||
**Verdict received:** BLOCK TESS-PLAN-001. Coding remains stopped.
|
||||
|
||||
**Blocking findings:** formal threat model absent; verification matrix absent; migration inventory implied but absent; non-existent task paths; AC-TESS-03 lacked a crisp test. Security review also identified command scope bypass, cross-tenant session attachment, MCP actor impersonation, unsafe Discord service ingress, pre-persistence/egress secret leakage, non-durable replay, and globally scoped GC.
|
||||
|
||||
**Remediation applied:**
|
||||
- Added `docs/tess/ARCHITECTURE.md`.
|
||||
- Added `docs/tess/THREAT-MODEL.md` with TM-01..12.
|
||||
- Added `docs/tess/VERIFICATION-MATRIX.md` mapping AC-TESS-01..11.
|
||||
- Added `docs/tess/MIGRATION-INVENTORY.md`.
|
||||
- Added hard requirements TESS-SEC-002..009.
|
||||
- Added six prerequisite security tasks before provider/ingress implementation.
|
||||
- Corrected task paths to existing package surfaces.
|
||||
- Added explicit GPT-5.6 Sol/high/tool-policy status verification for AC-TESS-03.
|
||||
|
||||
**Re-review 1:** BLOCK only on composite `repo` values that looked like nonexistent paths. Remediated by declaring comma-separated roots and validating every root.
|
||||
|
||||
**Final focused review:** PASS. Deterministic audit validated all task repository roots with zero missing paths; no planning placeholders remained; security prerequisites still gate Tess exposure; observability traceability is explicit.
|
||||
|
||||
**Current gate:** planning PR must merge to `main` with terminal-green CI before any source-code worker starts.
|
||||
@@ -1,27 +0,0 @@
|
||||
# TESS-M1-SEC-001 — Command authorization and exact-action approval
|
||||
|
||||
- Issue/milestone: #707 / M1
|
||||
- Branch: `fix/tess-command-authz`
|
||||
- Requirement: `TESS-SEC-002`, with approval binding controls from `TESS-SEC-007`
|
||||
- Scope: `apps/gateway` only, plus required in-repo security/developer documentation.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Locate the gateway command executor, command metadata, authorization context, and existing test conventions.
|
||||
2. Write abuse/authz tests before production changes. Expected red cases: non-admin blocked from admin/system command; forged caller scope cannot authorize; privileged/destructive action requires durable exact-action approval; expired/replayed/mutated approvals deny.
|
||||
3. Implement server-derived role/scope enforcement and durable approval validation/consumption with audit results.
|
||||
4. Run focused security tests, then repository baseline gates: typecheck, lint, format-check, test.
|
||||
5. Run independent security/code review, commit, queue-guard, push, and open the PR to `main` through the stated Gitea API fallback. Stop after PR creation.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The existing gateway persistence interface is the available durable approval boundary. If no persistence abstraction exists, a minimal injectable repository interface will be introduced rather than an in-memory approval implementation, because TESS-SEC-002/007 require durable enforcement.
|
||||
- “Exact action” is a canonical digest over structured command identity and normalized arguments; role/scope checks always use authenticated server context, not client-declared claims.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
- Pending: abuse/authz test written and observed red before implementation.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
- Pending.
|
||||
@@ -1,71 +0,0 @@
|
||||
# Tess Architecture
|
||||
|
||||
## Purpose
|
||||
|
||||
Tess is the Mosaic operator interaction plane. Mos remains the coding/general fleet orchestration authority. Tess receives authorized operator intent, presents fleet/session state, delegates Mos-owned work to Mos, and exposes native Mosaic plus transitional external-agent capabilities through normalized providers.
|
||||
|
||||
## Component Boundaries
|
||||
|
||||
```text
|
||||
Discord plugin ─┐
|
||||
├─ authenticated ingress envelope ─> Mosaic Gateway
|
||||
mosaic tess CLI ┘ │
|
||||
├─ policy/approval/audit
|
||||
├─ Tess durable session service (Pi GPT-5.6 Sol high)
|
||||
├─ AgentRuntimeProvider registry
|
||||
│ ├─ native Pi provider
|
||||
│ ├─ fleet/tmux provider
|
||||
│ ├─ Hermes adapter
|
||||
│ └─ Matrix/native transport provider
|
||||
├─ memory/state/inbox plugins
|
||||
└─ Mos coordination adapter ─> Mos / fleet queue
|
||||
```
|
||||
|
||||
## Core Contract
|
||||
|
||||
`AgentRuntimeProvider` is separate from the existing model-completion `IProviderAdapter`. It normalizes external and native agent runtimes without leaking provider-specific schemas.
|
||||
|
||||
Required operations:
|
||||
|
||||
- `capabilities()` and `health()`
|
||||
- `listSessions(scope)`
|
||||
- `getSessionTree(scope)`
|
||||
- `streamSession(sessionRef, cursor, scope)`
|
||||
- `sendMessage(sessionRef, message, idempotencyKey, scope)`
|
||||
- `attach(sessionRef, mode, scope)` / `detach()`
|
||||
- `terminate(sessionRef, approvalRef, scope)`
|
||||
|
||||
Every call receives an immutable, server-derived actor/tenant/channel scope and correlation ID. Caller-supplied actor IDs are forbidden. Unsupported capabilities fail closed with typed errors.
|
||||
|
||||
## Authority Model
|
||||
|
||||
| Intent | Owner | Tess behavior |
|
||||
| --------------------------------------------------------------------------- | ----------------------- | ---------------------------------------------------------------------- |
|
||||
| Conversation, status, retrieval, safe diagnostics | Tess | Execute within policy |
|
||||
| Code/project decomposition, worker assignment, reviews, merge orchestration | Mos | Create a correlated handoff and observe result |
|
||||
| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently |
|
||||
| Provider-specific unsupported action | None | Fail closed; never emulate silently |
|
||||
|
||||
## Session and State Model
|
||||
|
||||
A Tess session has stable `sessionId`, `tenantId`, `ownerId`, provider/runtime identity, ingress bindings, cursor, checkpoint, inbox/outbox, and idempotency records. Discord and CLI bind to the same authorized session. Ownership is verified server-side on every list/read/attach/send/terminate operation.
|
||||
|
||||
Valkey may hold ephemeral coordination state; PostgreSQL is canonical for durable session bindings, approvals, audit, checkpoints, inbox/outbox, and idempotency. Pi session files are replay sources, not cross-agent truth.
|
||||
|
||||
## Transport Strategy
|
||||
|
||||
- **Initial:** fleet/tmux provider, including exact target, socket, identity, heartbeat, and safe attach semantics.
|
||||
- **Forward:** Matrix/native Mosaic provider using authenticated identity, idempotent transaction IDs, replay cursors, and the same contract suite.
|
||||
- Discord/CLI never call tmux or Matrix directly.
|
||||
|
||||
## Plugin Families
|
||||
|
||||
1. Channel: Discord now; other channels later.
|
||||
2. Runtime: Pi, fleet/tmux, Hermes, Matrix/native.
|
||||
3. Operator tools: fleet health, Mos handoff, GitOps wrappers, incident-safe diagnostics.
|
||||
4. Memory/state: search/recent/capture, durable inbox, checkpoint, handoff, compaction recovery.
|
||||
5. Migration: capability inventory, adapters, cutover, rollback, telemetry.
|
||||
|
||||
## Deployment
|
||||
|
||||
Tess runs as a rostered, systemd-supervised Pi agent using GPT-5.6 Sol and high reasoning. Secrets are supplied through approved runtime secret mechanisms. Startup fails when required model, gateway identity, Discord binding, or durable-state dependencies are missing. Health reports effective model/reasoning/tool policy without credential material.
|
||||
@@ -1,34 +0,0 @@
|
||||
# Tess Capability Migration Inventory
|
||||
|
||||
Status values: `native` · `adapt` · `defer` · `reject`. This is the initial inventory; M5 requires implementation and evidence fields to be completed before cutover.
|
||||
|
||||
| Capability | Current source | Target | Initial status | Cutover/rollback intent |
|
||||
| ---------------------------------------- | ---------------------------------------- | ------------------------------------------ | -------------- | ----------------------------------------------------------------------- |
|
||||
| Interactive agent chat/session streaming | Hermes/Pi/OpenClaw | Mosaic Tess session service | native | Dual-run per channel; revert binding to legacy gateway |
|
||||
| Discord dedicated-channel routing | Hermes/Claude/OpenClaw plugins | Mosaic Discord plugin + gateway | native | Per-channel binding switch; legacy bot disabled only after soak |
|
||||
| CLI/TUI session interaction and attach | Hermes/Pi/tmux | `mosaic tess` + AgentRuntimeProvider | native | Keep direct tmux attach as break-glass rollback |
|
||||
| Session list/tree/send/terminate | Hermes/fleet | AgentRuntimeProvider | native | Capability-negotiated adapter remains during migration |
|
||||
| Mos/fleet orchestration handoff | tmux messaging/Mosaic fleet | Mosaic coord/fleet provider | native | tmux handoff remains initial transport |
|
||||
| Kanban/projects/tasks | Hermes Kanban | Mosaic queue/coord/project providers | adapt | Read projection first; mutating cutover after parity/audit |
|
||||
| Skills catalog/load/manage | Hermes skills/Pi skills | Mosaic skill registry/provider | adapt | Import metadata/provenance; preserve source skill until validated |
|
||||
| Tools and MCP | Hermes/OpenClaw/MCP | Mosaic tool registry/MCP | adapt | Default deny; migrate allowlisted tools one capability at a time |
|
||||
| Cron/scheduled work | Hermes cron | Mosaic scheduler/queue | adapt | Shadow schedules; prevent duplicate execution; rollback owner field |
|
||||
| Memory search/recent/capture | jarvis-brain/OpenViking/OpenBrain/Hermes | Mosaic memory provider | adapt | Flat/project stores remain truth; semantic systems are mirrors |
|
||||
| User/profile preferences | Hermes memory/user profile | Mosaic user/memory domain | adapt | Provenance + explicit conflict rules; exportable rollback snapshot |
|
||||
| Agent state/inbox/handoff | OpenClaw extensions/session files | Mosaic durable state service | native | Read legacy handoff during coexistence; write Mosaic only after cutover |
|
||||
| Runtime contract/bootstrap | Mosaic framework/Hermes/OpenClaw | Mosaic compose/runtime provider | native | Legacy launchers remain until clean-host parity passes |
|
||||
| Repository/PR workflow | Mosaic wrappers/Hermes tools | Mosaic operator plugin | native | Wrapper-only; no raw-provider fallback |
|
||||
| Incident-safe diagnostics | Hermes skills/tools | Mosaic scoped operator plugin | adapt | Read-only first; privileged recovery requires approval |
|
||||
| Broad unrestricted shell from Discord | Hermes/OpenClaw configurations | None | reject | No cutover; replace with allowlisted typed operations |
|
||||
| Raw full transcript bulk migration | Hermes/Claude/OpenClaw histories | Indexed summaries/selective import | reject | Keep source archives subject to retention; no automatic copy |
|
||||
| Voice/video interaction | Hermes optional tools | Future Mosaic channel plugins | defer | Not required for Tess operational release |
|
||||
| Matrix transport | Mosaic connector | AgentRuntimeProvider Matrix implementation | native | Non-default until contract/reliability parity; tmux rollback |
|
||||
|
||||
## Cutover Gates
|
||||
|
||||
1. Capability contract and security tests pass.
|
||||
2. Data mapping/provenance and retention are documented.
|
||||
3. Shadow or dual-run shows no unauthorized access, loss, or duplicate effects.
|
||||
4. Operator runbook and rollback are exercised.
|
||||
5. Channel/provider binding changes are reversible without schema rollback.
|
||||
6. Legacy capability is disabled only after a defined soak period and evidence review.
|
||||
@@ -1,46 +0,0 @@
|
||||
# Mission Manifest — Tess Interaction Agent
|
||||
|
||||
## Mission
|
||||
|
||||
- **ID:** tess-20260712
|
||||
- **Issue:** #706
|
||||
- **Branch:** `feat/tess-interaction-agent`
|
||||
- **Phase:** Execution
|
||||
- **Current Milestone:** TESS-M1 — Runtime contracts and security foundation
|
||||
- **Progress:** 0 / 5 delivery milestones complete
|
||||
- **Status:** active
|
||||
- **Owner:** Mosaic orchestrator; Mos is coordinating fleet authority
|
||||
- **Source PRD:** `docs/PRD.md` — `TESS-*` requirements
|
||||
- **Scratchpad:** `docs/scratchpads/tess-20260712.md`
|
||||
|
||||
## Mission Statement
|
||||
|
||||
Ship Tess as Jason's durable Pi-native GPT-5.6 Sol high-reasoning interaction agent for Discord and CLI, with safe visibility/control of Mosaic fleet and transitional Hermes capabilities, while Mos remains the coding/general orchestration authority.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. Mosaic is the enterprise AI hub; Hermes is a reference migration adapter.
|
||||
2. Gateway is the single API surface.
|
||||
3. Mos owns coding/general fleet orchestration; Tess owns human interaction, visibility, mediation, and migration access.
|
||||
4. Runtime, transport, channel, memory, and external-agent integrations are replaceable adapters.
|
||||
5. No source task completes before merged PR, terminal-green CI, independent review, and linked task/issue closure.
|
||||
|
||||
## Milestones
|
||||
|
||||
| ID | Issue | Name | Status | Exit gate |
|
||||
| ------- | ----- | ------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged |
|
||||
| TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests |
|
||||
| TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals |
|
||||
| TESS-M4 | #710 | Fleet, Mos, Hermes, memory, state, and tool plugins | not-started | Fleet/Mos boundary and transitional capability matrix demonstrated end-to-end |
|
||||
| TESS-M5 | #711 | Matrix/native migration, recovery, documentation, and qualification | not-started | Transport parity, migration/rollback matrix, security review, docs, greenfield and deployment validation complete |
|
||||
|
||||
## Success Criteria
|
||||
|
||||
All `AC-TESS-*` criteria in `docs/PRD.md` are mapped to reproducible evidence. The final operational test must prove Discord + CLI session continuity, fleet/Mos coordination, authorized Hermes transition capabilities, denial/audit paths, restart recovery, and rollback.
|
||||
|
||||
## Session History
|
||||
|
||||
| Session | Date | Runtime | Outcome |
|
||||
| ------- | ---------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| S1 | 2026-07-12 | Hermes / GPT-5.6 Sol | User commission captured; Mosaic/OpenViking/session/code archaeology completed; issue #706 created; PRD and task control plane initialized. |
|
||||
@@ -1,34 +0,0 @@
|
||||
# Tasks — Tess Interaction Agent
|
||||
|
||||
> Mission: `tess-20260712` · Issue: #706 · PRD requirements: `TESS-*`
|
||||
> Orchestrator is sole writer. Workers must not modify this file.
|
||||
> `repo` contains one or more comma-separated repository-relative roots; every listed root must exist before dispatch.
|
||||
|
||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| TESS-PLAN-001 | done | Finalize PRD, architecture, authority boundary, threat model, migration inventory, and verification matrix | #706 | sonnet | docs, packages/types, apps/gateway | feat/tess-interaction-agent | — | 22K | Independent gate PASS after two remediation rounds; completion effective when planning PR merges |
|
||||
| TESS-M1-SEC-001 | not-started | Enforce command scopes/roles and durable exact-action approval for privileged/destructive commands | #707 | codex | apps/gateway | fix/tess-command-authz | TESS-PLAN-001 | 25K | TESS-SEC-002; security TDD |
|
||||
| TESS-M1-SEC-002 | not-started | Enforce owner/tenant binding on session list/read/attach/send/terminate across REST and WS | #707 | codex | apps/gateway | fix/tess-session-ownership | TESS-PLAN-001 | 30K | TESS-SEC-003; security TDD |
|
||||
| TESS-M1-SEC-003 | not-started | Bind MCP actor/tenant to authenticated context and add per-tool scopes | #707 | codex | apps/gateway | fix/tess-mcp-identity | TESS-PLAN-001 | 22K | TESS-SEC-004; security TDD |
|
||||
| TESS-M1-SEC-004 | not-started | Add authenticated Discord service ingress, allowlists, correlation and replay protection | #707 | codex | plugins/discord, apps/gateway | fix/tess-discord-ingress | TESS-PLAN-001 | 28K | TESS-SEC-005; security TDD |
|
||||
| TESS-M1-SEC-005 | not-started | Redact/classify secret and PII before persistence/egress; harden provider login flow | #707 | codex | apps/gateway, packages/log | fix/tess-redaction | TESS-PLAN-001 | 28K | TESS-SEC-006; seeded canary tests |
|
||||
| TESS-M1-SEC-006 | not-started | Scope session GC/retention or separate authorized global retention job | #707 | codex | apps/gateway, packages/log | fix/tess-session-gc-scope | TESS-PLAN-001 | 18K | TESS-SEC-009; isolation TDD |
|
||||
| TESS-M1-001 | not-started | Define AgentRuntimeProvider, capabilities, session tree, normalized stream events/errors, attach semantics | #707 | codex | packages/types, packages/agent | feat/tess-runtime-contract | TESS-PLAN-001 | 25K | TESS-ARP-001, TESS-TRN-001; contract TDD |
|
||||
| TESS-M1-002 | not-started | Implement provider registry/service with immutable actor scope, approval, audit and correlation boundaries | #707 | codex | apps/gateway, packages/agent | feat/tess-provider-registry | TESS-M1-001,TESS-M1-SEC-001,TESS-M1-SEC-002,TESS-M1-SEC-003 | 30K | TESS-SEC-001..004,007; security TDD |
|
||||
| TESS-M1-003 | not-started | Implement tmux/fleet runtime provider and safe attach/message/terminate capability policy | #707 | codex | packages/mosaic, packages/agent | feat/tess-fleet-provider | TESS-M1-002 | 30K | TESS-FLT-001; exact target/identity tests |
|
||||
| TESS-M1-OBS-001 | not-started | Implement correlation propagation, structured runtime/provider/tool audit, health/readiness and safe effective-policy status | #707 | codex | apps/gateway, packages/agent, packages/log | feat/tess-observability | TESS-M1-002 | 24K | TESS-OBS-001; no credential material |
|
||||
| TESS-M1-V | not-started | Independent architecture/security review and complete contract/abuse-suite verification | #707 | sonnet | apps/gateway, packages/agent, packages/log, plugins/discord | review/tess-m1 | TESS-M1-SEC-001,TESS-M1-SEC-002,TESS-M1-SEC-003,TESS-M1-SEC-004,TESS-M1-SEC-005,TESS-M1-SEC-006,TESS-M1-003,TESS-M1-OBS-001 | 20K | Gate M2 |
|
||||
| TESS-M2-001 | not-started | Add Tess roster/profile/service pinned to GPT-5.6 Sol high with fail-fast config and observable effective policy | #708 | codex | packages/mosaic/framework | feat/tess-pi-service | TESS-M1-V | 22K | TESS-PI-001; explicit AC-TESS-03 test |
|
||||
| TESS-M2-002 | not-started | Implement durable session identity, inbox/outbox, approval, checkpoint, handoff, compaction and restart recovery | #708 | codex | apps/gateway, packages/agent, packages/db | feat/tess-durable-state | TESS-M2-001 | 38K | TESS-STA-001, TESS-SEC-007..008; recovery TDD |
|
||||
| TESS-M2-V | not-started | Clean-host Pi launch plus model/policy status and restart/compaction/duplicate-side-effect verification | #708 | sonnet | apps/gateway/src/__tests__/integration, packages/mosaic/src | review/tess-m2 | TESS-M2-002 | 18K | Gate M3; AC-TESS-03/06 |
|
||||
| TESS-M3-001 | not-started | Bind dedicated Tess Discord channel with streaming, threads, attachments, pairing/RBAC and approvals | #709 | codex | plugins/discord, apps/gateway | feat/tess-discord | TESS-M2-V,TESS-M1-SEC-004 | 35K | TESS-DSC-001 |
|
||||
| TESS-M3-002 | not-started | Implement `mosaic tess` chat/status/sessions/tree/attach/send/stop/health/recover CLI | #709 | codex | packages/mosaic | feat/tess-cli | TESS-M2-V | 30K | TESS-CLI-001 |
|
||||
| TESS-M3-V | not-started | Discord+CLI same-session E2E, denial/approval tests, and operator-flow review | #709 | sonnet | apps/gateway/src/__tests__/integration, plugins/discord, packages/mosaic/src | review/tess-m3 | TESS-M3-001,TESS-M3-002 | 20K | Gate M4 |
|
||||
| TESS-M4-001 | not-started | Implement Mos coordination handoff/observe/result contract with authority-boundary tests | #710 | codex | packages/coord, apps/gateway | feat/tess-mos-coordination | TESS-M3-V | 25K | TESS-MOS-001 |
|
||||
| TESS-M4-002 | not-started | Implement transitional Hermes runtime/capability adapter | #710 | codex | packages/agent, apps/gateway | feat/tess-hermes-adapter | TESS-M3-V | 40K | TESS-HRM-001; no legacy schema in core contracts |
|
||||
| TESS-M4-003 | not-started | Implement memory/retrieval, state/inbox, runtime bootstrap, fleet diagnostics and GitOps plugin foundations | #710 | codex | packages/memory, packages/agent, packages/mosaic | feat/tess-operator-plugins | TESS-M3-V | 40K | TESS-MEM-001, TESS-PLG-001 |
|
||||
| TESS-M4-V | not-started | Cross-provider capability, privacy, authority and failure-path qualification | #710 | sonnet | apps/gateway/src/__tests__/integration, packages/agent | review/tess-m4 | TESS-M4-001,TESS-M4-002,TESS-M4-003 | 22K | Gate M5 |
|
||||
| TESS-M5-001 | not-started | Implement Matrix/native runtime provider behind common contracts and parity suite | #711 | codex | packages/mosaic, packages/agent | feat/tess-matrix-provider | TESS-M4-V | 30K | TESS-TRN-001 |
|
||||
| TESS-M5-002 | not-started | Complete migration inventory, cutover, rollback, retention and deprecation evidence | #711 | sonnet | docs/tess | feat/tess-migration-docs | TESS-M4-V | 18K | TESS-MIG-001 |
|
||||
| TESS-M5-003 | not-started | Complete OpenAPI, user/admin/developer/plugin/operations docs and checklist | #711 | codex | docs | feat/tess-docs | TESS-M5-001,TESS-M5-002 | 22K | Documentation hard gate |
|
||||
| TESS-M5-V | not-started | Full baseline, contract, integration, Discord/CLI E2E, security review, recovery drill and rollback qualification | #711 | sonnet | apps/gateway, packages/agent, plugins/discord, packages/mosaic | review/tess-final | TESS-M5-003 | 35K | Maps AC-TESS-01..11 to evidence |
|
||||
@@ -1,46 +0,0 @@
|
||||
# Tess Threat Model
|
||||
|
||||
## Assets and Trust Boundaries
|
||||
|
||||
Assets: operator identity, tenant/project data, agent sessions, fleet control, approvals, credentials, memories, tool outputs, audit evidence, and provider transports.
|
||||
|
||||
Trust boundaries: Discord→plugin, CLI→gateway, plugin→gateway service identity, gateway→Pi/provider, Tess→Mos/fleet, Tess→Hermes, MCP→gateway, persistence, and tmux/Matrix transports.
|
||||
|
||||
## Threat Matrix
|
||||
|
||||
| ID | Severity | Threat | Required control | Required verification |
|
||||
| ----- | -------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||
| TM-01 | critical | Client invokes admin/system command without role | Server-side scope/role enforcement in executor; durable approval for privileged/destructive commands | Authenticated non-admin and forged-scope tests deny and audit |
|
||||
| TM-02 | critical | Cross-user/tenant list, attach, send, or terminate by guessed session ID | Owner/tenant binding on every session operation; admin override is explicit and audited | Cross-tenant matrix for REST, WS, CLI, Discord and provider methods |
|
||||
| TM-03 | high | MCP caller supplies another `userId` | Remove actor IDs from schemas; derive actor/tenant from authenticated context; per-tool scopes | Forged actor/tool calls deny; no victim data returned |
|
||||
| TM-04 | high | Discord ingress impersonates user/channel or bypasses gateway auth | Service-to-service identity, guild/channel/user allowlists, signed/correlated envelope, replay protection | Invalid service identity, unlisted IDs, replayed message IDs all deny |
|
||||
| TM-05 | high | Secrets/PII leak in chat, auth links, tool args, logs, memory, or DB | Redact before persistence/egress; DM/out-of-band auth flow; short-lived hashed token state; output classification | Seeded secret/PII canary absent from durable stores/logs/public channel |
|
||||
| TM-06 | high | Prompt/tool injection escalates from content to privileged action | Treat messages/files/tool output as untrusted data; structured proposals only; allowlisted tools; approval binds exact action digest | Injection corpus cannot invoke unapproved tools or alter authority |
|
||||
| TM-07 | high | Approval forged, replayed, or applied to modified action | One-time approval with actor, tenant, action digest, expiry, correlation and consumption record | Forged/replayed/expired/mutated approvals deny and audit |
|
||||
| TM-08 | medium | Restart causes message loss or duplicate side effects | Durable inbox/outbox/checkpoint; idempotency keys; transactional state transitions; bounded replay | Kill/restart at each state transition; exactly-once effect or safe dedupe |
|
||||
| TM-09 | medium | Session GC/retention crosses tenant/session scope | Session/user-scoped GC or separately authorized global retention job | GC one session; unrelated logs/memory remain unchanged |
|
||||
| TM-10 | high | tmux/Matrix transport target or identity spoofing | Exact target/socket binding, peer identity verification, Matrix whoami, authenticated transport metadata | Wrong socket/peer/room/identity refuses delivery/attach |
|
||||
| TM-11 | medium | Hermes adapter exposes unsupported or broader legacy powers | Capability negotiation, default deny, normalized scopes, adapter sandbox/timeouts | Unsupported and over-scoped operations fail closed |
|
||||
| TM-12 | medium | Tess competes with Mos or bypasses orchestration gates | Authority policy and correlated Mos handoff; no Tess worker-claim capability by default | Coding/decomposition intent produces handoff, not direct claim |
|
||||
|
||||
## Security Invariants
|
||||
|
||||
1. Authentication is not authorization; every command/tool/provider operation is authorized server-side.
|
||||
2. Actor, tenant, roles, and channel bindings come only from authenticated gateway context.
|
||||
3. No client-provided session ID grants ownership or attachment.
|
||||
4. No privileged action executes without a matching, unexpired, one-time approval when policy requires it.
|
||||
5. Redaction occurs before persistence and before channel egress.
|
||||
6. Every externally caused operation is replay-safe and correlated.
|
||||
7. Provider capability absence is a denial, not an invitation to shell around it.
|
||||
|
||||
## Existing Findings That Block Tess
|
||||
|
||||
- Command executor lacks server-side enforcement for declared scopes.
|
||||
- Session list/reuse/destroy surfaces are not owner-filtered consistently.
|
||||
- MCP schemas accept caller-supplied user identity.
|
||||
- Discord plugin lacks a complete authenticated service ingress and user/channel allowlists.
|
||||
- Chat/tool persistence lacks mandatory redaction.
|
||||
- Sessions/pending Discord output are in-memory and not restart-safe.
|
||||
- Session GC currently performs globally scoped promotion.
|
||||
|
||||
These are tracked as M1 security prerequisites and must pass independent security review before Tess ingress is enabled.
|
||||
@@ -1,30 +0,0 @@
|
||||
# Tess Verification Matrix
|
||||
|
||||
| Acceptance criterion | Requirements | Planned evidence | Gate |
|
||||
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
|
||||
| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V |
|
||||
| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V |
|
||||
| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V |
|
||||
| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | Authority E2E: coding request creates Mos handoff; safe status runs in Tess; no competing worker claim | M4-V |
|
||||
| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V |
|
||||
| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V |
|
||||
| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V |
|
||||
| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V |
|
||||
| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone |
|
||||
| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V |
|
||||
| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V |
|
||||
|
||||
## Security Abuse Suite Minimum
|
||||
|
||||
- Role/scope matrix for every command and provider capability.
|
||||
- Cross-tenant and cross-user session ID matrix across REST, WS, Discord, CLI, MCP, and providers.
|
||||
- Discord service identity, guild/channel/user allowlist, replay, attachment, and mention/DM policy cases.
|
||||
- Prompt/tool injection corpus and structured-proposal enforcement.
|
||||
- Approval action-digest mutation, replay, expiry, tenant, and actor mismatch cases.
|
||||
- Secret/PII canaries through message, attachment, tool args/output, logs, memory, audit, and error paths.
|
||||
- Restart fault injection before/after enqueue, provider send, side effect, response persistence, and acknowledgement.
|
||||
- Wrong tmux socket/target and Matrix identity/room/replay cases.
|
||||
|
||||
## Evidence Rules
|
||||
|
||||
Evidence must include command/test name, terminal result, CI run URL, PR/merge reference, environment, and artifact/log location. A worker self-report is not evidence until independently verified.
|
||||
@@ -15,22 +15,6 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
|
||||
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
|
||||
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
|
||||
|
||||
## Agent Host Prerequisites
|
||||
|
||||
Agent hosts must provide the Python runtime shape that runtime agents and
|
||||
Mosaic automation assume is present.
|
||||
|
||||
For Debian/Ubuntu hosts:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
# #561: bare python invocations from agents must resolve.
|
||||
sudo apt-get install -y python3 python-is-python3
|
||||
```
|
||||
|
||||
For non-Debian hosts, install the equivalent Python 3 runtime and ensure
|
||||
`/usr/bin/python` resolves to `python3` (for example, via a managed symlink).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test": "turbo run test",
|
||||
"test": "turbo run test && pnpm run test:installer",
|
||||
"test:installer": "bash tools/install-next-lane.test.sh",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"prepare": "husky"
|
||||
|
||||
@@ -11,9 +11,37 @@ import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.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 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', () => ({
|
||||
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
|
||||
@@ -23,6 +51,14 @@ vi.mock('../../src/stages/gateway-bootstrap.js', () => ({
|
||||
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 { runWizard } from '../../src/wizard.js';
|
||||
|
||||
@@ -44,6 +80,16 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
||||
}
|
||||
gatewayConfigMock.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
|
||||
// branch does not call `process.exit(1)` during these tests.
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
@@ -98,8 +144,12 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
||||
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
|
||||
});
|
||||
|
||||
it('does not invoke bootstrap when config stage reports not ready', async () => {
|
||||
gatewayConfigMock.mockResolvedValue({ ready: false });
|
||||
it('prints the success summary only after gateway health succeeds', async () => {
|
||||
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
|
||||
p.log('Gateway is healthy.');
|
||||
return { ready: true, host: 'localhost', port: 14242 };
|
||||
});
|
||||
gatewayBootstrapMock.mockResolvedValue({ completed: true });
|
||||
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
@@ -118,6 +168,43 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
||||
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(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -143,4 +230,34 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
||||
expect(gatewayConfigMock).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.'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,16 @@ The installer:
|
||||
- Runs a health audit
|
||||
- Detects existing installs and preserves local files (SOUL.md, USER.md, etc.)
|
||||
|
||||
### Install lanes
|
||||
|
||||
| Lane | Command | Use when | Source |
|
||||
| ------------------------ | ------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| Stable | `bash tools/install.sh` | You want the released framework and CLI | npm `@mosaicstack/mosaic@latest` + `main` |
|
||||
| Prerelease integration | `bash tools/install.sh --next` | You want the permanent `next` integration lane | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
|
||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are validating a branch before release | Build-from-source at the requested git 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` wins and uses the source path.
|
||||
|
||||
## First Run
|
||||
|
||||
After install, open a new terminal (or `source ~/.bashrc`) and run:
|
||||
@@ -108,8 +118,8 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
|
||||
├── TOOLS.md ← Machine-level tool reference (generated by mosaic init)
|
||||
├── STANDARDS.md ← Machine-wide standards
|
||||
├── guides/ ← Operational guides (E2E delivery, PRD, docs, etc.)
|
||||
├── bin/ ← CLI tools (mosaic launcher, mosaic-init, mosaic-doctor, etc.)
|
||||
├── tools/ ← Tool suites: git, orchestrator, prdy, quality, etc.
|
||||
│ └── _scripts/ ← Framework helper scripts (sync skills, doctor, runtime links)
|
||||
├── runtime/ ← Runtime adapters + runtime-specific references
|
||||
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
|
||||
│ ├── codex/ ← instructions.md, RUNTIME.md
|
||||
@@ -174,7 +184,9 @@ The installer preserves local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/` by
|
||||
bash tools/install.sh --check # Version check only
|
||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||
bash tools/install.sh --next # Prerelease lane: npm @next, source fallback
|
||||
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)
|
||||
```
|
||||
|
||||
## Universal Skills
|
||||
@@ -182,15 +194,15 @@ bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`, then links each skill into runtime directories.
|
||||
|
||||
```bash
|
||||
mosaic sync # Full sync (clone + link)
|
||||
~/.config/mosaic/bin/mosaic-sync-skills --link-only # Re-link only
|
||||
mosaic sync # Full sync (clone + link)
|
||||
~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only
|
||||
```
|
||||
|
||||
## Health Audit
|
||||
|
||||
```bash
|
||||
mosaic doctor # Standard audit
|
||||
~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
|
||||
mosaic doctor # Standard audit
|
||||
~/.config/mosaic/tools/_scripts/mosaic-doctor --fail-on-warn # Strict mode
|
||||
```
|
||||
|
||||
## MCP Registration
|
||||
@@ -201,8 +213,8 @@ sequential-thinking MCP is required for Mosaic Stack. The installer registers it
|
||||
To verify or re-register manually:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking
|
||||
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check
|
||||
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking
|
||||
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking --check
|
||||
```
|
||||
|
||||
### Claude Code MCP Registration
|
||||
|
||||
@@ -15,22 +15,6 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
|
||||
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
|
||||
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
|
||||
|
||||
## Agent Host Prerequisites
|
||||
|
||||
Agent hosts must provide the Python runtime shape that runtime agents and
|
||||
Mosaic automation assume is present.
|
||||
|
||||
For Debian/Ubuntu hosts:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
# #561: bare python invocations from agents must resolve.
|
||||
sudo apt-get install -y python3 python-is-python3
|
||||
```
|
||||
|
||||
For non-Debian hosts, install the equivalent Python 3 runtime and ensure
|
||||
`/usr/bin/python` resolves to `python3` (for example, via a managed symlink).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
@@ -15,23 +15,13 @@
|
||||
#
|
||||
# After loading, service-specific env vars are exported.
|
||||
# Run `load_credentials --help` for details.
|
||||
#
|
||||
# Resolution order (first match wins):
|
||||
# 1. $MOSAIC_CREDENTIALS_FILE (explicit override — never second-guessed)
|
||||
# 2. $HOME/.config/mosaic/credentials.json
|
||||
# 3. /etc/mosaic/credentials.json (host-level fallback)
|
||||
# The /etc fallback exists for HOME-redirected profile environments, where
|
||||
# $HOME points at a per-profile directory that has no credentials file.
|
||||
# Operators symlink /etc/mosaic/credentials.json to the host's canonical
|
||||
# file once, instead of exporting MOSAIC_CREDENTIALS_FILE per invocation.
|
||||
|
||||
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
|
||||
for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do
|
||||
for _cand in "$HOME/.config/mosaic/credentials.json"; do
|
||||
if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi
|
||||
done
|
||||
: "${MOSAIC_CREDENTIALS_FILE:=$HOME/.config/mosaic/credentials.json}"
|
||||
fi
|
||||
export MOSAIC_CREDENTIALS_FILE
|
||||
|
||||
_mosaic_require_jq() {
|
||||
if ! command -v jq &>/dev/null; then
|
||||
|
||||
@@ -86,16 +86,7 @@ gitea_url_matches_host() {
|
||||
|
||||
get_gitea_service_for_host() {
|
||||
local host="$1"
|
||||
local cred_file="${MOSAIC_CREDENTIALS_FILE:-}"
|
||||
if [[ -z "$cred_file" ]]; then
|
||||
# Same resolution chain as _lib/credentials.sh: profile HOME, then
|
||||
# host-level /etc only if it exists; neither existing keeps the
|
||||
# $HOME default (matches the lib's final := fallback).
|
||||
cred_file="$HOME/.config/mosaic/credentials.json"
|
||||
if [[ ! -f "$cred_file" && -f /etc/mosaic/credentials.json ]]; then
|
||||
cred_file="/etc/mosaic/credentials.json"
|
||||
fi
|
||||
fi
|
||||
local cred_file="${MOSAIC_CREDENTIALS_FILE:-$HOME/.config/mosaic/credentials.json}"
|
||||
|
||||
case "$host" in
|
||||
git.mosaicstack.dev)
|
||||
@@ -240,33 +231,6 @@ get_gitea_login_for_host() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Validate the current authenticated Gitea user for a resolved Tea login.
|
||||
# Tea stores a user name with each login which can become stale after user rename,
|
||||
# token rotation, or server migration. Querying /user derives the identity from the
|
||||
# active credential instead of trusting that saved name. Callers fall back to the
|
||||
# host-scoped API path when this validation fails.
|
||||
get_gitea_authenticated_user() {
|
||||
local login_name="$1" response
|
||||
|
||||
command -v tea >/dev/null 2>&1 || return 1
|
||||
response=$(tea api --login "$login_name" /user 2>/dev/null) || return 1
|
||||
TEA_AUTHENTICATED_USER_JSON="$response" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
try:
|
||||
user = json.loads(os.environ["TEA_AUTHENTICATED_USER_JSON"])
|
||||
except (KeyError, json.JSONDecodeError):
|
||||
raise SystemExit(1)
|
||||
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if isinstance(login, str) and login:
|
||||
print(login)
|
||||
raise SystemExit(0)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
get_default_tea_login() {
|
||||
local logins_json
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ Examples:
|
||||
$(basename "$0") -i 42 -l "in-progress" -m "0.2.0"
|
||||
$(basename "$0") -i 42 -a @me
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
@@ -60,7 +60,7 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
|
||||
@@ -12,7 +12,6 @@ TITLE=""
|
||||
BODY=""
|
||||
LABELS=""
|
||||
MILESTONE=""
|
||||
INTERACTIVE=false
|
||||
|
||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||
|
||||
@@ -67,15 +66,13 @@ Options:
|
||||
-b, --body BODY Issue body/description
|
||||
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
|
||||
-m, --milestone NAME Milestone name to assign
|
||||
-i, --interactive Prompt for missing issue fields
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
$(basename "$0") -t "Fix login bug" -l "bug,priority-high"
|
||||
$(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0"
|
||||
$(basename "$0") -i
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
@@ -97,12 +94,8 @@ while [[ $# -gt 0 ]]; do
|
||||
MILESTONE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-i|--interactive)
|
||||
INTERACTIVE=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
@@ -111,13 +104,6 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$INTERACTIVE" == true ]]; then
|
||||
[[ -n "$TITLE" ]] || read -r -p "Issue title: " TITLE
|
||||
[[ -n "$BODY" ]] || read -r -p "Issue body (optional): " BODY || true
|
||||
[[ -n "$LABELS" ]] || read -r -p "Labels, comma-separated (optional): " LABELS || true
|
||||
[[ -n "$MILESTONE" ]] || read -r -p "Milestone (optional): " MILESTONE || true
|
||||
fi
|
||||
|
||||
if [[ -z "$TITLE" ]]; then
|
||||
echo "Error: Title is required (-t)" >&2
|
||||
usage
|
||||
@@ -141,11 +127,6 @@ case "$PLATFORM" in
|
||||
gitea_issue_create_api
|
||||
exit $?
|
||||
}
|
||||
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
||||
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
||||
gitea_issue_create_api
|
||||
exit $?
|
||||
fi
|
||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
||||
CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||
|
||||
@@ -36,7 +36,7 @@ Examples:
|
||||
$(basename "$0") -m "0.2.0" # Issues in milestone 0.2.0
|
||||
$(basename "$0") --repo ddk/ai-bma # List issues from anywhere
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
@@ -67,7 +67,7 @@ while [[ $# -gt 0 ]]; do
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
|
||||
@@ -37,7 +37,7 @@ Examples:
|
||||
$(basename "$0") -t "0.0.1" -d "Pre-MVP Foundation Sprint"
|
||||
$(basename "$0") -t "0.1.0" -d "MVP Release" --due "2025-03-01"
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
@@ -60,7 +60,7 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
|
||||
@@ -86,7 +86,7 @@ Examples:
|
||||
$(basename "$0") -i 42 -b "Implements the feature described in #42"
|
||||
$(basename "$0") -t "WIP: New feature" --draft
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
@@ -125,7 +125,7 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
@@ -183,11 +183,6 @@ case "$PLATFORM" in
|
||||
gitea_pr_create_api
|
||||
exit $?
|
||||
}
|
||||
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
||||
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
||||
gitea_pr_create_api
|
||||
exit $?
|
||||
fi
|
||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
||||
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||
|
||||
@@ -34,7 +34,7 @@ Examples:
|
||||
$(basename "$0") -s merged -a username # Merged PRs by user
|
||||
$(basename "$0") --repo ddk/ai-bma # List PRs from anywhere
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
@@ -61,7 +61,7 @@ while [[ $# -gt 0 ]]; do
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
|
||||
@@ -35,7 +35,7 @@ Examples:
|
||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
@@ -63,7 +63,7 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
|
||||
@@ -45,11 +45,6 @@ JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "api" ]]; then
|
||||
printf '%s\n' '{"login":"ci-bot"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||
if [[ "${MOSAIC_TEA_FAIL_PR_CREATE:-}" == "1" && "$*" == pr\ create* ]]; then
|
||||
echo 'GetUserByName: simulated stale login failure' >&2
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for #701: -h/--help must exit 0, bad args must still exit nonzero.
|
||||
#
|
||||
# Covers the 7 wrappers whose usage() previously hard-coded `exit 1`, so every
|
||||
# --help invocation exited nonzero and logged a phantom isError across fleet lanes.
|
||||
# Asserts, per wrapper:
|
||||
# 1. `--help` exits 0 and prints usage.
|
||||
# 2. `-h` exits 0 and prints usage.
|
||||
# 3. A genuine unknown flag still exits nonzero (usage() default path untouched).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
WRAPPERS=(
|
||||
issue-assign.sh
|
||||
issue-create.sh
|
||||
issue-list.sh
|
||||
milestone-create.sh
|
||||
pr-create.sh
|
||||
pr-list.sh
|
||||
pr-merge.sh
|
||||
)
|
||||
|
||||
fail=0
|
||||
|
||||
for wrapper in "${WRAPPERS[@]}"; do
|
||||
path="$SCRIPT_DIR/$wrapper"
|
||||
|
||||
if ! output=$(bash "$path" --help 2>&1); then
|
||||
echo "FAIL: $wrapper --help exited nonzero" >&2
|
||||
fail=1
|
||||
elif [[ "$output" != Usage:* ]]; then
|
||||
echo "FAIL: $wrapper --help did not print usage" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if ! bash "$path" -h >/dev/null 2>&1; then
|
||||
echo "FAIL: $wrapper -h exited nonzero" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if bash "$path" --this-is-not-a-real-flag >/dev/null 2>&1; then
|
||||
echo "FAIL: $wrapper accepted an unknown flag (should have exited nonzero)" >&2
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "help-exit-code regression passed (7/7 wrappers)"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
@@ -55,11 +55,6 @@ JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "api" ]]; then
|
||||
printf '%s\n' '{"login":"ci-bot"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then
|
||||
desc=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for #703: interactive issue creation and stale Tea-user fallback.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
LOG_FILE="$WORK_DIR/calls.log"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
|
||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
|
||||
JSON
|
||||
|
||||
cat > "$BIN_DIR/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$*" == "login list --output json" ]]; then
|
||||
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${1:-}" == "api" ]]; then
|
||||
if [[ "${MOSAIC_TEA_STALE_USER:-0}" == "1" ]]; then
|
||||
echo 'GetUserByName: stale configured user' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' '{"login":"current-user"}'
|
||||
exit 0
|
||||
fi
|
||||
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||
printf '%s\n' '{"number":703}'
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
|
||||
|
||||
run_wrapper() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||
"$@"
|
||||
)
|
||||
}
|
||||
|
||||
: > "$LOG_FILE"
|
||||
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
|
||||
|
||||
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Interactive title --description Interactive body --labels label-a,label-b --milestone M1' "$LOG_FILE"
|
||||
|
||||
# Explicit values take precedence in interactive mode: no title input is
|
||||
# supplied, but the wrapper still creates the issue with the explicit title.
|
||||
: > "$LOG_FILE"
|
||||
printf '\n\n\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i -t 'Explicit title' >/dev/null
|
||||
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Explicit title' "$LOG_FILE"
|
||||
|
||||
: > "$LOG_FILE"
|
||||
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/issue-create.sh" -t 'Fallback title' -b 'Fallback body' >/dev/null 2>"$WORK_DIR/issue-stderr"
|
||||
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues' "$LOG_FILE"
|
||||
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/issue-stderr"
|
||||
if grep -q -- 'tea issue create' "$LOG_FILE"; then
|
||||
echo 'FAIL: issue-create invoked Tea mutation after stale-user validation failed' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
: > "$LOG_FILE"
|
||||
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/pr-create.sh" -t 'PR fallback' -H feature/wrapfix >/dev/null 2>"$WORK_DIR/pr-stderr"
|
||||
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls' "$LOG_FILE"
|
||||
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/pr-stderr"
|
||||
if grep -q -- 'tea pr create' "$LOG_FILE"; then
|
||||
echo 'FAIL: pr-create invoked Tea mutation after stale-user validation failed' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo 'issue-create interactive/auth regression harness passed'
|
||||
@@ -87,10 +87,6 @@ message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards.
|
||||
|
||||
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
|
||||
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
|
||||
- `auto-submit-drafts.sh` — watchdog that flushes stable unsubmitted prompt
|
||||
drafts on a coordinator pane (default target `mos-claude`); run it as a
|
||||
long-lived process alongside the coordinator session.
|
||||
- `agent-send.test.sh` — regression + grammar lock for `agent-send.sh`.
|
||||
- `test-send-message-socket.sh` — smoke test for named-socket isolation.
|
||||
|
||||
## Distribution
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# auto-submit-drafts.sh — watchdog for Claude Code panes that receive channel
|
||||
# messages but leave them as unsubmitted prompt drafts. Intended for Mos only.
|
||||
set -uo pipefail
|
||||
|
||||
TARGET="${1:-mos-claude}"
|
||||
INTERVAL="${INTERVAL:-2}"
|
||||
STABLE_SECONDS="${STABLE_SECONDS:-4}"
|
||||
LOG_PREFIX="[auto-submit-drafts:$TARGET]"
|
||||
|
||||
last_prompt=""
|
||||
first_seen=0
|
||||
|
||||
prompt_text() {
|
||||
tmux capture-pane -t "$TARGET" -p 2>/dev/null | python3 -c '
|
||||
import sys, re
|
||||
lines = sys.stdin.read().splitlines()
|
||||
idx = None
|
||||
for i in range(len(lines)-1, -1, -1):
|
||||
if "❯" in lines[i]:
|
||||
idx = i
|
||||
break
|
||||
if idx is None:
|
||||
raise SystemExit
|
||||
parts = []
|
||||
after = lines[idx].split("❯", 1)[1]
|
||||
parts.append(after)
|
||||
for line in lines[idx+1:]:
|
||||
# Stop at Claude Code separator/border lines.
|
||||
if "─" in line or "╰" in line or "╭" in line:
|
||||
break
|
||||
s = line.replace("\u00a0", " ")
|
||||
s = re.sub(r"[\x00-\x1f\x7f]", "", s).strip()
|
||||
if s:
|
||||
parts.append(s)
|
||||
text = " ".join(parts).replace("\u00a0", " ")
|
||||
text = re.sub(r"[\x00-\x1f\x7f]", "", text).strip()
|
||||
print(text)
|
||||
'
|
||||
}
|
||||
|
||||
while true; do
|
||||
if ! tmux has-session -t "$TARGET" 2>/dev/null; then
|
||||
echo "$LOG_PREFIX target missing; waiting" >&2
|
||||
sleep "$INTERVAL"
|
||||
last_prompt=""
|
||||
first_seen=0
|
||||
continue
|
||||
fi
|
||||
|
||||
current="$(prompt_text || true)"
|
||||
now="$(date +%s)"
|
||||
|
||||
if [[ -z "$current" ]]; then
|
||||
last_prompt=""
|
||||
first_seen=0
|
||||
sleep "$INTERVAL"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$current" != "$last_prompt" ]]; then
|
||||
last_prompt="$current"
|
||||
first_seen="$now"
|
||||
sleep "$INTERVAL"
|
||||
continue
|
||||
fi
|
||||
|
||||
age=$(( now - first_seen ))
|
||||
if (( age >= STABLE_SECONDS )); then
|
||||
echo "$LOG_PREFIX submitting stable draft after ${age}s: ${current:0:120}" >&2
|
||||
tmux send-keys -t "$TARGET" C-j
|
||||
sleep 0.8
|
||||
tmux send-keys -t "$TARGET" C-m
|
||||
sleep 2
|
||||
last_prompt=""
|
||||
first_seen=0
|
||||
else
|
||||
sleep "$INTERVAL"
|
||||
fi
|
||||
done
|
||||
@@ -77,20 +77,10 @@ snippet=$(printf '%s' "$MSG" | tr '\n' ' ' | tr -s ' ' | sed 's/[^[:print:]]//g'
|
||||
|
||||
# 1) Paste the body as a bracketed paste so multi-line content does not submit
|
||||
# line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`.
|
||||
# Buffer name MUST be unique per invocation: concurrent senders on the shared
|
||||
# tmux server race a fixed name (load overwrites load, -d deletes underneath),
|
||||
# cross-delivering or dropping messages — bit the fleet on the 2026-07-09
|
||||
# simultaneous restart (briefs swapped between sessions).
|
||||
BUF="__mosaic_send_$$_$(date +%s%N)"
|
||||
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
|
||||
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b __mosaic_send -
|
||||
# -p = bracketed paste when the client supports it; fall back if not.
|
||||
"${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \
|
||||
|| "${tmux_cmd[@]}" paste-buffer -d -b "$BUF" -t "$EFFECTIVE_TARGET" \
|
||||
|| "${tmux_cmd[@]}" delete-buffer -b "$BUF" 2>/dev/null
|
||||
# ^ -d deletes the buffer only on a SUCCESSFUL paste; if both attempts fail
|
||||
# (e.g. the target vanished since the liveness check), delete explicitly —
|
||||
# named buffers are exempt from tmux's buffer-limit eviction, so orphans
|
||||
# would otherwise accumulate forever.
|
||||
"${tmux_cmd[@]}" paste-buffer -d -p -b __mosaic_send -t "$EFFECTIVE_TARGET" 2>/dev/null \
|
||||
|| "${tmux_cmd[@]}" paste-buffer -d -b __mosaic_send -t "$EFFECTIVE_TARGET"
|
||||
sleep 0.5
|
||||
|
||||
# 2) Submit, then verify; flush with another Enter if it is still a draft.
|
||||
|
||||
@@ -47,32 +47,4 @@ if capture_default | grep -qF "agent socket hello"; then
|
||||
fail "agent-send.sh leaked named-socket message to default tmux server"
|
||||
fi
|
||||
|
||||
# Concurrency: parallel senders on one server must not cross-deliver or drop.
|
||||
# Locks the unique-per-invocation paste buffer (a fixed buffer name raced:
|
||||
# load overwrote load, -d deleted underneath — messages swapped between panes).
|
||||
CONC_N=5
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
tmux -L "$SOCKET" new-session -d -s "conc-$i" -c "$TMPDIR" 'bash --noprofile --norc -i'
|
||||
done
|
||||
pids=()
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=conc-$i" -m "CONCPAYLOAD-${i}-END" >/dev/null &
|
||||
pids+=($!)
|
||||
done
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" || fail "concurrent send-message.sh invocation exited non-zero"
|
||||
done
|
||||
sleep 0.2
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p)
|
||||
printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${i}-END" \
|
||||
|| fail "concurrent send dropped payload for pane conc-$i"
|
||||
for j in $(seq 1 "$CONC_N"); do
|
||||
[ "$j" = "$i" ] && continue
|
||||
if printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${j}-END"; then
|
||||
fail "concurrent send cross-delivered payload $j to pane conc-$i"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
echo "ok - named tmux socket send tools"
|
||||
|
||||
@@ -37,7 +37,7 @@ const RUNTIME_DEFS: Record<
|
||||
label: 'Pi',
|
||||
command: 'pi',
|
||||
versionFlag: '--version',
|
||||
installHint: 'npm install -g @mariozechner/pi-coding-agent',
|
||||
installHint: 'curl -fsSL https://pi.dev/install.sh | sh',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -85,16 +85,16 @@ function makeConfigService(): ConfigService {
|
||||
|
||||
describe('finalizeStage — skill installer', () => {
|
||||
let tmp: string;
|
||||
let binDir: string;
|
||||
let scriptsDir: string;
|
||||
let syncScript: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'mosaic-finalize-'));
|
||||
binDir = join(tmp, 'bin');
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
syncScript = join(binDir, 'mosaic-sync-skills');
|
||||
scriptsDir = join(tmp, 'tools', '_scripts');
|
||||
mkdirSync(scriptsDir, { recursive: true });
|
||||
syncScript = join(scriptsDir, 'mosaic-sync-skills');
|
||||
|
||||
// Default: script exists and succeeds
|
||||
// Default: current framework layout has tools/_scripts and succeeds.
|
||||
writeFileSync(syncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });
|
||||
spawnSyncMock.mockReturnValue({ status: 0, stdout: 'ok', stderr: '' });
|
||||
});
|
||||
@@ -122,10 +122,29 @@ describe('finalizeStage — skill installer', () => {
|
||||
|
||||
const call = findSkillsSyncCall();
|
||||
expect(call).toBeDefined();
|
||||
expect(call![1]).toEqual([join(tmp, 'tools', '_scripts', 'mosaic-sync-skills')]);
|
||||
const opts = call![2] as { env?: Record<string, string> };
|
||||
expect(opts.env?.['MOSAIC_INSTALL_SKILLS']).toBe('brainstorming:lint:systematic-debugging');
|
||||
});
|
||||
|
||||
it('falls back to legacy bin path for pre-migration installs', async () => {
|
||||
rmSync(syncScript);
|
||||
const legacyBinDir = join(tmp, 'bin');
|
||||
mkdirSync(legacyBinDir, { recursive: true });
|
||||
const legacySyncScript = join(legacyBinDir, 'mosaic-sync-skills');
|
||||
writeFileSync(legacySyncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });
|
||||
|
||||
const state = makeState(tmp, ['brainstorming']);
|
||||
const p = buildPrompter();
|
||||
const config = makeConfigService();
|
||||
|
||||
await finalizeStage(p, state, config);
|
||||
|
||||
const call = findSkillsSyncCall();
|
||||
expect(call).toBeDefined();
|
||||
expect(call![1]).toEqual([legacySyncScript]);
|
||||
});
|
||||
|
||||
it('skips the sync script entirely when no skills are selected', async () => {
|
||||
const state = makeState(tmp, []);
|
||||
const p = buildPrompter();
|
||||
@@ -165,7 +184,9 @@ describe('finalizeStage — skill installer', () => {
|
||||
|
||||
// spawnSync should NOT have been called for the skills script
|
||||
expect(findSkillsSyncCall()).toBeUndefined();
|
||||
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
expect(p.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('tools/_scripts/mosaic-sync-skills'),
|
||||
);
|
||||
});
|
||||
|
||||
it('includes skills count in the summary when install succeeds', async () => {
|
||||
|
||||
@@ -7,8 +7,21 @@ import type { ConfigService } from '../config/config-service.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { getShellProfilePath } from '../platform/detect.js';
|
||||
|
||||
function frameworkScriptPath(mosaicHome: string, name: string): string {
|
||||
const currentPath = join(mosaicHome, 'tools', '_scripts', name);
|
||||
if (existsSync(currentPath)) return currentPath;
|
||||
|
||||
// Backward-compatible fallback for pre-migration installs that still have bin/.
|
||||
const legacyPath = join(mosaicHome, 'bin', name);
|
||||
if (existsSync(legacyPath)) return legacyPath;
|
||||
|
||||
// Return the current expected path so user-facing errors point at the layout
|
||||
// installed by packages/mosaic/framework/install.sh.
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
||||
const script = frameworkScriptPath(mosaicHome, 'mosaic-link-runtime-assets');
|
||||
if (existsSync(script)) {
|
||||
try {
|
||||
spawnSync('bash', [script], {
|
||||
@@ -48,7 +61,7 @@ function syncSkills(mosaicHome: string, selectedSkills: string[]): SyncSkillsRes
|
||||
return { success: true, installedCount: 0 };
|
||||
}
|
||||
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
|
||||
const script = frameworkScriptPath(mosaicHome, 'mosaic-sync-skills');
|
||||
if (!existsSync(script)) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -96,7 +109,7 @@ interface DoctorResult {
|
||||
}
|
||||
|
||||
function runDoctor(mosaicHome: string): DoctorResult {
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-doctor');
|
||||
const script = frameworkScriptPath(mosaicHome, 'mosaic-doctor');
|
||||
if (!existsSync(script)) {
|
||||
return { warnings: 0, output: 'mosaic-doctor not found' };
|
||||
}
|
||||
@@ -149,11 +162,24 @@ function setupPath(mosaicHome: string, _p: WizardPrompter): PathAction {
|
||||
}
|
||||
}
|
||||
|
||||
export interface FinalizeStageOptions {
|
||||
/**
|
||||
* Defer the success summary/outro so callers can run downstream readiness
|
||||
* gates (gateway health/bootstrap) before claiming Mosaic is ready.
|
||||
*/
|
||||
deferSummary?: boolean;
|
||||
}
|
||||
|
||||
export interface FinalizeStageResult {
|
||||
showSummary: () => void;
|
||||
}
|
||||
|
||||
export async function finalizeStage(
|
||||
p: WizardPrompter,
|
||||
state: WizardState,
|
||||
config: ConfigService,
|
||||
): Promise<void> {
|
||||
options: FinalizeStageOptions = {},
|
||||
): Promise<FinalizeStageResult> {
|
||||
p.separator();
|
||||
|
||||
const spin = p.spinner();
|
||||
@@ -200,44 +226,56 @@ export async function finalizeStage(
|
||||
// 6. PATH setup
|
||||
const pathAction = setupPath(state.mosaicHome, p);
|
||||
|
||||
// 7. Summary
|
||||
const skillsSummary = skillsResult.success
|
||||
? skillsResult.installedCount > 0
|
||||
? `${skillsResult.installedCount.toString()} installed`
|
||||
: 'none selected'
|
||||
: `install failed — ${skillsResult.failureReason ?? 'unknown error'}`;
|
||||
let summaryShown = false;
|
||||
const showSummary = () => {
|
||||
if (summaryShown) return;
|
||||
summaryShown = true;
|
||||
|
||||
const summary: string[] = [
|
||||
`Agent: ${state.soul.agentName ?? 'Assistant'}`,
|
||||
`Style: ${state.soul.communicationStyle ?? 'direct'}`,
|
||||
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
|
||||
`Skills: ${skillsSummary}`,
|
||||
`Config: ${state.mosaicHome}`,
|
||||
];
|
||||
// 7. Summary
|
||||
const skillsSummary = skillsResult.success
|
||||
? skillsResult.installedCount > 0
|
||||
? `${skillsResult.installedCount.toString()} installed`
|
||||
: 'none selected'
|
||||
: `install failed — ${skillsResult.failureReason ?? 'unknown error'}`;
|
||||
|
||||
if (doctorResult.warnings > 0) {
|
||||
summary.push(
|
||||
`Health: ${doctorResult.warnings.toString()} warning(s) — run 'mosaic doctor' for details`,
|
||||
);
|
||||
} else {
|
||||
summary.push('Health: all checks passed');
|
||||
const summary: string[] = [
|
||||
`Agent: ${state.soul.agentName ?? 'Assistant'}`,
|
||||
`Style: ${state.soul.communicationStyle ?? 'direct'}`,
|
||||
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
|
||||
`Skills: ${skillsSummary}`,
|
||||
`Config: ${state.mosaicHome}`,
|
||||
];
|
||||
|
||||
if (doctorResult.warnings > 0) {
|
||||
summary.push(
|
||||
`Health: ${doctorResult.warnings.toString()} warning(s) — run 'mosaic doctor' for details`,
|
||||
);
|
||||
} else {
|
||||
summary.push('Health: all checks passed');
|
||||
}
|
||||
|
||||
p.note(summary.join('\n'), 'Installation Summary');
|
||||
|
||||
// 8. Next steps
|
||||
const nextSteps: string[] = [];
|
||||
if (pathAction === 'added') {
|
||||
const profilePath = getShellProfilePath();
|
||||
nextSteps.push(`Reload shell: source ${profilePath ?? '~/.profile'}`);
|
||||
}
|
||||
if (state.runtimes.detected.length === 0) {
|
||||
nextSteps.push('Install at least one runtime (claude, codex, or opencode)');
|
||||
}
|
||||
nextSteps.push("Launch with 'mosaic claude' (or codex/opencode)");
|
||||
nextSteps.push('Edit identity files directly in ~/.config/mosaic/ for fine-tuning');
|
||||
|
||||
p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps');
|
||||
|
||||
p.outro('Mosaic is ready.');
|
||||
};
|
||||
|
||||
if (!options.deferSummary) {
|
||||
showSummary();
|
||||
}
|
||||
|
||||
p.note(summary.join('\n'), 'Installation Summary');
|
||||
|
||||
// 8. Next steps
|
||||
const nextSteps: string[] = [];
|
||||
if (pathAction === 'added') {
|
||||
const profilePath = getShellProfilePath();
|
||||
nextSteps.push(`Reload shell: source ${profilePath ?? '~/.profile'}`);
|
||||
}
|
||||
if (state.runtimes.detected.length === 0) {
|
||||
nextSteps.push('Install at least one runtime (claude, codex, or opencode)');
|
||||
}
|
||||
nextSteps.push("Launch with 'mosaic claude' (or codex/opencode)");
|
||||
nextSteps.push('Edit identity files directly in ~/.config/mosaic/ for fine-tuning');
|
||||
|
||||
p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps');
|
||||
|
||||
p.outro('Mosaic is ready.');
|
||||
return { showSummary };
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ describe('gatewayConfigStage', () => {
|
||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||
delete process.env['MOSAIC_DATABASE_URL'];
|
||||
delete process.env['MOSAIC_VALKEY_URL'];
|
||||
delete process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -167,6 +168,75 @@ describe('gatewayConfigStage', () => {
|
||||
expect(state.gateway?.regeneratedConfig).toBe(true);
|
||||
});
|
||||
|
||||
it('installs the gateway package on fresh install when skipInstall is not set', async () => {
|
||||
const p = buildPrompter();
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: false,
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(daemonState.installPkgCalled).toBe(1);
|
||||
});
|
||||
|
||||
it('honors MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 and skips the registry install (dev/offline installs)', async () => {
|
||||
process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'] = '1';
|
||||
const p = buildPrompter();
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: false,
|
||||
});
|
||||
|
||||
// The source-built global gateway must NOT be overwritten by @latest.
|
||||
expect(result.ready).toBe(true);
|
||||
expect(daemonState.installPkgCalled).toBe(0);
|
||||
});
|
||||
|
||||
it('does not ask for a gateway API key when provider setup was completed with no key', async () => {
|
||||
delete process.env['MOSAIC_ASSUME_YES'];
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
|
||||
try {
|
||||
const textFn = vi.fn(async (opts: { message: string; initialValue?: string }) => {
|
||||
if (opts.message === 'Gateway port') return opts.initialValue ?? '14242';
|
||||
if (opts.message === 'Web UI hostname (for browser access)') return 'localhost';
|
||||
if (opts.message.includes('API_KEY')) {
|
||||
throw new Error('gateway API key prompt should be skipped');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const p = buildPrompter({ text: textFn, select: vi.fn().mockResolvedValue('local') });
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: true,
|
||||
providerType: 'none',
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(textFn).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining('API_KEY') }),
|
||||
);
|
||||
const envContents = readFileSync(daemonState.envFile, 'utf-8');
|
||||
expect(envContents).not.toContain('ANTHROPIC_API_KEY=');
|
||||
expect(envContents).not.toContain('OPENAI_API_KEY=');
|
||||
} finally {
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('short-circuits when gateway is already fully installed and user declines rerun', async () => {
|
||||
// Pre-populate both files + running daemon + meta with token
|
||||
const fs = require('node:fs');
|
||||
|
||||
@@ -294,7 +294,12 @@ export async function gatewayConfigStage(
|
||||
}
|
||||
|
||||
// Install the gateway npm package on first install or after failure.
|
||||
if (!opts.skipInstall && !daemonRunning) {
|
||||
// MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 forces a skip even without opts.skipInstall:
|
||||
// used by dev/offline installs where @mosaicstack/gateway is already present
|
||||
// globally (e.g. a build-from-source `install.sh --dev`) and must not be
|
||||
// overwritten by the registry @latest build.
|
||||
const skipNpmInstall = opts.skipInstall || process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'] === '1';
|
||||
if (!skipNpmInstall && !daemonRunning) {
|
||||
installGatewayPackage();
|
||||
}
|
||||
|
||||
@@ -506,6 +511,9 @@ async function collectAndWriteConfig(
|
||||
if (opts.providerKey) {
|
||||
anthropicKey = opts.providerKey;
|
||||
p.log(`Using API key from provider setup (${opts.providerType ?? 'unknown'}).`);
|
||||
} else if (opts.providerType === 'none') {
|
||||
anthropicKey = '';
|
||||
p.log('No API key provided during provider setup; skipping gateway API key prompt.');
|
||||
} else {
|
||||
anthropicKey = await p.text({
|
||||
message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)',
|
||||
|
||||
@@ -37,6 +37,7 @@ export async function quickStartPath(
|
||||
|
||||
// 1. Provider setup (first question)
|
||||
await providerSetupStage(prompter, state);
|
||||
state.completedSections?.add('providers');
|
||||
|
||||
// Apply sensible defaults for everything else
|
||||
state.soul.agentName ??= 'Mosaic';
|
||||
@@ -57,9 +58,13 @@ export async function quickStartPath(
|
||||
|
||||
// Skills (recommended set, no user input in quick mode)
|
||||
await skillsSelectStage(prompter, state);
|
||||
state.completedSections?.add('skills');
|
||||
|
||||
// Finalize (writes configs, links runtime assets, syncs skills)
|
||||
await finalizeStage(prompter, state, configService);
|
||||
// Finalize writes configs/assets/skills, but defer the success summary until
|
||||
// after the gateway health/bootstrap gates complete.
|
||||
const finalizeResult = await finalizeStage(prompter, state, configService, {
|
||||
deferSummary: true,
|
||||
});
|
||||
|
||||
// Gateway config + bootstrap
|
||||
if (!options.skipGateway) {
|
||||
@@ -72,7 +77,7 @@ export async function quickStartPath(
|
||||
portOverride: options.gatewayPortOverride,
|
||||
skipInstall: options.skipGatewayNpmInstall,
|
||||
providerKey: state.providerKey,
|
||||
providerType: state.providerType ?? 'none',
|
||||
providerType: state.providerType,
|
||||
});
|
||||
|
||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||
@@ -80,19 +85,24 @@ export async function quickStartPath(
|
||||
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
finalizeResult.showSummary();
|
||||
} catch (err) {
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
finalizeResult.showSummary();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,11 @@ type MenuChoice =
|
||||
| 'advanced'
|
||||
| 'finish';
|
||||
|
||||
function menuSectionKey(section: MenuChoice): MenuSection | null {
|
||||
if (section === 'quick-start' || section === 'finish') return null;
|
||||
return section === 'gateway-config' ? 'gateway' : section;
|
||||
}
|
||||
|
||||
function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
|
||||
const labels: Record<MenuChoice, string> = {
|
||||
'quick-start': 'Quick Start',
|
||||
@@ -137,14 +142,24 @@ function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
|
||||
finish: 'Finish & Apply',
|
||||
};
|
||||
const base = labels[section];
|
||||
const sectionKey: MenuSection =
|
||||
section === 'gateway-config' ? 'gateway' : (section as MenuSection);
|
||||
if (completed.has(sectionKey)) {
|
||||
const sectionKey = menuSectionKey(section);
|
||||
if (sectionKey && completed.has(sectionKey)) {
|
||||
return `${base} [done]`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function skipCompletedMenuChoice(
|
||||
prompter: WizardPrompter,
|
||||
completed: Set<MenuSection>,
|
||||
choice: MenuChoice,
|
||||
): boolean {
|
||||
const sectionKey = menuSectionKey(choice);
|
||||
if (!sectionKey || !completed.has(sectionKey)) return false;
|
||||
prompter.log(`${menuLabel(choice, completed)} is already complete; skipping.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runMenuLoop(
|
||||
prompter: WizardPrompter,
|
||||
state: WizardState,
|
||||
@@ -201,21 +216,25 @@ async function runMenuLoop(
|
||||
return; // Quick start is a complete flow — exit menu
|
||||
|
||||
case 'providers':
|
||||
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
|
||||
await providerSetupStage(prompter, state);
|
||||
completed.add('providers');
|
||||
break;
|
||||
|
||||
case 'identity':
|
||||
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
|
||||
await agentIntentStage(prompter, state);
|
||||
completed.add('identity');
|
||||
break;
|
||||
|
||||
case 'skills':
|
||||
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
|
||||
await skillsSelectStage(prompter, state);
|
||||
completed.add('skills');
|
||||
break;
|
||||
|
||||
case 'gateway-config':
|
||||
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
|
||||
// Gateway config is handled during Finish — mark as "configured"
|
||||
// after user reviews settings.
|
||||
await runGatewaySubMenu(prompter, state, options);
|
||||
@@ -223,6 +242,7 @@ async function runMenuLoop(
|
||||
break;
|
||||
|
||||
case 'advanced':
|
||||
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
|
||||
await runAdvancedSubMenu(prompter, state);
|
||||
completed.add('advanced');
|
||||
break;
|
||||
@@ -310,8 +330,11 @@ async function runFinishPath(
|
||||
await skillsSelectStage(prompter, state);
|
||||
}
|
||||
|
||||
// Finalize (writes configs, links runtime assets, syncs skills)
|
||||
await finalizeStage(prompter, state, configService);
|
||||
// Finalize writes configs/assets/skills, but defer the success summary until
|
||||
// after the gateway health/bootstrap gates complete.
|
||||
const finalizeResult = await finalizeStage(prompter, state, configService, {
|
||||
deferSummary: true,
|
||||
});
|
||||
|
||||
// Gateway stages
|
||||
if (!options.skipGateway) {
|
||||
@@ -322,7 +345,7 @@ async function runFinishPath(
|
||||
portOverride: options.gatewayPortOverride,
|
||||
skipInstall: options.skipGatewayNpmInstall,
|
||||
providerKey: state.providerKey,
|
||||
providerType: state.providerType ?? 'none',
|
||||
providerType: state.providerType,
|
||||
});
|
||||
|
||||
if (configResult.ready && configResult.host && configResult.port) {
|
||||
@@ -333,12 +356,16 @@ async function runFinishPath(
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
finalizeResult.showSummary();
|
||||
}
|
||||
} catch (err) {
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
finalizeResult.showSummary();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,8 +401,11 @@ async function runHeadlessPath(
|
||||
// Skills
|
||||
await skillsSelectStage(prompter, state);
|
||||
|
||||
// Finalize
|
||||
await finalizeStage(prompter, state, configService);
|
||||
// Finalize writes configs/assets/skills, but defer the success summary until
|
||||
// after the gateway health/bootstrap gates complete.
|
||||
const finalizeResult = await finalizeStage(prompter, state, configService, {
|
||||
deferSummary: true,
|
||||
});
|
||||
|
||||
// Gateway stages
|
||||
if (!options.skipGateway) {
|
||||
@@ -386,26 +416,31 @@ async function runHeadlessPath(
|
||||
portOverride: options.gatewayPortOverride,
|
||||
skipInstall: options.skipGatewayNpmInstall,
|
||||
providerKey: state.providerKey,
|
||||
providerType: state.providerType ?? 'none',
|
||||
providerType: state.providerType,
|
||||
});
|
||||
|
||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
||||
process.exit(1);
|
||||
} else {
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
finalizeResult.showSummary();
|
||||
} catch (err) {
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
finalizeResult.showSummary();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,8 +461,11 @@ async function runKeepPath(
|
||||
// Skills
|
||||
await skillsSelectStage(prompter, state);
|
||||
|
||||
// Finalize
|
||||
await finalizeStage(prompter, state, configService);
|
||||
// Finalize writes configs/assets/skills, but defer the success summary until
|
||||
// after the gateway health/bootstrap gates complete.
|
||||
const finalizeResult = await finalizeStage(prompter, state, configService, {
|
||||
deferSummary: true,
|
||||
});
|
||||
|
||||
// Gateway stages
|
||||
if (!options.skipGateway) {
|
||||
@@ -447,11 +485,15 @@ async function runKeepPath(
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
finalizeResult.showSummary();
|
||||
}
|
||||
} catch (err) {
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
finalizeResult.showSummary();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
AgentRuntimeProvider,
|
||||
RuntimeAttachHandle,
|
||||
RuntimeCapability,
|
||||
RuntimeError,
|
||||
RuntimeSession,
|
||||
RuntimeStreamEvent,
|
||||
} from './agent-runtime-provider.js';
|
||||
|
||||
describe('AgentRuntimeProvider contract', (): void => {
|
||||
it('exposes stable, normalized runtime-only contracts', (): void => {
|
||||
const capability: RuntimeCapability = 'session.attach';
|
||||
const session: RuntimeSession = {
|
||||
id: 'session-1',
|
||||
providerId: 'fleet',
|
||||
runtimeId: 'tmux',
|
||||
state: 'active',
|
||||
createdAt: '2026-07-12T00:00:00.000Z',
|
||||
updatedAt: '2026-07-12T00:00:00.000Z',
|
||||
};
|
||||
const event: RuntimeStreamEvent = {
|
||||
type: 'message.delta',
|
||||
sessionId: session.id,
|
||||
cursor: '2',
|
||||
occurredAt: session.updatedAt,
|
||||
content: 'hello',
|
||||
};
|
||||
const error: RuntimeError = {
|
||||
code: 'capability_unsupported',
|
||||
message: 'Denied',
|
||||
retryable: false,
|
||||
};
|
||||
const attach: RuntimeAttachHandle = {
|
||||
attachmentId: 'attach-1',
|
||||
sessionId: session.id,
|
||||
mode: 'read',
|
||||
expiresAt: session.updatedAt,
|
||||
};
|
||||
const provider: Pick<AgentRuntimeProvider, 'capabilities' | 'getSessionTree' | 'attach'> =
|
||||
{} as Pick<AgentRuntimeProvider, 'capabilities' | 'getSessionTree' | 'attach'>;
|
||||
expect([capability, session.id, event.type, error.code, attach.mode, provider]).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
export type RuntimeCapability =
|
||||
| 'session.list'
|
||||
| 'session.tree'
|
||||
| 'session.stream'
|
||||
| 'session.send'
|
||||
| 'session.attach'
|
||||
| 'session.terminate';
|
||||
export type RuntimeSessionState = 'starting' | 'active' | 'idle' | 'stopped' | 'failed';
|
||||
export type RuntimeAttachMode = 'read' | 'control';
|
||||
|
||||
/** Server-derived immutable authority context. Client identity fields are intentionally absent. */
|
||||
export interface RuntimeScope {
|
||||
readonly actorId: string;
|
||||
readonly tenantId: string;
|
||||
readonly channelId: string;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
export interface RuntimeSession {
|
||||
id: string;
|
||||
providerId: string;
|
||||
runtimeId: string;
|
||||
parentSessionId?: string;
|
||||
state: RuntimeSessionState;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
export interface RuntimeSessionTree {
|
||||
session: RuntimeSession;
|
||||
children: RuntimeSessionTree[];
|
||||
}
|
||||
export interface RuntimeCapabilitySet {
|
||||
supported: RuntimeCapability[];
|
||||
}
|
||||
export interface RuntimeHealth {
|
||||
status: 'healthy' | 'degraded' | 'down';
|
||||
checkedAt: string;
|
||||
detail?: string;
|
||||
}
|
||||
export interface RuntimeMessage {
|
||||
content: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
export interface RuntimeAttachHandle {
|
||||
attachmentId: string;
|
||||
sessionId: string;
|
||||
mode: RuntimeAttachMode;
|
||||
expiresAt: string;
|
||||
}
|
||||
export interface RuntimeError {
|
||||
code:
|
||||
| 'capability_unsupported'
|
||||
| 'not_found'
|
||||
| 'forbidden'
|
||||
| 'conflict'
|
||||
| 'unavailable'
|
||||
| 'invalid_request';
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
export type RuntimeStreamEvent =
|
||||
| {
|
||||
type: 'session.state';
|
||||
sessionId: string;
|
||||
cursor: string;
|
||||
occurredAt: string;
|
||||
state: RuntimeSessionState;
|
||||
}
|
||||
| {
|
||||
type: 'message.delta';
|
||||
sessionId: string;
|
||||
cursor: string;
|
||||
occurredAt: string;
|
||||
content: string;
|
||||
}
|
||||
| {
|
||||
type: 'message.complete';
|
||||
sessionId: string;
|
||||
cursor: string;
|
||||
occurredAt: string;
|
||||
messageId: string;
|
||||
}
|
||||
| {
|
||||
type: 'runtime.error';
|
||||
sessionId: string;
|
||||
cursor: string;
|
||||
occurredAt: string;
|
||||
error: RuntimeError;
|
||||
};
|
||||
|
||||
/** Runtime-neutral boundary; implementations fail closed for unsupported operations. */
|
||||
export interface AgentRuntimeProvider {
|
||||
readonly id: string;
|
||||
capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet>;
|
||||
health(scope: RuntimeScope): Promise<RuntimeHealth>;
|
||||
listSessions(scope: RuntimeScope): Promise<RuntimeSession[]>;
|
||||
getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]>;
|
||||
streamSession(
|
||||
sessionId: string,
|
||||
cursor: string | undefined,
|
||||
scope: RuntimeScope,
|
||||
): AsyncIterable<RuntimeStreamEvent>;
|
||||
sendMessage(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void>;
|
||||
attach(
|
||||
sessionId: string,
|
||||
mode: RuntimeAttachMode,
|
||||
scope: RuntimeScope,
|
||||
): Promise<RuntimeAttachHandle>;
|
||||
detach(attachmentId: string, scope: RuntimeScope): Promise<void>;
|
||||
terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void>;
|
||||
}
|
||||
@@ -2,5 +2,3 @@
|
||||
export interface AgentSessionHandle {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export * from './agent-runtime-provider.js';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
CommandManifestPayload,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandPayload,
|
||||
SlashCommandResultPayload,
|
||||
SystemReloadPayload,
|
||||
@@ -117,7 +116,6 @@ export interface ServerToClientEvents {
|
||||
'session:info': (payload: SessionInfoPayload) => void;
|
||||
'commands:manifest': (payload: CommandManifestPayload) => void;
|
||||
'command:result': (payload: SlashCommandResultPayload) => void;
|
||||
'command:approval': (payload: SlashCommandApprovalResultPayload) => void;
|
||||
'system:reload': (payload: SystemReloadPayload) => void;
|
||||
error: (payload: ErrorPayload) => void;
|
||||
}
|
||||
@@ -127,6 +125,5 @@ export interface ClientToServerEvents {
|
||||
message: (data: ChatMessagePayload) => void;
|
||||
'set:thinking': (data: SetThinkingPayload) => void;
|
||||
'command:execute': (data: SlashCommandPayload) => void;
|
||||
'command:approve': (data: SlashCommandPayload) => void;
|
||||
abort: (data: AbortPayload) => void;
|
||||
}
|
||||
|
||||
@@ -63,18 +63,6 @@ export interface SlashCommandPayload {
|
||||
conversationId: string;
|
||||
command: string;
|
||||
args?: string;
|
||||
/** One-time server-issued approval for a privileged command execution. */
|
||||
approvalId?: string;
|
||||
}
|
||||
|
||||
/** Server response to a request to approve a privileged slash command. */
|
||||
export interface SlashCommandApprovalResultPayload {
|
||||
conversationId: string;
|
||||
command: string;
|
||||
success: boolean;
|
||||
approvalId?: string;
|
||||
expiresAt?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** Server response to a slash command */
|
||||
|
||||
222
tools/install-next-lane.test.sh
Executable file
222
tools/install-next-lane.test.sh
Executable file
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
FAKE_BIN="$TMP/bin"
|
||||
HOME_DIR="$TMP/home"
|
||||
PREFIX="$TMP/prefix"
|
||||
MOSAIC_HOME="$TMP/mosaic"
|
||||
STATE="$TMP/state"
|
||||
LOG="$TMP/npm.log"
|
||||
mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE"
|
||||
|
||||
cat > "$FAKE_BIN/npm" <<'FAKE_NPM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||
STATE="${MOSAIC_TEST_STATE:?}"
|
||||
echo "$*" >> "$LOG"
|
||||
|
||||
if [[ "$1" == "view" ]]; then
|
||||
case "$2 $3" in
|
||||
"@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;;
|
||||
"@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;;
|
||||
"@mosaicstack/mosaic version") echo "0.0.48" ;;
|
||||
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "install" ]]; then
|
||||
case "$*" in
|
||||
*"@mosaicstack/mosaic@0.0.49-next.999"*)
|
||||
echo "0.0.49-next.999" > "$STATE/mosaic"
|
||||
;;
|
||||
*"@mosaicstack/gateway@0.0.7-next.999"*)
|
||||
if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then
|
||||
echo "forced gateway install failure" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "0.0.7-next.999" > "$STATE/gateway"
|
||||
;;
|
||||
*"mosaicstack-mosaic-0.0.0-source.tgz"*)
|
||||
echo "0.0.0-source" > "$STATE/mosaic"
|
||||
;;
|
||||
*"mosaicstack-gateway-0.0.0-source.tgz"*)
|
||||
echo "0.0.0-source" > "$STATE/gateway"
|
||||
;;
|
||||
*) echo "unexpected npm install: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "ls" ]]; then
|
||||
cli="$(cat "$STATE/mosaic" 2>/dev/null || true)"
|
||||
gateway="$(cat "$STATE/gateway" 2>/dev/null || true)"
|
||||
node -e '
|
||||
const cli = process.argv[1];
|
||||
const gateway = process.argv[2];
|
||||
const dependencies = {};
|
||||
if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli };
|
||||
if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway };
|
||||
process.stdout.write(JSON.stringify({ dependencies }));
|
||||
' "$cli" "$gateway"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unexpected npm command: $*" >&2
|
||||
exit 1
|
||||
FAKE_NPM
|
||||
chmod +x "$FAKE_BIN/npm"
|
||||
|
||||
cat > "$FAKE_BIN/curl" <<'FAKE_CURL'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# The fake tar creates the source tree; curl only needs to keep the pipe alive.
|
||||
exit 0
|
||||
FAKE_CURL
|
||||
chmod +x "$FAKE_BIN/curl"
|
||||
|
||||
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
dest=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-C) dest="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
if [[ -z "$dest" ]]; then
|
||||
echo "fake tar missing -C destination" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$dest/stack/packages/mosaic" "$dest/stack/apps/gateway"
|
||||
FAKE_TAR
|
||||
chmod +x "$FAKE_BIN/tar"
|
||||
|
||||
cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||
echo "pnpm $*" >> "$LOG"
|
||||
|
||||
if [[ "$1" == "pack" ]]; then
|
||||
out=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--pack-destination) out="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
if [[ -z "$out" ]]; then
|
||||
echo "fake pnpm pack missing destination" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$out"
|
||||
case "$PWD" in
|
||||
*/apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;;
|
||||
*/packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;;
|
||||
*) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# install/build commands are no-ops in this harness.
|
||||
exit 0
|
||||
FAKE_PNPM
|
||||
chmod +x "$FAKE_BIN/pnpm"
|
||||
|
||||
reset_state() {
|
||||
: > "$LOG"
|
||||
rm -f "$STATE"/*
|
||||
}
|
||||
|
||||
reset_state
|
||||
echo "[test] --next fast path pins resolved package versions"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||
)"
|
||||
|
||||
grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT"
|
||||
grep -qF 'install -g @mosaicstack/gateway@0.0.7-next.999' "$LOG"
|
||||
grep -qF 'install -g @mosaicstack/mosaic@0.0.49-next.999' "$LOG"
|
||||
if grep -qE '^install -g .+@next( |$)' "$LOG"; then
|
||||
echo "expected exact-version installs, found mutable @next install" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -qF 'Downloading source from next' <<<"$OUTPUT"; then
|
||||
echo "fast path unexpectedly fell back to source" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
reset_state
|
||||
echo "[test] fast path failure falls back to source build"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||
)"
|
||||
|
||||
grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT"
|
||||
grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT"
|
||||
grep -qF 'Downloading source from next' <<<"$OUTPUT"
|
||||
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||
grep -qF 'install -g @mosaicstack/mosaic@0.0.49-next.999' "$LOG"
|
||||
grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG"
|
||||
grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG"
|
||||
[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]]
|
||||
[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]]
|
||||
|
||||
reset_state
|
||||
echo "[test] explicit --ref keeps source lane and avoids @next lookup"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x
|
||||
)"
|
||||
|
||||
grep -qF 'explicit ref wins, build-from-source' <<<"$OUTPUT"
|
||||
if grep -qF '@next version' "$LOG"; then
|
||||
echo "explicit ref should not query @next dist-tags" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
reset_state
|
||||
echo "[test] --check --next warns on mismatched prerelease pipeline suffixes"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --check --cli --next
|
||||
)"
|
||||
|
||||
grep -qF '@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source.' <<<"$OUTPUT"
|
||||
|
||||
echo "[test] installer next lane tests passed"
|
||||
202
tools/install.sh
202
tools/install.sh
@@ -16,6 +16,10 @@
|
||||
# --framework Install/upgrade framework only (skip npm CLI)
|
||||
# --cli Install/upgrade npm CLI only (skip framework)
|
||||
# --ref <branch> Git ref for framework archive (default: main)
|
||||
# --next Prerelease lane: try fast npm @next install for CLI +
|
||||
# gateway from the Gitea registry, then fall back to a
|
||||
# source build at next if unavailable. Explicit
|
||||
# --ref/MOSAIC_REF wins and uses the source path.
|
||||
# --dev Build CLI + gateway FROM SOURCE at --ref instead of the
|
||||
# registry @latest. Zero registry writes — packs local
|
||||
# tarballs and installs them globally. Use to test a branch
|
||||
@@ -31,6 +35,7 @@
|
||||
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
|
||||
# MOSAIC_NO_COLOR — disable colour (set to 1)
|
||||
# MOSAIC_REF — git ref for framework (default: main)
|
||||
# MOSAIC_NEXT — equivalent to --next (set to 1)
|
||||
# MOSAIC_DEV — equivalent to --dev (set to 1)
|
||||
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -49,7 +54,12 @@ FLAG_NO_AUTO_LAUNCH=false
|
||||
FLAG_YES=false
|
||||
FLAG_UNINSTALL=false
|
||||
FLAG_DEV=false
|
||||
FLAG_NEXT=false
|
||||
GIT_REF="${MOSAIC_REF:-main}"
|
||||
GIT_REF_EXPLICIT=false
|
||||
if [[ -n "${MOSAIC_REF:-}" ]]; then
|
||||
GIT_REF_EXPLICIT=true
|
||||
fi
|
||||
|
||||
# MOSAIC_ASSUME_YES env var acts the same as --yes
|
||||
if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
|
||||
@@ -61,13 +71,24 @@ if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
|
||||
FLAG_DEV=true
|
||||
fi
|
||||
|
||||
# MOSAIC_NEXT env var acts the same as --next: fast npm @next install with
|
||||
# source fallback from the permanent next integration branch unless
|
||||
# MOSAIC_REF/--ref explicitly wins.
|
||||
if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then
|
||||
FLAG_NEXT=true
|
||||
if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then
|
||||
GIT_REF="next"
|
||||
fi
|
||||
fi
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--check) FLAG_CHECK=true; shift ;;
|
||||
--framework) FLAG_CLI=false; shift ;;
|
||||
--cli) FLAG_FRAMEWORK=false; shift ;;
|
||||
--ref) GIT_REF="${2:-main}"; shift 2 ;;
|
||||
--ref) GIT_REF="${2:-main}"; GIT_REF_EXPLICIT=true; shift 2 ;;
|
||||
--dev) FLAG_DEV=true; shift ;;
|
||||
--next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
|
||||
--yes|-y) FLAG_YES=true; shift ;;
|
||||
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
|
||||
--uninstall) FLAG_UNINSTALL=true; shift ;;
|
||||
@@ -75,12 +96,24 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# Explicit refs represent a request for that exact source tree. Keep --next as
|
||||
# a lane selector, but do not install the registry @next package for a different
|
||||
# ref than the permanent next branch.
|
||||
if [[ "$FLAG_NEXT" == "true" && "$GIT_REF_EXPLICIT" == "true" ]]; then
|
||||
FLAG_DEV=true
|
||||
fi
|
||||
|
||||
if [[ "$FLAG_YES" == "true" ]]; then
|
||||
export MOSAIC_ASSUME_YES=1
|
||||
fi
|
||||
|
||||
# ─── constants ────────────────────────────────────────────────────────────────
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}"
|
||||
SCOPE="${MOSAIC_SCOPE:-@mosaicstack}"
|
||||
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
|
||||
CLI_PKG="${SCOPE}/mosaic"
|
||||
GATEWAY_PKG="${SCOPE}/gateway"
|
||||
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
|
||||
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
|
||||
|
||||
@@ -95,6 +128,20 @@ fi
|
||||
WORK_DIR=""
|
||||
EXTRACTED_DIR=""
|
||||
|
||||
newest_matching_file() {
|
||||
local dir="$1"
|
||||
local pattern="$2"
|
||||
local matches=()
|
||||
[[ -d "$dir" ]] || return 0
|
||||
shopt -s nullglob
|
||||
# shellcheck disable=SC2206 # Intentional glob expansion for caller-provided file pattern.
|
||||
matches=("$dir"/$pattern)
|
||||
shopt -u nullglob
|
||||
[[ "${#matches[@]}" -gt 0 ]] || return 0
|
||||
# shellcheck disable=SC2012 # Need portable mtime sorting across Linux/macOS.
|
||||
ls -1t "${matches[@]}" 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
# ─── uninstall path ───────────────────────────────────────────────────────────
|
||||
# Shell-level uninstall for when the CLI is broken or not available.
|
||||
# Handles: framework directory, npm CLI package, npmrc scope line.
|
||||
@@ -158,7 +205,7 @@ if [[ "$FLAG_UNINSTALL" == "true" ]]; then
|
||||
# Find most recent backup
|
||||
backup=""
|
||||
if [[ -d "$dir" ]]; then
|
||||
backup="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
|
||||
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
|
||||
fi
|
||||
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
|
||||
cp "$backup" "$dest"
|
||||
@@ -214,6 +261,22 @@ fail() { echo "${R}✖${RESET} $*" >&2; }
|
||||
dim() { echo "${DIM}$*${RESET}"; }
|
||||
step() { echo ""; echo "${BOLD}$*${RESET}"; }
|
||||
|
||||
is_next_registry_lane() {
|
||||
[[ "$FLAG_NEXT" == "true" && "$FLAG_DEV" == "false" && "$GIT_REF" == "next" && "$GIT_REF_EXPLICIT" == "false" ]]
|
||||
}
|
||||
|
||||
source_ref_details() {
|
||||
if is_next_registry_lane; then
|
||||
echo "ref: next, --next prerelease lane"
|
||||
elif [[ "$FLAG_NEXT" == "true" && "$GIT_REF" == "next" ]]; then
|
||||
echo "ref: next, --next prerelease lane (build-from-source)"
|
||||
elif [[ "$FLAG_NEXT" == "true" ]]; then
|
||||
echo "ref: ${GIT_REF}, --next requested, explicit ref wins"
|
||||
else
|
||||
echo "ref: ${GIT_REF}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
require_cmd() {
|
||||
@@ -236,10 +299,43 @@ installed_cli_version() {
|
||||
fi
|
||||
}
|
||||
|
||||
installed_gateway_version() {
|
||||
local json
|
||||
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
|
||||
if [[ -n "$json" ]]; then
|
||||
node -e "
|
||||
const d = JSON.parse(process.argv[1]);
|
||||
const v = d?.dependencies?.['${GATEWAY_PKG}']?.version ?? '';
|
||||
process.stdout.write(v);
|
||||
" "$json" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
latest_cli_version() {
|
||||
npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true
|
||||
}
|
||||
|
||||
next_cli_version() {
|
||||
npm view "${CLI_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
|
||||
}
|
||||
|
||||
next_gateway_version() {
|
||||
npm view "${GATEWAY_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
|
||||
}
|
||||
|
||||
next_pipeline_suffix() {
|
||||
printf '%s' "$1" | sed -n 's/.*-next\.\([0-9][0-9]*\)$/\1/p'
|
||||
}
|
||||
|
||||
next_versions_share_pipeline() {
|
||||
local cli_next="$1"
|
||||
local gateway_next="$2"
|
||||
local cli_pipeline gateway_pipeline
|
||||
cli_pipeline="$(next_pipeline_suffix "$cli_next")"
|
||||
gateway_pipeline="$(next_pipeline_suffix "$gateway_next")"
|
||||
[[ -n "$cli_pipeline" && -n "$gateway_pipeline" && "$cli_pipeline" == "$gateway_pipeline" ]]
|
||||
}
|
||||
|
||||
version_lt() {
|
||||
node -e "
|
||||
const a=process.argv[1], b=process.argv[2];
|
||||
@@ -332,8 +428,8 @@ install_cli_from_source() {
|
||||
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
|
||||
|
||||
local cli_tgz gw_tgz
|
||||
cli_tgz="$(ls -1t "$out_dir"/mosaicstack-mosaic-*.tgz 2>/dev/null | head -1)"
|
||||
gw_tgz="$(ls -1t "$out_dir"/mosaicstack-gateway-*.tgz 2>/dev/null | head -1)"
|
||||
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')"
|
||||
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')"
|
||||
|
||||
if [[ ! -f "$cli_tgz" ]]; then
|
||||
fail "CLI tarball was not produced by pnpm pack."
|
||||
@@ -355,6 +451,49 @@ install_cli_from_source() {
|
||||
ok "Installed from source: CLI $(installed_cli_version)"
|
||||
}
|
||||
|
||||
install_next_cli_from_registry() {
|
||||
local cli_next gateway_next
|
||||
cli_next="$(next_cli_version)"
|
||||
gateway_next="$(next_gateway_version)"
|
||||
|
||||
if [[ -z "$cli_next" ]]; then
|
||||
warn "${CLI_PKG}@next is unavailable from $REGISTRY."
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$gateway_next" ]]; then
|
||||
warn "${GATEWAY_PKG}@next is unavailable from $REGISTRY."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! next_versions_share_pipeline "$cli_next" "$gateway_next"; then
|
||||
warn "@next CLI/gateway versions do not share a pipeline suffix (${cli_next}, ${gateway_next})."
|
||||
return 1
|
||||
fi
|
||||
|
||||
info "Installing ${CLI_PKG}@${cli_next} from registry…"
|
||||
if ! npm install -g "${CLI_PKG}@${cli_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
|
||||
warn "Fast CLI @next install failed."
|
||||
return 1
|
||||
fi
|
||||
|
||||
info "Installing ${GATEWAY_PKG}@${gateway_next} from registry…"
|
||||
if ! npm install -g "${GATEWAY_PKG}@${gateway_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
|
||||
warn "Fast gateway @next install failed."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local installed_cli installed_gateway
|
||||
installed_cli="$(installed_cli_version)"
|
||||
installed_gateway="$(installed_gateway_version)"
|
||||
if [[ "$installed_cli" != "$cli_next" || "$installed_gateway" != "$gateway_next" ]]; then
|
||||
warn "Installed @next versions did not match resolved versions (CLI: ${installed_cli:-missing}, gateway: ${installed_gateway:-missing})."
|
||||
return 1
|
||||
fi
|
||||
|
||||
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
|
||||
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
|
||||
}
|
||||
|
||||
# ─── preflight ────────────────────────────────────────────────────────────────
|
||||
|
||||
require_cmd node
|
||||
@@ -388,7 +527,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
|
||||
else
|
||||
dim " Installed: (none)"
|
||||
fi
|
||||
dim " Source: ${REPO_BASE} (ref: ${GIT_REF})"
|
||||
dim " Source: ${REPO_BASE} ($(source_ref_details))"
|
||||
echo ""
|
||||
|
||||
if [[ "$FLAG_CHECK" == "true" ]]; then
|
||||
@@ -455,8 +594,12 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
||||
fi
|
||||
|
||||
CURRENT="$(installed_cli_version)"
|
||||
NEXT_GATEWAY=""
|
||||
if [[ "$FLAG_DEV" == "true" ]]; then
|
||||
LATEST=""
|
||||
elif is_next_registry_lane; then
|
||||
LATEST="$(next_cli_version)"
|
||||
NEXT_GATEWAY="$(next_gateway_version)"
|
||||
else
|
||||
LATEST="$(latest_cli_version)"
|
||||
fi
|
||||
@@ -468,7 +611,19 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
||||
fi
|
||||
|
||||
if [[ "$FLAG_DEV" == "true" ]]; then
|
||||
dim " Source: ${REPO_BASE} (ref: ${GIT_REF}, build-from-source)"
|
||||
dim " Source: ${REPO_BASE} ($(source_ref_details), build-from-source)"
|
||||
elif is_next_registry_lane; then
|
||||
if [[ -n "$LATEST" ]]; then
|
||||
dim " Next CLI: ${CLI_PKG}@${LATEST}"
|
||||
else
|
||||
dim " Next CLI: (registry @next unreachable)"
|
||||
fi
|
||||
if [[ -n "$NEXT_GATEWAY" ]]; then
|
||||
dim " Next GW: ${GATEWAY_PKG}@${NEXT_GATEWAY}"
|
||||
else
|
||||
dim " Next GW: (registry @next unreachable)"
|
||||
fi
|
||||
dim " Fallback: ${REPO_BASE} (ref: next, build-from-source)"
|
||||
elif [[ -n "$LATEST" ]]; then
|
||||
dim " Latest: ${CLI_PKG}@${LATEST}"
|
||||
else
|
||||
@@ -479,6 +634,12 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
||||
if [[ "$FLAG_CHECK" == "true" ]]; then
|
||||
if [[ "$FLAG_DEV" == "true" ]]; then
|
||||
info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)."
|
||||
elif is_next_registry_lane; then
|
||||
if [[ -n "$LATEST" && -n "$NEXT_GATEWAY" ]] && next_versions_share_pipeline "$LATEST" "$NEXT_GATEWAY"; then
|
||||
ok "@next registry lane available: ${CLI_PKG}@${LATEST}, ${GATEWAY_PKG}@${NEXT_GATEWAY}."
|
||||
else
|
||||
warn "@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source."
|
||||
fi
|
||||
elif [[ -z "$LATEST" ]]; then
|
||||
warn "Could not reach registry."
|
||||
elif [[ -z "$CURRENT" ]]; then
|
||||
@@ -495,6 +656,23 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
||||
ensure_monorepo
|
||||
install_cli_from_source
|
||||
|
||||
# PATH check for npm prefix
|
||||
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
|
||||
warn "$PREFIX/bin is not on your PATH"
|
||||
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
|
||||
fi
|
||||
elif is_next_registry_lane; then
|
||||
info "Next mode — trying fast npm @next install from ${REGISTRY}…"
|
||||
if install_next_cli_from_registry; then
|
||||
:
|
||||
else
|
||||
warn "Falling back to source build at ref ${GIT_REF}; --next will not hard-fail on registry issues."
|
||||
unset MOSAIC_GATEWAY_SKIP_NPM_INSTALL
|
||||
ensure_monorepo
|
||||
install_cli_from_source
|
||||
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
|
||||
fi
|
||||
|
||||
# PATH check for npm prefix
|
||||
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
|
||||
warn "$PREFIX/bin is not on your PATH"
|
||||
@@ -603,7 +781,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
||||
local base dir backup_path backup_val
|
||||
base="$(basename "$dest")"
|
||||
dir="$(dirname "$dest")"
|
||||
backup_path="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
|
||||
backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
|
||||
if [[ -n "$backup_path" ]]; then
|
||||
backup_val="\"$backup_path\""
|
||||
else
|
||||
@@ -628,7 +806,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
||||
NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]"
|
||||
fi
|
||||
|
||||
node -e "
|
||||
if node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const p = process.argv[1];
|
||||
@@ -653,9 +831,11 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
||||
"$MANIFEST_CLI_VERSION" \
|
||||
"$MANIFEST_FW_VERSION" \
|
||||
"$NPMRC_LINES_JSON" \
|
||||
"$RUNTIME_COPIES" 2>/dev/null \
|
||||
&& ok "Install manifest written: $MANIFEST_PATH" \
|
||||
|| warn "Could not write install manifest (non-fatal)"
|
||||
"$RUNTIME_COPIES" 2>/dev/null; then
|
||||
ok "Install manifest written: $MANIFEST_PATH"
|
||||
else
|
||||
warn "Could not write install manifest (non-fatal)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
ok "Done."
|
||||
|
||||
Reference in New Issue
Block a user