Compare commits
1 Commits
e3d98d7390
...
feat/mosai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c74ba08f48 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -12,13 +12,3 @@ docs/reports/
|
||||
|
||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||
infra/step-ca/dev-password
|
||||
|
||||
# Scratch dirs created by the framework git-wrapper shell test harnesses
|
||||
.mosaic-test-work/
|
||||
|
||||
# Transient config files vite/vitest/esbuild write next to a *.config.ts while
|
||||
# loading it, then unlink. They are untracked but were not ignored, so turbo's
|
||||
# package traversal hashed them and intermittently failed CI with "Package
|
||||
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
|
||||
# file vanished mid-scan. Ignoring them removes the race.
|
||||
*.timestamp-*.mjs
|
||||
|
||||
4
.npmrc
4
.npmrc
@@ -1,5 +1 @@
|
||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
|
||||
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
||||
# instead of repopulating a fresh one.
|
||||
store-dir=/root/.local/share/pnpm/store
|
||||
|
||||
@@ -5,5 +5,3 @@ pnpm-lock.yaml
|
||||
**/drizzle
|
||||
**/.next
|
||||
.claude/
|
||||
docs/tess/TASKS.md
|
||||
docs/scratchpads/
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# Build & push the pre-baked CI base image (Dockerfile.ci) to the Gitea
|
||||
# registry CI already publishes to. Reuses the exact kaniko + auth pattern
|
||||
# from publish.yml (REGISTRY_USER/REGISTRY_PASS from_secret, /kaniko/.docker
|
||||
# config.json). Other pipelines (ci.yml, publish.yml) pull `ci-base:latest`
|
||||
# for their install step.
|
||||
#
|
||||
# Rebuild ONLY when the dependency set or the image recipe changes — a normal
|
||||
# code push must not trigger a 25-min image build. `path` applies to push/PR
|
||||
# events; `event: tag` (releases) rebuilds unconditionally so a tagged release
|
||||
# always ships a fresh base.
|
||||
when:
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'Dockerfile.ci'
|
||||
|
||||
steps:
|
||||
build-ci-base:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
REGISTRY_PASS:
|
||||
from_secret: gitea_password
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
# Lockfile-hash tag: an immutable identity for the exact dep set baked
|
||||
# into this image. `:latest` is the mutable pointer pipelines consume.
|
||||
LOCK_HASH=$(sha256sum pnpm-lock.yaml | cut -c1-12)
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/ci-base:latest"
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-$LOCK_HASH"
|
||||
/kaniko/executor --context . --dockerfile Dockerfile.ci $DESTINATIONS
|
||||
@@ -1,20 +1,9 @@
|
||||
# &node_image is the pre-baked CI base built by .woodpecker/ci-image.yml:
|
||||
# node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm
|
||||
# store. The install step resolves from the baked store (--prefer-offline)
|
||||
# instead of paying a ~731s cold fetch + native compile every run.
|
||||
variables:
|
||||
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
||||
- &node_image 'node:22-alpine'
|
||||
- &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).
|
||||
@@ -26,21 +15,8 @@ steps:
|
||||
image: *node_image
|
||||
commands:
|
||||
- corepack enable
|
||||
# python3/make/g++ are baked into ci-base; --prefer-offline resolves from
|
||||
# the baked pnpm store.
|
||||
- pnpm install --frozen-lockfile --prefer-offline
|
||||
|
||||
# Blocking gate: public framework package must contain no operator-specific
|
||||
# personal data or private $HOME defaults. Runs early (no node_modules needed).
|
||||
sanitization:
|
||||
image: *node_image
|
||||
commands:
|
||||
- apk add --no-cache bash
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
|
||||
# Resident line-count ceiling over framework-owned resident files
|
||||
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
||||
- apk add --no-cache python3 make g++
|
||||
- pnpm install --frozen-lockfile
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
@@ -49,7 +25,6 @@ steps:
|
||||
- pnpm typecheck
|
||||
depends_on:
|
||||
- install
|
||||
- sanitization
|
||||
|
||||
# lint, format, and test are independent — run in parallel after typecheck
|
||||
lint:
|
||||
@@ -76,7 +51,8 @@ steps:
|
||||
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
# postgresql-client (pg_isready) is baked into ci-base.
|
||||
# Install postgresql-client for pg_isready
|
||||
- apk add --no-cache postgresql-client
|
||||
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
||||
- |
|
||||
ready=0
|
||||
|
||||
@@ -2,27 +2,8 @@
|
||||
# Runs only on main branch push/tag
|
||||
|
||||
variables:
|
||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||
# toolchain + warm pnpm store. Kills the second cold install publish pays.
|
||||
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
||||
- &node_image 'node:22-alpine'
|
||||
- &enable_pnpm 'corepack enable'
|
||||
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
|
||||
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
|
||||
# images (gateway/appservice/web do not depend on @mosaicstack/mosaic). Releases
|
||||
# (tags) always build everything. Exclude-list keeps the default SAFE: any
|
||||
# non-excluded change still builds, so no transitive dep can silently go stale.
|
||||
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||
# the separate `event: tag` entry.)
|
||||
- &image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
exclude:
|
||||
- 'packages/mosaic/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
|
||||
when:
|
||||
- branch: [main]
|
||||
@@ -33,8 +14,7 @@ steps:
|
||||
image: *node_image
|
||||
commands:
|
||||
- corepack enable
|
||||
# Resolve from the baked pnpm store instead of a cold network fetch.
|
||||
- pnpm install --frozen-lockfile --prefer-offline
|
||||
- pnpm install --frozen-lockfile
|
||||
|
||||
build:
|
||||
image: *node_image
|
||||
@@ -46,15 +26,6 @@ steps:
|
||||
|
||||
publish-npm:
|
||||
image: *node_image
|
||||
# Publish only when a publishable package changed (or on a release tag); a
|
||||
# pure-docs merge runs no publish. Cheap step, but gated for cleanliness.
|
||||
when:
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- 'packages/**'
|
||||
environment:
|
||||
NPM_TOKEN:
|
||||
from_secret: gitea_token
|
||||
@@ -120,7 +91,6 @@ steps:
|
||||
|
||||
build-gateway:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
@@ -146,7 +116,6 @@ steps:
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
@@ -172,7 +141,6 @@ steps:
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# Pre-baked CI base image for Woodpecker pipelines.
|
||||
#
|
||||
# Purpose: eliminate the cold `pnpm install` that dominates every pipeline
|
||||
# (~731s median). This image ships the native toolchain (no per-run `apk add`)
|
||||
# AND a warm, content-addressable pnpm store with the dependency-tree tarballs
|
||||
# already fetched at build time. `pnpm fetch` only populates the store from the
|
||||
# lockfile — it does NOT run the native node-gyp builds (better-sqlite3,
|
||||
# node-pty, sqlite3, canvas, sharp); those still compile at `pnpm install`,
|
||||
# which is exactly why the musl toolchain stays baked into this image. A
|
||||
# pipeline `pnpm install --frozen-lockfile --prefer-offline` then resolves
|
||||
# tarballs from local hard-links (no network) and compiles natives against the
|
||||
# already-present toolchain, in tens of seconds instead of ~731s.
|
||||
#
|
||||
# Rebuilt only when `pnpm-lock.yaml` or this Dockerfile change
|
||||
# (see .woodpecker/ci-image.yml).
|
||||
#
|
||||
# Node version is pinned to 24 (Active LTS). This is the follow-up bump from
|
||||
# node:22 — sequenced AFTER the CI cache work landed so the runtime change
|
||||
# carries zero cache variables. node:26 stays held until it reaches LTS
|
||||
# (Oct 2026); the Current line risks native-module (node-gyp) breakage on a
|
||||
# runner that compiles better-sqlite3 / canvas / sharp / node-pty from source.
|
||||
FROM node:24-alpine
|
||||
|
||||
# Native toolchain required to compile node-gyp deps on musl, plus the
|
||||
# postgresql-client used by the test step's pg_isready readiness probe. `bash`
|
||||
# is baked here too — the sanitization step in ci.yml otherwise does a per-run
|
||||
# `apk add bash`.
|
||||
RUN apk add --no-cache python3 make g++ postgresql-client bash
|
||||
|
||||
# Pin pnpm to the repo's packageManager version via corepack.
|
||||
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Pin the store location so the pipeline can point `store-dir` at the same path.
|
||||
ENV PNPM_HOME=/root/.local/share/pnpm
|
||||
RUN pnpm config set store-dir /root/.local/share/pnpm/store
|
||||
|
||||
# Warm the store. `pnpm fetch` populates the content-addressable store with the
|
||||
# dependency tarballs directly from the lockfile (no package.json / workspace
|
||||
# needed), so a baked store stays valid until the lockfile changes. Note:
|
||||
# `fetch` does NOT compile native modules — that happens later at `pnpm install`
|
||||
# in the pipeline, against the toolchain baked above.
|
||||
COPY pnpm-lock.yaml ./
|
||||
RUN pnpm fetch --frozen-lockfile
|
||||
21
LICENSE
21
LICENSE
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Mosaic Stack
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -3,8 +3,6 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { AppserviceDaemon } from '../server.js';
|
||||
import type { DaemonConfig, DaemonRequest } from '../server.js';
|
||||
|
||||
const AGENTS_TYPE = 'org.uscllc.mosaic_as.agents';
|
||||
|
||||
const cfg: DaemonConfig = {
|
||||
homeserverUrl: 'https://hs.example',
|
||||
domain: 'hs.example',
|
||||
@@ -230,149 +228,6 @@ describe('AppserviceDaemon routing', () => {
|
||||
expect(bad.status).toBe(400);
|
||||
});
|
||||
|
||||
// A daemon whose fetch mock backs account_data with a mutable in-test object,
|
||||
// so register/verify/revoke round-trip through the (faked) homeserver.
|
||||
const makeAgentDaemon = () => {
|
||||
const accountData: { value: Record<string, unknown> | null } = { value: null };
|
||||
const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
|
||||
const url = new URL(String(input));
|
||||
const path = url.pathname;
|
||||
if (path.includes(`/account_data/${AGENTS_TYPE}`)) {
|
||||
if (init?.method === 'PUT') {
|
||||
accountData.value = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
return jsonResponse(200, {});
|
||||
}
|
||||
if (accountData.value === null) {
|
||||
return jsonResponse(404, { errcode: 'M_NOT_FOUND', error: 'not found' });
|
||||
}
|
||||
return jsonResponse(200, accountData.value);
|
||||
}
|
||||
if (path.endsWith('/register')) return jsonResponse(200, { user_id: 'whatever' });
|
||||
if (path.includes('/send/m.room.message/')) return jsonResponse(200, { event_id: '$sent' });
|
||||
return jsonResponse(200, {});
|
||||
});
|
||||
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
|
||||
return { daemon, fetchMock };
|
||||
};
|
||||
|
||||
const registerAgent = async (
|
||||
daemon: AppserviceDaemon,
|
||||
body: Record<string, unknown> = { alias: 'pi0', host: 'web1' },
|
||||
) =>
|
||||
daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/agents',
|
||||
authorizationHeader: 'Bearer bridge-secret',
|
||||
body,
|
||||
}),
|
||||
);
|
||||
|
||||
it('host token registers an agent and returns agent_user_id + bridge_token', async () => {
|
||||
const { daemon, fetchMock } = makeAgentDaemon();
|
||||
const res = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.agent_user_id).toBe('@agent-pi0-web1:hs.example');
|
||||
expect(String(res.body.bridge_token).startsWith('magt_')).toBe(true);
|
||||
const registerCall = fetchMock.mock.calls
|
||||
.map((c) => new URL(String(c[0])))
|
||||
.find((u) => u.pathname.endsWith('/register'));
|
||||
expect(registerCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('register requires a HOST token (agent token and no token are 403)', async () => {
|
||||
const { daemon } = makeAgentDaemon();
|
||||
const minted = await registerAgent(daemon);
|
||||
const agentToken = String(minted.body.bridge_token);
|
||||
|
||||
const asAgent = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/agents',
|
||||
authorizationHeader: `Bearer ${agentToken}`,
|
||||
body: { alias: 'pi1', host: 'web2' },
|
||||
}),
|
||||
);
|
||||
expect(asAgent.status).toBe(403);
|
||||
|
||||
const noAuth = await daemon.handle(
|
||||
request({ method: 'POST', path: '/bridge/v1/agents', body: { alias: 'pi1', host: 'web2' } }),
|
||||
);
|
||||
expect(noAuth.status).toBe(403);
|
||||
});
|
||||
|
||||
it('agent-scoped token may send as itself but not as another agent', async () => {
|
||||
const { daemon } = makeAgentDaemon();
|
||||
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
|
||||
const agentToken = String(minted.body.bridge_token);
|
||||
|
||||
const self = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/messages',
|
||||
authorizationHeader: `Bearer ${agentToken}`,
|
||||
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
|
||||
}),
|
||||
);
|
||||
expect(self.status).toBe(200);
|
||||
|
||||
const other = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/messages',
|
||||
authorizationHeader: `Bearer ${agentToken}`,
|
||||
body: { room_id: '!r:hs.example', agent: 'pi9-web9', body: 'hi' },
|
||||
}),
|
||||
);
|
||||
expect(other.status).toBe(403);
|
||||
expect(other.body.error).toBe('token not scoped to this agent');
|
||||
});
|
||||
|
||||
it('revoked agent token is rejected on messages', async () => {
|
||||
const { daemon } = makeAgentDaemon();
|
||||
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
|
||||
const agentToken = String(minted.body.bridge_token);
|
||||
|
||||
const revoke = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/agents/revoke',
|
||||
authorizationHeader: 'Bearer bridge-secret',
|
||||
body: { agent_user_id: '@agent-pi0-web1:hs.example' },
|
||||
}),
|
||||
);
|
||||
expect(revoke.status).toBe(200);
|
||||
expect(revoke.body.revoked).toBe(1);
|
||||
|
||||
const afterRevoke = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/messages',
|
||||
authorizationHeader: `Bearer ${agentToken}`,
|
||||
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
|
||||
}),
|
||||
);
|
||||
expect(afterRevoke.status).toBe(403);
|
||||
});
|
||||
|
||||
it('GET /bridge/v1/agents lists registered agents (host only)', async () => {
|
||||
const { daemon } = makeAgentDaemon();
|
||||
await registerAgent(daemon, { alias: 'pi0', host: 'web1', display_name: 'Pi Zero' });
|
||||
|
||||
const res = await daemon.handle(
|
||||
request({
|
||||
method: 'GET',
|
||||
path: '/bridge/v1/agents',
|
||||
authorizationHeader: 'Bearer bridge-secret',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const agents = res.body.agents as Array<Record<string, unknown>>;
|
||||
expect(agents).toHaveLength(1);
|
||||
expect(agents[0]?.agent_user_id).toBe('@agent-pi0-web1:hs.example');
|
||||
expect(agents[0]?.display_name).toBe('Pi Zero');
|
||||
});
|
||||
|
||||
it('empty bridge token list denies everything', async () => {
|
||||
const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {});
|
||||
const res = await daemon.handle(
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import {
|
||||
AgentTokenStore,
|
||||
AppserviceIntent,
|
||||
TransactionHandler,
|
||||
validateBridgeMessage,
|
||||
validateBridgeTyping,
|
||||
validateProvisionRoom,
|
||||
validateRegisterAgent,
|
||||
validateRevokeAgent,
|
||||
} from '@mosaicstack/appservice';
|
||||
import type { AppserviceConfig, MatrixEvent } from '@mosaicstack/appservice';
|
||||
|
||||
@@ -40,13 +37,6 @@ const safeEqual = (a: string, b: string): boolean => timingSafeEqual(digest(a),
|
||||
|
||||
const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/;
|
||||
|
||||
/**
|
||||
* Resolved identity for an authenticated /bridge/v1/* caller. Host principals
|
||||
* (the agent-comms host daemons) are unrestricted; agent principals are scoped
|
||||
* to a single virtual user and may only act as themselves.
|
||||
*/
|
||||
export type BridgePrincipal = { kind: 'host' } | { kind: 'agent'; agentUserId: string } | null;
|
||||
|
||||
/**
|
||||
* HTTP-framework-agnostic request router for the mosaic-as daemon: the
|
||||
* Application Service transactions endpoint (Synapse-facing) plus the
|
||||
@@ -56,7 +46,6 @@ export type BridgePrincipal = { kind: 'host' } | { kind: 'agent'; agentUserId: s
|
||||
export class AppserviceDaemon {
|
||||
readonly intent: AppserviceIntent;
|
||||
private readonly transactions: TransactionHandler;
|
||||
private readonly agents: AgentTokenStore;
|
||||
|
||||
constructor(
|
||||
private readonly cfg: DaemonConfig,
|
||||
@@ -64,7 +53,6 @@ export class AppserviceDaemon {
|
||||
private readonly log: (line: string) => void = (line) => console.log(line),
|
||||
) {
|
||||
this.intent = new AppserviceIntent(cfg, fetchImpl);
|
||||
this.agents = new AgentTokenStore(this.intent);
|
||||
this.transactions = new TransactionHandler({
|
||||
hsToken: cfg.hsToken,
|
||||
onEvent: (event) => this.onEvent(event),
|
||||
@@ -81,20 +69,10 @@ export class AppserviceDaemon {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the calling principal, or null when unauthorized. Fail-closed:
|
||||
* host tokens win (timing-safe compare); otherwise a magt_* bearer is looked
|
||||
* up in the agent token store; anything else is rejected. */
|
||||
private async bridgeAuthorized(
|
||||
authorizationHeader: string | undefined,
|
||||
): Promise<BridgePrincipal> {
|
||||
if (!authorizationHeader?.startsWith('Bearer ')) return null;
|
||||
private bridgeAuthorized(authorizationHeader: string | undefined): boolean {
|
||||
if (!authorizationHeader?.startsWith('Bearer ')) return false;
|
||||
const presented = authorizationHeader.slice('Bearer '.length);
|
||||
if (this.cfg.bridgeTokens.some((token) => safeEqual(presented, token))) {
|
||||
return { kind: 'host' };
|
||||
}
|
||||
const agentUserId = await this.agents.verifyToken(presented);
|
||||
if (agentUserId) return { kind: 'agent', agentUserId };
|
||||
return null;
|
||||
return this.cfg.bridgeTokens.some((token) => safeEqual(presented, token));
|
||||
}
|
||||
|
||||
async handle(req: DaemonRequest): Promise<DaemonResponse> {
|
||||
@@ -111,60 +89,12 @@ export class AppserviceDaemon {
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/bridge/v1/')) {
|
||||
const principal = await this.bridgeAuthorized(req.authorizationHeader);
|
||||
if (!principal) {
|
||||
if (!this.bridgeAuthorized(req.authorizationHeader)) {
|
||||
return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad bridge token' } };
|
||||
}
|
||||
try {
|
||||
if (req.method === 'POST' && req.path === '/bridge/v1/agents') {
|
||||
if (principal.kind !== 'host') {
|
||||
return {
|
||||
status: 403,
|
||||
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot register agents' },
|
||||
};
|
||||
}
|
||||
validateRegisterAgent(req.body);
|
||||
const { agentUserId, token } = await this.agents.register({
|
||||
alias: req.body.alias,
|
||||
host: req.body.host,
|
||||
displayName: req.body.display_name,
|
||||
});
|
||||
this.log(`registered agent ${agentUserId}`);
|
||||
return { status: 200, body: { agent_user_id: agentUserId, bridge_token: token } };
|
||||
}
|
||||
if (req.method === 'POST' && req.path === '/bridge/v1/agents/revoke') {
|
||||
if (principal.kind !== 'host') {
|
||||
return {
|
||||
status: 403,
|
||||
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot revoke agents' },
|
||||
};
|
||||
}
|
||||
validateRevokeAgent(req.body);
|
||||
const revoked = await this.agents.revoke(req.body.agent_user_id);
|
||||
this.log(`revoked ${revoked} token(s) for ${req.body.agent_user_id}`);
|
||||
return { status: 200, body: { revoked } };
|
||||
}
|
||||
if (req.method === 'GET' && req.path === '/bridge/v1/agents') {
|
||||
if (principal.kind !== 'host') {
|
||||
return {
|
||||
status: 403,
|
||||
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot list agents' },
|
||||
};
|
||||
}
|
||||
const agents = await this.agents.list();
|
||||
return { status: 200, body: { agents } };
|
||||
}
|
||||
if (req.method === 'POST' && req.path === '/bridge/v1/messages') {
|
||||
validateBridgeMessage(req.body);
|
||||
if (
|
||||
principal.kind === 'agent' &&
|
||||
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
|
||||
) {
|
||||
return {
|
||||
status: 403,
|
||||
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
|
||||
};
|
||||
}
|
||||
const eventId = await this.intent.sendAsAgent({
|
||||
roomId: req.body.room_id,
|
||||
agent: req.body.agent,
|
||||
@@ -177,15 +107,6 @@ export class AppserviceDaemon {
|
||||
}
|
||||
if (req.method === 'POST' && req.path === '/bridge/v1/typing') {
|
||||
validateBridgeTyping(req.body);
|
||||
if (
|
||||
principal.kind === 'agent' &&
|
||||
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
|
||||
) {
|
||||
return {
|
||||
status: 403,
|
||||
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
|
||||
};
|
||||
}
|
||||
await this.intent.setTyping(req.body.room_id, req.body.agent, req.body.typing);
|
||||
return { status: 200, body: {} };
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export class AdminHealthController {
|
||||
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';
|
||||
|
||||
@@ -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()
|
||||
@@ -34,17 +32,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 +47,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 +94,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 +143,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 +171,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 +181,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 +224,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 +236,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 +248,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 +338,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)`,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
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';
|
||||
@@ -25,7 +24,6 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
inject: [COMMANDS_QUEUE_HANDLE],
|
||||
},
|
||||
CommandRegistryService,
|
||||
CommandAuthorizationService,
|
||||
CommandExecutorService,
|
||||
],
|
||||
exports: [CommandRegistryService, CommandExecutorService],
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Db } from '@mosaicstack/db';
|
||||
import type { FederationListResponse } from '@mosaicstack/types';
|
||||
import {
|
||||
FederationClientError,
|
||||
type FederationClientService,
|
||||
} from '../federation-client.service.js';
|
||||
import { type QuerySourceError, QuerySourceService } from '../query-source.service.js';
|
||||
|
||||
interface TestRow {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface PeerRow {
|
||||
id: string;
|
||||
commonName: string;
|
||||
endpointUrl: string | null;
|
||||
clientKeyPem: string | null;
|
||||
state: 'active' | 'pending' | 'suspended' | 'revoked';
|
||||
}
|
||||
|
||||
const LOCAL_ROWS: TestRow[] = [
|
||||
{ id: 'local-1', title: 'Local One' },
|
||||
{ id: 'local-2', title: 'Local Two' },
|
||||
];
|
||||
|
||||
const PEER_A: PeerRow = {
|
||||
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
commonName: 'peer-a',
|
||||
endpointUrl: 'https://peer-a.example.com',
|
||||
clientKeyPem: 'sealed-key-a',
|
||||
state: 'active',
|
||||
};
|
||||
|
||||
const PEER_B: PeerRow = {
|
||||
id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
commonName: 'peer-b',
|
||||
endpointUrl: 'https://peer-b.example.com',
|
||||
clientKeyPem: 'sealed-key-b',
|
||||
state: 'active',
|
||||
};
|
||||
|
||||
const PEER_LOCALHOST: PeerRow = {
|
||||
id: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
|
||||
commonName: 'peer-localhost',
|
||||
endpointUrl: 'https://localhost:3001',
|
||||
clientKeyPem: 'sealed-key-c',
|
||||
state: 'active',
|
||||
};
|
||||
|
||||
function makeDb(activePeers: PeerRow[]): Db {
|
||||
const orderBy = vi.fn().mockResolvedValue(activePeers);
|
||||
const where = vi.fn().mockReturnValue({ orderBy });
|
||||
const from = vi.fn().mockReturnValue({ where });
|
||||
const select = vi.fn().mockReturnValue({ from });
|
||||
|
||||
return {
|
||||
select,
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
} as unknown as Db;
|
||||
}
|
||||
|
||||
function makeFederationClient(
|
||||
list: (
|
||||
peerId: string,
|
||||
resource: string,
|
||||
request: Record<string, unknown>,
|
||||
) => Promise<FederationListResponse<TestRow>>,
|
||||
): FederationClientService {
|
||||
return {
|
||||
list: list as unknown as FederationClientService['list'],
|
||||
} as FederationClientService;
|
||||
}
|
||||
|
||||
function makeLocalResponse(rows: TestRow[] = LOCAL_ROWS): Promise<FederationListResponse<TestRow>> {
|
||||
return Promise.resolve({ items: rows });
|
||||
}
|
||||
|
||||
describe('QuerySourceService', () => {
|
||||
it('routes source="local" to the local executor and tags rows as local', async () => {
|
||||
const list = vi.fn(async (): Promise<FederationListResponse<TestRow>> => ({ items: [] }));
|
||||
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
|
||||
|
||||
const result = await service.list<TestRow>({
|
||||
source: 'local',
|
||||
resource: 'tasks',
|
||||
request: { cursor: 'ignored-for-local-test' },
|
||||
local: () => makeLocalResponse(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{ id: 'local-1', title: 'Local One', _source: 'local' },
|
||||
{ id: 'local-2', title: 'Local Two', _source: 'local' },
|
||||
],
|
||||
});
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes source="federated:<host>" to the matching active peer and tags rows with peer commonName', async () => {
|
||||
const list = vi.fn(
|
||||
async (): Promise<FederationListResponse<TestRow>> => ({
|
||||
items: [{ id: 'remote-1', title: 'Remote One' }],
|
||||
}),
|
||||
);
|
||||
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
|
||||
|
||||
const result = await service.list<TestRow>({
|
||||
source: 'federated:peer-b.example.com',
|
||||
resource: 'tasks',
|
||||
request: { status: 'open' },
|
||||
local: () => makeLocalResponse(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [{ id: 'remote-1', title: 'Remote One', _source: 'peer-b' }],
|
||||
});
|
||||
expect(list).toHaveBeenCalledWith(PEER_B.id, 'tasks', { status: 'open' });
|
||||
});
|
||||
|
||||
it('matches federated hosts by endpoint host including non-default port', async () => {
|
||||
const list = vi.fn(
|
||||
async (): Promise<FederationListResponse<TestRow>> => ({
|
||||
items: [{ id: 'remote-port', title: 'Remote Port' }],
|
||||
}),
|
||||
);
|
||||
const service = new QuerySourceService(makeDb([PEER_LOCALHOST]), makeFederationClient(list));
|
||||
|
||||
const result = await service.list<TestRow>({
|
||||
source: 'federated:localhost:3001',
|
||||
resource: 'tasks',
|
||||
request: {},
|
||||
local: () => makeLocalResponse(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [{ id: 'remote-port', title: 'Remote Port', _source: 'peer-localhost' }],
|
||||
});
|
||||
expect(list).toHaveBeenCalledWith(PEER_LOCALHOST.id, 'tasks', {});
|
||||
});
|
||||
|
||||
it('fans out source="all" to local plus every active outbound peer in parallel and merges tagged rows', async () => {
|
||||
const callOrder: string[] = [];
|
||||
const list = vi.fn(async (peerId: string): Promise<FederationListResponse<TestRow>> => {
|
||||
callOrder.push(`remote-start:${peerId}`);
|
||||
await Promise.resolve();
|
||||
return {
|
||||
items: [{ id: `remote-${peerId.slice(0, 1)}`, title: `Remote ${peerId.slice(0, 1)}` }],
|
||||
};
|
||||
});
|
||||
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
|
||||
|
||||
const result = await service.list<TestRow>({
|
||||
source: 'all',
|
||||
resource: 'tasks',
|
||||
request: { limit: 25 },
|
||||
local: async () => {
|
||||
callOrder.push('local-start');
|
||||
await Promise.resolve();
|
||||
return { items: [{ id: 'local-1', title: 'Local One' }] };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{ id: 'local-1', title: 'Local One', _source: 'local' },
|
||||
{ id: 'remote-a', title: 'Remote a', _source: 'peer-a' },
|
||||
{ id: 'remote-b', title: 'Remote b', _source: 'peer-b' },
|
||||
],
|
||||
});
|
||||
expect(list).toHaveBeenCalledTimes(2);
|
||||
expect(callOrder).toEqual([
|
||||
'local-start',
|
||||
`remote-start:${PEER_A.id}`,
|
||||
`remote-start:${PEER_B.id}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks source="all" as partial and truncated when any subquery returns a cursor', async () => {
|
||||
const list = vi.fn(
|
||||
async (): Promise<FederationListResponse<TestRow>> => ({
|
||||
items: [{ id: 'remote-a', title: 'Remote A' }],
|
||||
nextCursor: 'remote-next',
|
||||
}),
|
||||
);
|
||||
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
|
||||
|
||||
const result = await service.list<TestRow>({
|
||||
source: 'all',
|
||||
resource: 'tasks',
|
||||
request: {},
|
||||
local: () => makeLocalResponse([{ id: 'local-1', title: 'Local One' }]),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{ id: 'local-1', title: 'Local One', _source: 'local' },
|
||||
{ id: 'remote-a', title: 'Remote A', _source: 'peer-a' },
|
||||
],
|
||||
_partial: true,
|
||||
_truncated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns _partial=true for source="all" when one peer fails without dropping successful sources', async () => {
|
||||
const list = vi.fn(async (peerId: string): Promise<FederationListResponse<TestRow>> => {
|
||||
if (peerId === PEER_B.id) {
|
||||
throw new FederationClientError({
|
||||
code: 'NETWORK',
|
||||
message: 'peer unavailable',
|
||||
peerId,
|
||||
});
|
||||
}
|
||||
return { items: [{ id: 'remote-a', title: 'Remote A' }] };
|
||||
});
|
||||
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
|
||||
|
||||
const result = await service.list<TestRow>({
|
||||
source: 'all',
|
||||
resource: 'tasks',
|
||||
request: {},
|
||||
local: () => makeLocalResponse([{ id: 'local-1', title: 'Local One' }]),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{ id: 'local-1', title: 'Local One', _source: 'local' },
|
||||
{ id: 'remote-a', title: 'Remote A', _source: 'peer-a' },
|
||||
],
|
||||
_partial: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws QuerySourceError when a federated host does not match an active outbound peer', async () => {
|
||||
const list = vi.fn(async (): Promise<FederationListResponse<TestRow>> => ({ items: [] }));
|
||||
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
|
||||
|
||||
await expect(
|
||||
service.list<TestRow>({
|
||||
source: 'federated:missing.example.com',
|
||||
resource: 'tasks',
|
||||
request: {},
|
||||
local: () => makeLocalResponse(),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: 'QuerySourceError',
|
||||
code: 'PEER_NOT_FOUND',
|
||||
} satisfies Partial<QuerySourceError>);
|
||||
});
|
||||
});
|
||||
@@ -11,13 +11,3 @@ export {
|
||||
type FederationClientErrorCode,
|
||||
type FederationClientErrorOptions,
|
||||
} from './federation-client.service.js';
|
||||
export {
|
||||
QuerySourceService,
|
||||
QuerySourceError,
|
||||
type QuerySource,
|
||||
type QuerySourceErrorCode,
|
||||
type QuerySourceErrorOptions,
|
||||
type QuerySourceListOptions,
|
||||
type QuerySourceListResponse,
|
||||
type LocalListExecutor,
|
||||
} from './query-source.service.js';
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* QuerySourceService — gateway query source router (FED-M3-09).
|
||||
*
|
||||
* Accepts the federation query-layer `source` selector and routes list-style
|
||||
* reads to local storage, one federated peer, or all active outbound peers.
|
||||
*
|
||||
* `source: "all"` is intentionally tolerant of per-peer failures: local data
|
||||
* and successful peer responses are returned, and the envelope is marked
|
||||
* `_partial: true`. Local failures still reject because there is no safe local
|
||||
* fallback and the gateway's own storage is expected to be authoritative.
|
||||
*/
|
||||
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { and, eq, federationPeers, isNotNull, type Db } from '@mosaicstack/db';
|
||||
import {
|
||||
SOURCE_LOCAL,
|
||||
tagWithSource,
|
||||
type FederationListResponse,
|
||||
type SourceTag,
|
||||
} from '@mosaicstack/types';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { FederationClientService } from './federation-client.service.js';
|
||||
|
||||
export type QuerySource = 'local' | 'all' | `federated:${string}`;
|
||||
|
||||
export type QuerySourceErrorCode = 'INVALID_SOURCE' | 'PEER_NOT_FOUND';
|
||||
|
||||
export interface QuerySourceErrorOptions {
|
||||
code: QuerySourceErrorCode;
|
||||
message: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export class QuerySourceError extends Error {
|
||||
readonly code: QuerySourceErrorCode;
|
||||
readonly source: string;
|
||||
|
||||
constructor(opts: QuerySourceErrorOptions) {
|
||||
super(opts.message);
|
||||
this.name = 'QuerySourceError';
|
||||
this.code = opts.code;
|
||||
this.source = opts.source;
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalListExecutor<T extends object> = () => Promise<FederationListResponse<T> | T[]>;
|
||||
|
||||
export interface QuerySourceListOptions<T extends object> {
|
||||
source: QuerySource;
|
||||
resource: string;
|
||||
request?: Record<string, unknown>;
|
||||
local: LocalListExecutor<T>;
|
||||
}
|
||||
|
||||
export type QuerySourceListResponse<T extends object> = FederationListResponse<T & SourceTag>;
|
||||
|
||||
interface OutboundPeer {
|
||||
id: string;
|
||||
commonName: string;
|
||||
endpointUrl: string;
|
||||
}
|
||||
|
||||
interface TaggedList<T extends object> {
|
||||
items: Array<T & SourceTag>;
|
||||
partial: boolean;
|
||||
truncated: boolean;
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class QuerySourceService {
|
||||
private readonly logger = new Logger(QuerySourceService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(FederationClientService) private readonly federationClient: FederationClientService,
|
||||
) {}
|
||||
|
||||
async list<T extends object>(
|
||||
options: QuerySourceListOptions<T>,
|
||||
): Promise<QuerySourceListResponse<T>> {
|
||||
const request = options.request ?? {};
|
||||
|
||||
if (options.source === 'local') {
|
||||
const local = await this.runLocal(options.local);
|
||||
return this.toResponse(this.tagList(local, SOURCE_LOCAL));
|
||||
}
|
||||
|
||||
if (options.source === 'all') {
|
||||
return this.listAll(options.resource, request, options.local);
|
||||
}
|
||||
|
||||
if (options.source.startsWith('federated:')) {
|
||||
const host = options.source.slice('federated:'.length).trim();
|
||||
if (!host) {
|
||||
throw new QuerySourceError({
|
||||
code: 'INVALID_SOURCE',
|
||||
message: 'Federated source must include a host after federated:',
|
||||
source: options.source,
|
||||
});
|
||||
}
|
||||
|
||||
const peer = await this.findPeerByHost(host, options.source);
|
||||
const remote = await this.federationClient.list<T>(peer.id, options.resource, request);
|
||||
return this.toResponse(this.tagList(remote, peer.commonName));
|
||||
}
|
||||
|
||||
throw new QuerySourceError({
|
||||
code: 'INVALID_SOURCE',
|
||||
message: `Unsupported query source: ${options.source}`,
|
||||
source: options.source,
|
||||
});
|
||||
}
|
||||
|
||||
private async listAll<T extends object>(
|
||||
resource: string,
|
||||
request: Record<string, unknown>,
|
||||
local: LocalListExecutor<T>,
|
||||
): Promise<QuerySourceListResponse<T>> {
|
||||
const peers = await this.listActiveOutboundPeers();
|
||||
|
||||
const localPromise = this.runLocal(local).then((response) =>
|
||||
this.tagList(response, SOURCE_LOCAL),
|
||||
);
|
||||
const remotePromises = peers.map(async (peer: OutboundPeer): Promise<TaggedList<T> | null> => {
|
||||
try {
|
||||
const response = await this.federationClient.list<T>(peer.id, resource, request);
|
||||
return this.tagList(response, peer.commonName);
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(
|
||||
`Federated query to peer ${peer.commonName} (${peer.id}) failed; returning partial all-source response: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const [localResult, ...remoteResults] = await Promise.all([localPromise, ...remotePromises]);
|
||||
const successfulRemoteResults = remoteResults.filter(
|
||||
(result: TaggedList<T> | null): result is TaggedList<T> => result !== null,
|
||||
);
|
||||
const allResults = [localResult, ...successfulRemoteResults];
|
||||
const peerFailure = successfulRemoteResults.length !== peers.length;
|
||||
|
||||
return this.mergeTaggedLists(allResults, peerFailure);
|
||||
}
|
||||
|
||||
private async runLocal<T extends object>(
|
||||
local: LocalListExecutor<T>,
|
||||
): Promise<FederationListResponse<T>> {
|
||||
const response = await local();
|
||||
if (Array.isArray(response)) {
|
||||
return { items: response };
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private tagList<T extends object>(
|
||||
response: FederationListResponse<T>,
|
||||
source: string,
|
||||
): TaggedList<T> {
|
||||
return {
|
||||
items: tagWithSource(response.items, source),
|
||||
partial: response._partial === true,
|
||||
truncated: response._truncated === true || response.nextCursor !== undefined,
|
||||
nextCursor: response.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
private mergeTaggedLists<T extends object>(
|
||||
lists: Array<TaggedList<T>>,
|
||||
peerFailure: boolean,
|
||||
): QuerySourceListResponse<T> {
|
||||
const items = lists.flatMap((list: TaggedList<T>) => list.items);
|
||||
const partial =
|
||||
peerFailure ||
|
||||
lists.some((list: TaggedList<T>) => list.partial || list.nextCursor !== undefined);
|
||||
const truncated = lists.some((list: TaggedList<T>) => list.truncated);
|
||||
|
||||
const response: QuerySourceListResponse<T> = { items };
|
||||
if (partial) {
|
||||
response._partial = true;
|
||||
}
|
||||
if (truncated) {
|
||||
response._truncated = true;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private toResponse<T extends object>(tagged: TaggedList<T>): QuerySourceListResponse<T> {
|
||||
const response: QuerySourceListResponse<T> = {
|
||||
items: tagged.items,
|
||||
};
|
||||
if (tagged.nextCursor !== undefined) {
|
||||
response.nextCursor = tagged.nextCursor;
|
||||
}
|
||||
if (tagged.partial) {
|
||||
response._partial = true;
|
||||
}
|
||||
if (tagged.truncated) {
|
||||
response._truncated = true;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private async findPeerByHost(sourceHost: string, source: string): Promise<OutboundPeer> {
|
||||
const host = normalizeHost(sourceHost);
|
||||
const peers = await this.listActiveOutboundPeers();
|
||||
const peer = peers.find((candidate: OutboundPeer) => {
|
||||
const commonName = normalizeHost(candidate.commonName);
|
||||
const endpointHosts = endpointHostKeys(candidate.endpointUrl).map((endpointHost: string) =>
|
||||
normalizeHost(endpointHost),
|
||||
);
|
||||
return commonName === host || endpointHosts.includes(host);
|
||||
});
|
||||
|
||||
if (!peer) {
|
||||
throw new QuerySourceError({
|
||||
code: 'PEER_NOT_FOUND',
|
||||
message: `No active outbound federation peer matches source ${source}`,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
return peer;
|
||||
}
|
||||
|
||||
private async listActiveOutboundPeers(): Promise<OutboundPeer[]> {
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: federationPeers.id,
|
||||
commonName: federationPeers.commonName,
|
||||
endpointUrl: federationPeers.endpointUrl,
|
||||
})
|
||||
.from(federationPeers)
|
||||
.where(
|
||||
and(
|
||||
eq(federationPeers.state, 'active'),
|
||||
isNotNull(federationPeers.endpointUrl),
|
||||
isNotNull(federationPeers.clientKeyPem),
|
||||
),
|
||||
)
|
||||
.orderBy(federationPeers.commonName);
|
||||
|
||||
return rows.filter((row): row is OutboundPeer => typeof row.endpointUrl === 'string');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHost(host: string): string {
|
||||
return host.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function endpointHostKeys(endpointUrl: string): string[] {
|
||||
try {
|
||||
const url = new URL(endpointUrl);
|
||||
return Array.from(new Set([url.host, url.hostname].filter((host: string) => host.length > 0)));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -4,35 +4,26 @@ import { CaService } from './ca.service.js';
|
||||
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 { GrantsService } from './grants.service.js';
|
||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
||||
import { ListController } from './server/verbs/list.controller.js';
|
||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
||||
import { FederationClientService } from './client/index.js';
|
||||
import { FederationAuthGuard } from './server/index.js';
|
||||
|
||||
@Module({
|
||||
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
||||
controllers: [EnrollmentController, FederationController],
|
||||
providers: [
|
||||
AdminGuard,
|
||||
CaService,
|
||||
EnrollmentService,
|
||||
GrantsService,
|
||||
FederationClientService,
|
||||
QuerySourceService,
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
],
|
||||
exports: [
|
||||
CaService,
|
||||
EnrollmentService,
|
||||
GrantsService,
|
||||
FederationClientService,
|
||||
QuerySourceService,
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
],
|
||||
})
|
||||
export class FederationModule {}
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
/**
|
||||
* Unit tests for FederationScopeService (FED-M3-04).
|
||||
*
|
||||
* Coverage:
|
||||
* - resource allowlist deny
|
||||
* - excluded resource deny
|
||||
* - invalid scope deny
|
||||
* - invalid requested limit deny
|
||||
* - native RBAC deny as subjectUserId
|
||||
* - scope/native filter intersection for personal and team rows
|
||||
* - native RBAC personal deny wins over scope include_personal allow/default
|
||||
* - max_rows_per_query cap
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FederationScopeService, type FederationNativeRbacEvaluator } from '../scope.service.js';
|
||||
import type { FederationContext } from '../federation-context.js';
|
||||
|
||||
const GRANT_ID = 'grant-1';
|
||||
const PEER_ID = 'peer-1';
|
||||
const SUBJECT_USER_ID = 'user-1';
|
||||
|
||||
function makeContext(scope: Record<string, unknown>): FederationContext {
|
||||
return {
|
||||
grantId: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
scope,
|
||||
};
|
||||
}
|
||||
|
||||
function makeNativeRbac(
|
||||
result: Awaited<ReturnType<FederationNativeRbacEvaluator['evaluateReadAccess']>>,
|
||||
): FederationNativeRbacEvaluator {
|
||||
return {
|
||||
evaluateReadAccess: vi.fn().mockResolvedValue(result),
|
||||
};
|
||||
}
|
||||
|
||||
describe('FederationScopeService', () => {
|
||||
let service: FederationScopeService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new FederationScopeService();
|
||||
});
|
||||
|
||||
it('allows a granted resource and returns a capped query filter', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({
|
||||
resources: ['tasks'],
|
||||
filters: { tasks: { include_teams: ['team-1', 'team-3'], include_personal: true } },
|
||||
max_rows_per_query: 50,
|
||||
}),
|
||||
resource: 'tasks',
|
||||
requestedLimit: 500,
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
filter: {
|
||||
resource: 'tasks',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: true,
|
||||
teamIds: ['team-1'],
|
||||
limit: 50,
|
||||
maxRowsPerQuery: 50,
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).toHaveBeenCalledWith({
|
||||
grantId: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
resource: 'tasks',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults absent resource filters to native RBAC personal and team visibility', async () => {
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: ['notes'], max_rows_per_query: 100 }),
|
||||
resource: 'notes',
|
||||
nativeRbac: makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: true,
|
||||
filter: {
|
||||
includePersonal: true,
|
||||
teamIds: ['team-1', 'team-2'],
|
||||
limit: 100,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('honors include_personal false even when native RBAC allows personal rows', async () => {
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({
|
||||
resources: ['memory'],
|
||||
filters: { memory: { include_personal: false } },
|
||||
max_rows_per_query: 25,
|
||||
}),
|
||||
resource: 'memory',
|
||||
nativeRbac: makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: true,
|
||||
filter: {
|
||||
includePersonal: false,
|
||||
teamIds: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not leak personal rows when scope allows personal but native RBAC denies personal', async () => {
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({
|
||||
resources: ['tasks'],
|
||||
filters: { tasks: { include_personal: true } },
|
||||
max_rows_per_query: 25,
|
||||
}),
|
||||
resource: 'tasks',
|
||||
nativeRbac: makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: false, teamIds: ['team-1'] },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: true,
|
||||
filter: {
|
||||
includePersonal: false,
|
||||
teamIds: ['team-1'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not widen native RBAC when scope includes teams the user cannot access', async () => {
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({
|
||||
resources: ['tasks'],
|
||||
filters: { tasks: { include_teams: ['team-2'], include_personal: false } },
|
||||
max_rows_per_query: 25,
|
||||
}),
|
||||
resource: 'tasks',
|
||||
nativeRbac: makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: ['team-1'] },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: true,
|
||||
filter: {
|
||||
includePersonal: false,
|
||||
teamIds: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('denies invalid grant scope before RBAC evaluation', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: [], max_rows_per_query: 100 }),
|
||||
resource: 'tasks',
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'invalid_scope',
|
||||
stage: 'scope_parse',
|
||||
statusCode: 400,
|
||||
grantId: GRANT_ID,
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
resource: 'tasks',
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies unsupported resource names before RBAC evaluation', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
||||
resource: 'unknown_resource',
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'invalid_resource',
|
||||
stage: 'resource_allowlist',
|
||||
statusCode: 403,
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies resources explicitly present in excluded_resources before allowlist miss', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({
|
||||
resources: ['tasks'],
|
||||
excluded_resources: ['credentials'],
|
||||
max_rows_per_query: 100,
|
||||
}),
|
||||
resource: 'credentials',
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'resource_excluded',
|
||||
stage: 'resource_exclusion',
|
||||
statusCode: 403,
|
||||
resource: 'credentials',
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies supported resources that are not granted by scope', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
||||
resource: 'notes',
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'resource_not_granted',
|
||||
stage: 'resource_allowlist',
|
||||
statusCode: 403,
|
||||
resource: 'notes',
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies invalid requested row limits before RBAC evaluation', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
||||
resource: 'tasks',
|
||||
requestedLimit: 0,
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'invalid_limit',
|
||||
stage: 'row_cap',
|
||||
statusCode: 400,
|
||||
details: { requestedLimit: 0 },
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies when native RBAC rejects subjectUserId access to the resource', async () => {
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
||||
resource: 'tasks',
|
||||
nativeRbac: makeNativeRbac({
|
||||
allowed: false,
|
||||
reason: 'read:tasks denied',
|
||||
details: { permission: 'tasks:read' },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'native_rbac_denied',
|
||||
stage: 'native_rbac',
|
||||
statusCode: 403,
|
||||
message: 'read:tasks denied',
|
||||
grantId: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
resource: 'tasks',
|
||||
details: { permission: 'tasks:read' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,22 +10,4 @@
|
||||
*/
|
||||
|
||||
export { FederationAuthGuard } from './federation-auth.guard.js';
|
||||
export { FederationScopeService } from './scope.service.js';
|
||||
export type { FederationContext } from './federation-context.js';
|
||||
export type {
|
||||
FederationNativeRbacAccess,
|
||||
FederationNativeRbacAllowedResult,
|
||||
FederationNativeRbacDeniedResult,
|
||||
FederationNativeRbacEvaluator,
|
||||
FederationNativeRbacRequest,
|
||||
FederationNativeRbacResult,
|
||||
FederationScopeAllowedResult,
|
||||
FederationScopeDeniedResult,
|
||||
FederationScopeDenyCode,
|
||||
FederationScopeDenyDetails,
|
||||
FederationScopeDenyReason,
|
||||
FederationScopeDenyStage,
|
||||
FederationScopeEvaluationInput,
|
||||
FederationScopeEvaluationResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from './scope.service.js';
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
/**
|
||||
* FederationScopeService — M3 server-side scope enforcement pipeline.
|
||||
*
|
||||
* Pure trust-boundary service: it validates the grant scope, asks an injected
|
||||
* native RBAC evaluator what the subject user can read locally, intersects that
|
||||
* answer with the federation scope filters, and returns a query filter for the
|
||||
* verb controllers. The service performs no DB calls directly.
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
FEDERATION_RESOURCE_VALUES,
|
||||
type FederationResource,
|
||||
FederationScopeError,
|
||||
parseFederationScope,
|
||||
} from '../scope-schema.js';
|
||||
import type { FederationContext } from './federation-context.js';
|
||||
|
||||
const federationResourceSet: ReadonlySet<string> = new Set<string>(FEDERATION_RESOURCE_VALUES);
|
||||
|
||||
export type FederationScopeDenyStage =
|
||||
| 'scope_parse'
|
||||
| 'resource_allowlist'
|
||||
| 'resource_exclusion'
|
||||
| 'native_rbac'
|
||||
| 'row_cap';
|
||||
|
||||
export type FederationScopeDenyCode =
|
||||
| 'invalid_scope'
|
||||
| 'invalid_resource'
|
||||
| 'resource_not_granted'
|
||||
| 'resource_excluded'
|
||||
| 'native_rbac_denied'
|
||||
| 'invalid_limit';
|
||||
|
||||
export type FederationScopeDenyStatus = 400 | 403;
|
||||
|
||||
export interface FederationScopeDenyDetails {
|
||||
readonly [key: string]: string | number | boolean | readonly string[];
|
||||
}
|
||||
|
||||
export interface FederationScopeDenyReason {
|
||||
readonly code: FederationScopeDenyCode;
|
||||
readonly stage: FederationScopeDenyStage;
|
||||
readonly statusCode: FederationScopeDenyStatus;
|
||||
readonly message: string;
|
||||
readonly grantId: string;
|
||||
readonly peerId: string;
|
||||
readonly subjectUserId: string;
|
||||
readonly resource: string;
|
||||
readonly details?: FederationScopeDenyDetails;
|
||||
}
|
||||
|
||||
export interface FederationNativeRbacRequest {
|
||||
readonly grantId: string;
|
||||
readonly peerId: string;
|
||||
readonly subjectUserId: string;
|
||||
readonly resource: FederationResource;
|
||||
}
|
||||
|
||||
export interface FederationNativeRbacAccess {
|
||||
/** Whether this user may read personal rows for this resource. */
|
||||
readonly includePersonal: boolean;
|
||||
|
||||
/** Team IDs this user may read for this resource under native RBAC. */
|
||||
readonly teamIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface FederationNativeRbacAllowedResult {
|
||||
readonly allowed: true;
|
||||
readonly access: FederationNativeRbacAccess;
|
||||
}
|
||||
|
||||
export interface FederationNativeRbacDeniedResult {
|
||||
readonly allowed: false;
|
||||
readonly reason?: string;
|
||||
readonly details?: FederationScopeDenyDetails;
|
||||
}
|
||||
|
||||
export type FederationNativeRbacResult =
|
||||
| FederationNativeRbacAllowedResult
|
||||
| FederationNativeRbacDeniedResult;
|
||||
|
||||
export interface FederationNativeRbacEvaluator {
|
||||
evaluateReadAccess(request: FederationNativeRbacRequest): Promise<FederationNativeRbacResult>;
|
||||
}
|
||||
|
||||
export interface FederationScopeEvaluationInput {
|
||||
readonly context: FederationContext;
|
||||
readonly resource: string;
|
||||
readonly requestedLimit?: number;
|
||||
readonly nativeRbac: FederationNativeRbacEvaluator;
|
||||
}
|
||||
|
||||
export interface FederationScopeQueryFilter {
|
||||
readonly resource: FederationResource;
|
||||
readonly subjectUserId: string;
|
||||
readonly includePersonal: boolean;
|
||||
readonly teamIds: readonly string[];
|
||||
readonly limit: number;
|
||||
readonly maxRowsPerQuery: number;
|
||||
}
|
||||
|
||||
export interface FederationScopeAllowedResult {
|
||||
readonly allowed: true;
|
||||
readonly filter: FederationScopeQueryFilter;
|
||||
}
|
||||
|
||||
export interface FederationScopeDeniedResult {
|
||||
readonly allowed: false;
|
||||
readonly deny: FederationScopeDenyReason;
|
||||
}
|
||||
|
||||
export type FederationScopeEvaluationResult =
|
||||
| FederationScopeAllowedResult
|
||||
| FederationScopeDeniedResult;
|
||||
|
||||
function isFederationResource(resource: string): resource is FederationResource {
|
||||
return federationResourceSet.has(resource);
|
||||
}
|
||||
|
||||
function uniqueStrings(values: readonly string[]): readonly string[] {
|
||||
return Array.from(new Set<string>(values));
|
||||
}
|
||||
|
||||
function intersectTeamIds(
|
||||
nativeTeamIds: readonly string[],
|
||||
scopedTeamIds: readonly string[] | undefined,
|
||||
): readonly string[] {
|
||||
const uniqueNativeTeamIds = uniqueStrings(nativeTeamIds);
|
||||
|
||||
if (scopedTeamIds === undefined) {
|
||||
return uniqueNativeTeamIds;
|
||||
}
|
||||
|
||||
const nativeSet = new Set<string>(uniqueNativeTeamIds);
|
||||
return uniqueStrings(scopedTeamIds).filter((teamId: string): boolean => nativeSet.has(teamId));
|
||||
}
|
||||
|
||||
function makeDenyReason(params: {
|
||||
readonly code: FederationScopeDenyCode;
|
||||
readonly stage: FederationScopeDenyStage;
|
||||
readonly statusCode?: FederationScopeDenyStatus;
|
||||
readonly message: string;
|
||||
readonly context: FederationContext;
|
||||
readonly resource: string;
|
||||
readonly details?: FederationScopeDenyDetails;
|
||||
}): FederationScopeDeniedResult {
|
||||
return {
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: params.code,
|
||||
stage: params.stage,
|
||||
statusCode: params.statusCode ?? 403,
|
||||
message: params.message,
|
||||
grantId: params.context.grantId,
|
||||
peerId: params.context.peerId,
|
||||
subjectUserId: params.context.subjectUserId,
|
||||
resource: params.resource,
|
||||
...(params.details !== undefined ? { details: params.details } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FederationScopeService {
|
||||
async evaluateAccess(
|
||||
input: FederationScopeEvaluationInput,
|
||||
): Promise<FederationScopeEvaluationResult> {
|
||||
const { context, resource, requestedLimit, nativeRbac } = input;
|
||||
|
||||
let scope: ReturnType<typeof parseFederationScope>;
|
||||
try {
|
||||
scope = parseFederationScope(context.scope);
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof FederationScopeError
|
||||
? 'Federation grant scope is invalid'
|
||||
: 'Federation grant scope could not be parsed';
|
||||
const details = error instanceof Error ? { reason: error.message } : undefined;
|
||||
return makeDenyReason({
|
||||
code: 'invalid_scope',
|
||||
stage: 'scope_parse',
|
||||
statusCode: 400,
|
||||
message,
|
||||
context,
|
||||
resource,
|
||||
...(details !== undefined ? { details } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (!isFederationResource(resource)) {
|
||||
return makeDenyReason({
|
||||
code: 'invalid_resource',
|
||||
stage: 'resource_allowlist',
|
||||
message: 'Requested federation resource is not supported',
|
||||
context,
|
||||
resource,
|
||||
details: { supportedResources: FEDERATION_RESOURCE_VALUES },
|
||||
});
|
||||
}
|
||||
|
||||
if (scope.excluded_resources.includes(resource)) {
|
||||
return makeDenyReason({
|
||||
code: 'resource_excluded',
|
||||
stage: 'resource_exclusion',
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
context,
|
||||
resource,
|
||||
});
|
||||
}
|
||||
|
||||
if (!scope.resources.includes(resource)) {
|
||||
return makeDenyReason({
|
||||
code: 'resource_not_granted',
|
||||
stage: 'resource_allowlist',
|
||||
message: 'Requested federation resource is not granted by scope',
|
||||
context,
|
||||
resource,
|
||||
details: { grantedResources: scope.resources },
|
||||
});
|
||||
}
|
||||
|
||||
if (requestedLimit !== undefined && (!Number.isInteger(requestedLimit) || requestedLimit < 1)) {
|
||||
return makeDenyReason({
|
||||
code: 'invalid_limit',
|
||||
stage: 'row_cap',
|
||||
statusCode: 400,
|
||||
message: 'Requested row limit must be a positive integer',
|
||||
context,
|
||||
resource,
|
||||
details: { requestedLimit },
|
||||
});
|
||||
}
|
||||
|
||||
const nativeResult = await nativeRbac.evaluateReadAccess({
|
||||
grantId: context.grantId,
|
||||
peerId: context.peerId,
|
||||
subjectUserId: context.subjectUserId,
|
||||
resource,
|
||||
});
|
||||
|
||||
if (!nativeResult.allowed) {
|
||||
return makeDenyReason({
|
||||
code: 'native_rbac_denied',
|
||||
stage: 'native_rbac',
|
||||
message: nativeResult.reason ?? 'Subject user is not allowed to read this resource',
|
||||
context,
|
||||
resource,
|
||||
...(nativeResult.details !== undefined ? { details: nativeResult.details } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const scopeFilter = scope.filters?.[resource];
|
||||
const includePersonal =
|
||||
Boolean(scopeFilter?.include_personal ?? true) && nativeResult.access.includePersonal;
|
||||
const teamIds = intersectTeamIds(nativeResult.access.teamIds, scopeFilter?.include_teams);
|
||||
const limit = Math.min(requestedLimit ?? scope.max_rows_per_query, scope.max_rows_per_query);
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
filter: {
|
||||
resource,
|
||||
subjectUserId: context.subjectUserId,
|
||||
includePersonal,
|
||||
teamIds,
|
||||
limit,
|
||||
maxRowsPerQuery: scope.max_rows_per_query,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { FederationCapabilitiesResponseSchema, FEDERATION_VERBS } from '@mosaicstack/types';
|
||||
import { FederationScopeError } from '../../../scope-schema.js';
|
||||
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
||||
import { CapabilitiesController } from '../capabilities.controller.js';
|
||||
|
||||
const VALID_SCOPE = {
|
||||
resources: ['tasks', 'notes'],
|
||||
excluded_resources: ['credentials'],
|
||||
max_rows_per_query: 250,
|
||||
} as const;
|
||||
|
||||
const DEFAULTED_SCOPE = {
|
||||
resources: ['memory'],
|
||||
max_rows_per_query: 10,
|
||||
} as const;
|
||||
|
||||
function makeRequest(scope: Record<string, unknown>): FastifyRequest {
|
||||
return {
|
||||
federationContext: {
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
scope,
|
||||
},
|
||||
} as FastifyRequest;
|
||||
}
|
||||
|
||||
describe('CapabilitiesController', () => {
|
||||
it('declares GET /api/federation/v1/capabilities', () => {
|
||||
expect(Reflect.getMetadata('path', CapabilitiesController)).toBe(
|
||||
'api/federation/v1/capabilities',
|
||||
);
|
||||
expect(Reflect.getMetadata('path', CapabilitiesController.prototype.getCapabilities)).toBe('/');
|
||||
expect(Reflect.getMetadata('method', CapabilitiesController.prototype.getCapabilities)).toBe(
|
||||
RequestMethod.GET,
|
||||
);
|
||||
});
|
||||
|
||||
it('is protected only by FederationAuthGuard', () => {
|
||||
const guards = Reflect.getMetadata('__guards__', CapabilitiesController) as unknown[];
|
||||
|
||||
expect(guards).toEqual([FederationAuthGuard]);
|
||||
});
|
||||
|
||||
it('returns resources, excluded resources, max rows, and M3 supported verbs from the active grant scope', () => {
|
||||
const controller = new CapabilitiesController();
|
||||
|
||||
const response = controller.getCapabilities(makeRequest(VALID_SCOPE));
|
||||
|
||||
expect(response).toEqual({
|
||||
resources: ['tasks', 'notes'],
|
||||
excluded_resources: ['credentials'],
|
||||
max_rows_per_query: 250,
|
||||
supported_verbs: [...FEDERATION_VERBS],
|
||||
});
|
||||
expect(FederationCapabilitiesResponseSchema.safeParse(response).success).toBe(true);
|
||||
});
|
||||
|
||||
it('applies scope defaults without RBAC or resource filtering', () => {
|
||||
const controller = new CapabilitiesController();
|
||||
|
||||
const response = controller.getCapabilities(makeRequest(DEFAULTED_SCOPE));
|
||||
|
||||
expect(response).toEqual({
|
||||
resources: ['memory'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 10,
|
||||
supported_verbs: ['list', 'get', 'capabilities'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid scope state instead of returning an invalid capabilities contract', () => {
|
||||
const controller = new CapabilitiesController();
|
||||
|
||||
expect(() =>
|
||||
controller.getCapabilities(
|
||||
makeRequest({
|
||||
resources: [],
|
||||
max_rows_per_query: 0,
|
||||
}),
|
||||
),
|
||||
).toThrow(FederationScopeError);
|
||||
});
|
||||
});
|
||||
@@ -1,428 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createPgliteDb,
|
||||
insights,
|
||||
missionTasks,
|
||||
missions,
|
||||
preferences,
|
||||
projects,
|
||||
runPgliteMigrations,
|
||||
teams,
|
||||
users,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import type { FederationScopeQueryFilter } from '../../scope.service.js';
|
||||
import { FederationListQueryService } from '../list-query.service.js';
|
||||
|
||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
||||
resource: 'tasks',
|
||||
subjectUserId: 'user-1',
|
||||
includePersonal: true,
|
||||
teamIds: [],
|
||||
limit: 2,
|
||||
maxRowsPerQuery: 2,
|
||||
};
|
||||
|
||||
const SUBJECT_USER_ID = 'fed-m3-05-subject';
|
||||
const OTHER_USER_ID = 'fed-m3-05-other';
|
||||
const TEAM_ID = '05000000-0000-4000-8000-000000000001';
|
||||
const UNAUTHORIZED_TEAM_ID = '05000000-0000-4000-8000-000000000002';
|
||||
const PERSONAL_PROJECT_ID = '05000000-0000-4000-8000-000000000101';
|
||||
const TEAM_PROJECT_ID = '05000000-0000-4000-8000-000000000102';
|
||||
const UNAUTHORIZED_PROJECT_ID = '05000000-0000-4000-8000-000000000103';
|
||||
const PERSONAL_MISSION_ID = '05000000-0000-4000-8000-000000000201';
|
||||
const TEAM_MISSION_ID = '05000000-0000-4000-8000-000000000202';
|
||||
const UNAUTHORIZED_MISSION_ID = '05000000-0000-4000-8000-000000000203';
|
||||
const SUBJECT_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000301';
|
||||
const OTHER_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000302';
|
||||
const SUBJECT_PERSONAL_NOTE_ID = '05000000-0000-4000-8000-000000000303';
|
||||
const SUBJECT_UNAUTHORIZED_NOTE_ID = '05000000-0000-4000-8000-000000000304';
|
||||
const INSIGHT_ONE_ID = '05000000-0000-4000-8000-000000000401';
|
||||
const INSIGHT_TWO_ID = '05000000-0000-4000-8000-000000000402';
|
||||
const PREFERENCE_ONE_ID = '05000000-0000-4000-8000-000000000501';
|
||||
const PREFERENCE_TWO_ID = '05000000-0000-4000-8000-000000000502';
|
||||
|
||||
let dbHandle: DbHandle | undefined;
|
||||
|
||||
function makeService() {
|
||||
return new FederationListQueryService({} as Db);
|
||||
}
|
||||
|
||||
function makeDbService() {
|
||||
if (!dbHandle) {
|
||||
throw new Error('test DB not initialized');
|
||||
}
|
||||
return new FederationListQueryService(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-05 Team',
|
||||
slug: 'fed-m3-05-team',
|
||||
ownerId: SUBJECT_USER_ID,
|
||||
managerId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_TEAM_ID,
|
||||
name: 'FED-M3-05 Unauthorized Team',
|
||||
slug: 'fed-m3-05-unauthorized-team',
|
||||
ownerId: OTHER_USER_ID,
|
||||
managerId: OTHER_USER_ID,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(projects).values([
|
||||
{
|
||||
id: PERSONAL_PROJECT_ID,
|
||||
name: 'FED-M3-05 Personal Project',
|
||||
ownerId: SUBJECT_USER_ID,
|
||||
ownerType: 'user',
|
||||
},
|
||||
{
|
||||
id: TEAM_PROJECT_ID,
|
||||
name: 'FED-M3-05 Team Project',
|
||||
teamId: TEAM_ID,
|
||||
ownerType: 'team',
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_PROJECT_ID,
|
||||
name: 'FED-M3-05 Unauthorized Project',
|
||||
teamId: UNAUTHORIZED_TEAM_ID,
|
||||
ownerType: 'team',
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(missions).values([
|
||||
{
|
||||
id: PERSONAL_MISSION_ID,
|
||||
name: 'FED-M3-05 Personal Mission',
|
||||
projectId: PERSONAL_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: TEAM_MISSION_ID,
|
||||
name: 'FED-M3-05 Team Mission',
|
||||
projectId: TEAM_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_MISSION_ID,
|
||||
name: 'FED-M3-05 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'),
|
||||
},
|
||||
]);
|
||||
|
||||
const memoryCreatedAt = new Date('2026-06-24T05:00:00.000Z');
|
||||
await dbHandle.db.insert(insights).values([
|
||||
{
|
||||
id: INSIGHT_ONE_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
content: 'first insight',
|
||||
source: 'agent',
|
||||
createdAt: memoryCreatedAt,
|
||||
updatedAt: memoryCreatedAt,
|
||||
},
|
||||
{
|
||||
id: INSIGHT_TWO_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
content: 'second insight',
|
||||
source: 'agent',
|
||||
createdAt: memoryCreatedAt,
|
||||
updatedAt: memoryCreatedAt,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(preferences).values([
|
||||
{
|
||||
id: PREFERENCE_ONE_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
key: 'fed-m3-05-pref-1',
|
||||
value: { enabled: true },
|
||||
createdAt: memoryCreatedAt,
|
||||
updatedAt: memoryCreatedAt,
|
||||
},
|
||||
{
|
||||
id: PREFERENCE_TWO_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
key: 'fed-m3-05-pref-2',
|
||||
value: { enabled: false },
|
||||
createdAt: memoryCreatedAt,
|
||||
updatedAt: memoryCreatedAt,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function stubRows(
|
||||
service: FederationListQueryService,
|
||||
...pages: Array<Array<Record<string, unknown>>>
|
||||
) {
|
||||
const mock = vi.fn();
|
||||
for (const page of pages) {
|
||||
mock.mockResolvedValueOnce(page);
|
||||
}
|
||||
(
|
||||
service as unknown as {
|
||||
listAllRows: (
|
||||
_filter: FederationScopeQueryFilter,
|
||||
_rowLimit: number,
|
||||
_cursor: unknown,
|
||||
) => Promise<Array<Record<string, unknown>>>;
|
||||
}
|
||||
).listAllRows = mock;
|
||||
return mock;
|
||||
}
|
||||
|
||||
describe('FederationListQueryService', () => {
|
||||
beforeAll(async () => {
|
||||
dbHandle = createPgliteDb(`memory://fed-m3-05-list-${Date.now()}`);
|
||||
await runPgliteMigrations(dbHandle);
|
||||
await seedNotesFixture();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await dbHandle?.close();
|
||||
dbHandle = undefined;
|
||||
});
|
||||
|
||||
it('denies sensitive resources in native RBAC for M3 list 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 list 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('applies the scope row cap and returns an opaque next cursor when truncated', async () => {
|
||||
const service = makeService();
|
||||
const listAllRows = stubRows(
|
||||
service,
|
||||
[
|
||||
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
|
||||
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
|
||||
{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') },
|
||||
],
|
||||
[{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
|
||||
);
|
||||
|
||||
const firstPage = await service.list({ filter: TASK_FILTER });
|
||||
|
||||
expect(firstPage).toEqual({
|
||||
items: [
|
||||
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
|
||||
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
|
||||
],
|
||||
truncated: true,
|
||||
nextCursor: expect.any(String),
|
||||
});
|
||||
|
||||
expect(listAllRows).toHaveBeenNthCalledWith(1, TASK_FILTER, 3, undefined);
|
||||
|
||||
const secondPage = await service.list({ filter: TASK_FILTER, cursor: firstPage.nextCursor });
|
||||
expect(secondPage).toEqual({
|
||||
items: [{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
|
||||
truncated: false,
|
||||
});
|
||||
expect(listAllRows).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
TASK_FILTER,
|
||||
3,
|
||||
expect.objectContaining({ id: '2' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid cursors instead of falling back to the first page', async () => {
|
||||
const service = makeService();
|
||||
stubRows(service, [{ id: '1' }]);
|
||||
|
||||
await expect(service.list({ filter: TASK_FILTER, cursor: 'not-base64-json' })).rejects.toThrow(
|
||||
'Invalid federation list cursor',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when a truncated page cannot encode a resumable cursor', async () => {
|
||||
const service = makeService();
|
||||
stubRows(service, [
|
||||
{ id: '2', createdAt: 'not-a-date' },
|
||||
{ id: '1', createdAt: 'not-a-date' },
|
||||
]);
|
||||
|
||||
await expect(service.list({ filter: { ...TASK_FILTER, limit: 1 } })).rejects.toThrow(
|
||||
'Federation list cursor cannot be encoded',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on unsupported resources instead of crashing pagination', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.list({
|
||||
filter: {
|
||||
...TASK_FILTER,
|
||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('Unsupported federation list resource');
|
||||
});
|
||||
|
||||
it('does not leak another user mission task notes through team-scoped note reads', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
const result = await service.list({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 10,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const ids = result.items.map((item) => item['id']);
|
||||
expect(ids).toEqual([SUBJECT_TEAM_NOTE_ID]);
|
||||
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
|
||||
});
|
||||
|
||||
it('does not return subject personal mission task notes when includePersonal is false', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
const result = await service.list({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 10,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.items.map((item) => item['id'])).not.toContain(SUBJECT_PERSONAL_NOTE_ID);
|
||||
});
|
||||
|
||||
it('does not return subject notes from missions outside the grant-visible project set', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
const result = await service.list({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: true,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 10,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const ids = result.items.map((item) => item['id']);
|
||||
expect(ids).toContain(SUBJECT_PERSONAL_NOTE_ID);
|
||||
expect(ids).toContain(SUBJECT_TEAM_NOTE_ID);
|
||||
expect(ids).not.toContain(SUBJECT_UNAUTHORIZED_NOTE_ID);
|
||||
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
|
||||
});
|
||||
|
||||
it('paginates memory deterministically across insights and preferences', async () => {
|
||||
const service = makeDbService();
|
||||
const filter: FederationScopeQueryFilter = {
|
||||
resource: 'memory',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: true,
|
||||
teamIds: [],
|
||||
limit: 2,
|
||||
maxRowsPerQuery: 2,
|
||||
};
|
||||
|
||||
const firstPage = await service.list({ filter });
|
||||
const secondPage = await service.list({ filter, cursor: firstPage.nextCursor });
|
||||
const firstPageIds = firstPage.items.map((item) => item['id']);
|
||||
const secondPageIds = secondPage.items.map((item) => item['id']);
|
||||
const allIds = [...firstPageIds, ...secondPageIds];
|
||||
|
||||
expect(firstPage).toMatchObject({ truncated: true, nextCursor: expect.any(String) });
|
||||
expect(firstPageIds).toEqual([INSIGHT_TWO_ID, INSIGHT_ONE_ID]);
|
||||
expect(secondPageIds).toEqual([PREFERENCE_TWO_ID, PREFERENCE_ONE_ID]);
|
||||
expect(new Set(allIds).size).toBe(allIds.length);
|
||||
});
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
||||
import type {
|
||||
FederationScopeEvaluationResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from '../../scope.service.js';
|
||||
import { ListController } from '../list.controller.js';
|
||||
import type { FederationListQueryResult } from '../list-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: 10,
|
||||
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?: FederationListQueryResult;
|
||||
}) {
|
||||
const scope = {
|
||||
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
|
||||
};
|
||||
const query = {
|
||||
evaluateReadAccess: vi.fn(),
|
||||
list: vi.fn().mockResolvedValue(
|
||||
opts?.queryResult ?? {
|
||||
items: [
|
||||
{
|
||||
id: 'task-1',
|
||||
title: 'Federated task',
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
controller: new ListController(scope as never, query as never),
|
||||
scope,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ListController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('declares POST /api/federation/v1/list/:resource protected only by FederationAuthGuard', () => {
|
||||
expect(Reflect.getMetadata('path', ListController)).toBe('api/federation/v1/list');
|
||||
expect(Reflect.getMetadata('path', ListController.prototype.list)).toBe(':resource');
|
||||
expect(Reflect.getMetadata('method', ListController.prototype.list)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata('__guards__', ListController)).toEqual([FederationAuthGuard]);
|
||||
});
|
||||
|
||||
it('runs AuthGuard context through ScopeService and returns local-source tagged rows', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
const response = await controller.list('tasks', makeRequest(), { limit: 10 });
|
||||
|
||||
expect(scope.evaluateAccess).toHaveBeenCalledWith({
|
||||
context: FEDERATION_CONTEXT,
|
||||
resource: 'tasks',
|
||||
requestedLimit: 10,
|
||||
nativeRbac: query,
|
||||
});
|
||||
expect(query.list).toHaveBeenCalledWith({ filter: TASK_FILTER, cursor: undefined });
|
||||
expect(response).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: 'task-1',
|
||||
title: 'Federated task',
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||
_source: 'local',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves pagination metadata when row cap truncates the query layer result', async () => {
|
||||
const { controller } = makeController({
|
||||
queryResult: {
|
||||
items: [{ id: 'task-1' }],
|
||||
nextCursor: 'cursor-2',
|
||||
truncated: true,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await controller.list('tasks', makeRequest(), { cursor: 'cursor-1' });
|
||||
|
||||
expect(response).toEqual({
|
||||
items: [{ id: 'task-1', _source: 'local' }],
|
||||
nextCursor: 'cursor-2',
|
||||
_truncated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a federation error envelope when auth guard context is missing', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
await expect(
|
||||
controller.list('tasks', {} as unknown as FastifyRequest, {}),
|
||||
).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'unauthorized',
|
||||
message: 'Federation context missing',
|
||||
},
|
||||
},
|
||||
status: 401,
|
||||
});
|
||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||
expect(query.list).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.list('credentials', makeRequest(), {})).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
expect(query.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects malformed request body fields before querying storage', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
await expect(controller.list('tasks', makeRequest(), { cursor: 123 })).rejects.toMatchObject({
|
||||
response: { error: { code: 'invalid_request' } },
|
||||
status: 400,
|
||||
});
|
||||
await expect(controller.list('tasks', makeRequest(), { limit: false })).rejects.toMatchObject({
|
||||
response: { error: { code: 'invalid_request' } },
|
||||
status: 400,
|
||||
});
|
||||
await expect(controller.list('tasks', makeRequest(), { limit: 'abc' })).rejects.toMatchObject({
|
||||
response: { error: { code: 'invalid_request' } },
|
||||
status: 400,
|
||||
});
|
||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||
expect(query.list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Federation capabilities verb (FED-M3-07).
|
||||
*
|
||||
* Returns the read-only capability envelope for the active grant attached by
|
||||
* FederationAuthGuard. This endpoint intentionally does not invoke native RBAC
|
||||
* or ScopeService: an active grant is enough to ask what the grant allows.
|
||||
*/
|
||||
|
||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import {
|
||||
FEDERATION_VERBS,
|
||||
type FederationCapabilitiesResponse,
|
||||
type FederationVerb,
|
||||
} from '@mosaicstack/types';
|
||||
import { parseFederationScope } from '../../scope-schema.js';
|
||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
||||
import '../federation-context.js';
|
||||
|
||||
@Controller('api/federation/v1/capabilities')
|
||||
@UseGuards(FederationAuthGuard)
|
||||
export class CapabilitiesController {
|
||||
@Get()
|
||||
getCapabilities(@Req() request: FastifyRequest): FederationCapabilitiesResponse {
|
||||
if (!request.federationContext) {
|
||||
throw new Error('Federation context missing after auth guard');
|
||||
}
|
||||
|
||||
const scope = parseFederationScope(request.federationContext.scope);
|
||||
|
||||
return {
|
||||
resources: [...scope.resources],
|
||||
excluded_resources: [...scope.excluded_resources],
|
||||
max_rows_per_query: scope.max_rows_per_query,
|
||||
supported_verbs: [...FEDERATION_VERBS] satisfies FederationVerb[],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
/**
|
||||
* Federation list query layer (FED-M3-05).
|
||||
*
|
||||
* Read-only DB adapter used by ListController 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,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
insights,
|
||||
isNotNull,
|
||||
lt,
|
||||
missionTasks,
|
||||
missions,
|
||||
or,
|
||||
preferences,
|
||||
projects,
|
||||
tasks,
|
||||
teamMembers,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import type {
|
||||
FederationNativeRbacEvaluator,
|
||||
FederationNativeRbacRequest,
|
||||
FederationNativeRbacResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from '../scope.service.js';
|
||||
import { DB } from '../../../database/database.module.js';
|
||||
|
||||
export interface FederationListQueryRequest {
|
||||
readonly filter: FederationScopeQueryFilter;
|
||||
readonly cursor?: string;
|
||||
}
|
||||
|
||||
export interface FederationListQueryResult<T extends object = Record<string, unknown>> {
|
||||
readonly items: T[];
|
||||
readonly nextCursor?: string;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
type CursorSource = 'insights' | 'preferences';
|
||||
const CURSOR_SOURCE = Symbol('federationCursorSource');
|
||||
|
||||
type RowObject = Record<string, unknown> & { readonly [CURSOR_SOURCE]?: CursorSource };
|
||||
|
||||
interface KeysetCursor {
|
||||
readonly createdAt: Date;
|
||||
readonly id: string;
|
||||
readonly source?: CursorSource;
|
||||
}
|
||||
|
||||
function encodeCursor(row: RowObject): string {
|
||||
const createdAt = row['createdAt'];
|
||||
const id = row['id'];
|
||||
if (!(createdAt instanceof Date) || typeof id !== 'string') {
|
||||
throw new Error('Federation list cursor cannot be encoded');
|
||||
}
|
||||
|
||||
const source = row[CURSOR_SOURCE];
|
||||
return Buffer.from(
|
||||
JSON.stringify({ createdAt: createdAt.toISOString(), id, ...(source ? { source } : {}) }),
|
||||
'utf8',
|
||||
).toString('base64url');
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string | undefined): KeysetCursor | undefined {
|
||||
if (cursor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw new Error('cursor must be an object');
|
||||
}
|
||||
|
||||
const { createdAt, id, source } = parsed as {
|
||||
createdAt?: unknown;
|
||||
id?: unknown;
|
||||
source?: unknown;
|
||||
};
|
||||
if (typeof createdAt !== 'string' || typeof id !== 'string' || id.length === 0) {
|
||||
throw new Error('cursor is missing createdAt or id');
|
||||
}
|
||||
if (source !== undefined && source !== 'insights' && source !== 'preferences') {
|
||||
throw new Error('cursor source is invalid');
|
||||
}
|
||||
|
||||
const date = new Date(createdAt);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new Error('cursor createdAt is invalid');
|
||||
}
|
||||
|
||||
return { createdAt: date, id, ...(source ? { source } : {}) };
|
||||
} catch {
|
||||
throw new Error('Invalid federation list cursor');
|
||||
}
|
||||
}
|
||||
|
||||
function paginate<T extends RowObject>(rows: T[], limit: number): FederationListQueryResult<T> {
|
||||
const page = rows.slice(0, limit);
|
||||
const hasMore = rows.length > limit;
|
||||
const nextCursor = hasMore ? encodeCursor(page[page.length - 1] ?? {}) : undefined;
|
||||
|
||||
return {
|
||||
items: page,
|
||||
truncated: hasMore,
|
||||
...(nextCursor !== undefined ? { nextCursor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function markCursorSource<T extends RowObject>(row: T, source: CursorSource): T {
|
||||
Object.defineProperty(row, CURSOR_SOURCE, {
|
||||
value: source,
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function sortRows(rows: RowObject[]): RowObject[] {
|
||||
return [...rows].sort((a, b) => {
|
||||
const aTime = a['createdAt'] instanceof Date ? a['createdAt'].getTime() : 0;
|
||||
const bTime = b['createdAt'] instanceof Date ? b['createdAt'].getTime() : 0;
|
||||
if (aTime !== bTime) {
|
||||
return bTime - aTime;
|
||||
}
|
||||
return String(b['id'] ?? '').localeCompare(String(a['id'] ?? ''));
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FederationListQueryService 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 list 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 list<T extends RowObject = RowObject>(
|
||||
request: FederationListQueryRequest,
|
||||
): Promise<FederationListQueryResult<T>> {
|
||||
const cursor = decodeCursor(request.cursor);
|
||||
const rows = await this.listAllRows(request.filter, request.filter.limit + 1, cursor);
|
||||
return paginate(rows as T[], request.filter.limit);
|
||||
}
|
||||
|
||||
private async listAllRows(
|
||||
filter: FederationScopeQueryFilter,
|
||||
rowLimit: number,
|
||||
cursor: KeysetCursor | undefined,
|
||||
): Promise<RowObject[]> {
|
||||
switch (filter.resource) {
|
||||
case 'tasks':
|
||||
return this.listTasks(filter, rowLimit, cursor);
|
||||
case 'notes':
|
||||
return this.listNotes(filter, rowLimit, cursor);
|
||||
case 'memory':
|
||||
return this.listMemory(filter, rowLimit, cursor);
|
||||
case 'credentials':
|
||||
case 'api_keys':
|
||||
return [];
|
||||
default:
|
||||
throw new Error(`Unsupported federation list 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) {
|
||||
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 listTasks(
|
||||
filter: FederationScopeQueryFilter,
|
||||
rowLimit: number,
|
||||
cursor: KeysetCursor | undefined,
|
||||
): Promise<RowObject[]> {
|
||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||
const missionIds = await this.listMissionIds(projectIds);
|
||||
const clauses = [];
|
||||
|
||||
if (projectIds.length > 0) {
|
||||
clauses.push(inArray(tasks.projectId, projectIds));
|
||||
}
|
||||
if (missionIds.length > 0) {
|
||||
clauses.push(inArray(tasks.missionId, missionIds));
|
||||
}
|
||||
|
||||
if (clauses.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const scopeClause = clauses.length === 1 ? clauses[0] : or(...clauses);
|
||||
const cursorClause = cursor
|
||||
? or(
|
||||
lt(tasks.createdAt, cursor.createdAt),
|
||||
and(eq(tasks.createdAt, cursor.createdAt), lt(tasks.id, cursor.id)),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const rows = 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(and(scopeClause, cursorClause))
|
||||
.orderBy(desc(tasks.createdAt), desc(tasks.id))
|
||||
.limit(rowLimit);
|
||||
|
||||
return sortRows(rows as RowObject[]);
|
||||
}
|
||||
|
||||
private async listNotes(
|
||||
filter: FederationScopeQueryFilter,
|
||||
rowLimit: number,
|
||||
cursor: KeysetCursor | undefined,
|
||||
): Promise<RowObject[]> {
|
||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||
const missionIds = await this.listMissionIds(projectIds);
|
||||
|
||||
if (missionIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// mission_tasks rows are user-scoped even when the mission belongs to a team.
|
||||
// Team visibility can narrow the mission set, but it must never widen the
|
||||
// query to other users' mission task notes.
|
||||
const scopeClause = and(
|
||||
eq(missionTasks.userId, filter.subjectUserId),
|
||||
inArray(missionTasks.missionId, missionIds),
|
||||
);
|
||||
const cursorClause = cursor
|
||||
? or(
|
||||
lt(missionTasks.createdAt, cursor.createdAt),
|
||||
and(eq(missionTasks.createdAt, cursor.createdAt), lt(missionTasks.id, cursor.id)),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: missionTasks.id,
|
||||
missionId: missionTasks.missionId,
|
||||
taskId: missionTasks.taskId,
|
||||
status: missionTasks.status,
|
||||
content: missionTasks.notes,
|
||||
createdAt: missionTasks.createdAt,
|
||||
updatedAt: missionTasks.updatedAt,
|
||||
})
|
||||
.from(missionTasks)
|
||||
.where(and(scopeClause, cursorClause, isNotNull(missionTasks.notes)))
|
||||
.orderBy(desc(missionTasks.createdAt), desc(missionTasks.id))
|
||||
.limit(rowLimit);
|
||||
|
||||
return sortRows(rows.filter((row) => row.content !== '') as RowObject[]);
|
||||
}
|
||||
|
||||
private async listMemory(
|
||||
filter: FederationScopeQueryFilter,
|
||||
rowLimit: number,
|
||||
cursor: KeysetCursor | undefined,
|
||||
): Promise<RowObject[]> {
|
||||
if (!filter.includePersonal) {
|
||||
return [];
|
||||
}
|
||||
if (cursor && cursor.source === undefined) {
|
||||
throw new Error('Invalid federation list cursor');
|
||||
}
|
||||
|
||||
const rows: RowObject[] = [];
|
||||
|
||||
// Memory spans two physical tables. To keep pagination deterministic and
|
||||
// resumable without a SQL UNION, M3 emits a fixed block order: all insights
|
||||
// first, then preferences. The opaque cursor records which table produced
|
||||
// the boundary row, so the next page never re-applies one table's keyset to
|
||||
// the other table (which could duplicate/skip rows at equal timestamps).
|
||||
if (cursor?.source !== 'preferences') {
|
||||
const insightCursorClause = cursor
|
||||
? or(
|
||||
lt(insights.createdAt, cursor.createdAt),
|
||||
and(eq(insights.createdAt, cursor.createdAt), lt(insights.id, cursor.id)),
|
||||
)
|
||||
: undefined;
|
||||
const insightRows = await this.db
|
||||
.select({
|
||||
id: insights.id,
|
||||
kind: insights.source,
|
||||
content: insights.content,
|
||||
category: insights.category,
|
||||
relevanceScore: insights.relevanceScore,
|
||||
metadata: insights.metadata,
|
||||
createdAt: insights.createdAt,
|
||||
updatedAt: insights.updatedAt,
|
||||
})
|
||||
.from(insights)
|
||||
.where(and(eq(insights.userId, filter.subjectUserId), insightCursorClause))
|
||||
.orderBy(desc(insights.createdAt), desc(insights.id))
|
||||
.limit(rowLimit);
|
||||
|
||||
rows.push(...(insightRows as RowObject[]).map((row) => markCursorSource(row, 'insights')));
|
||||
}
|
||||
|
||||
const remaining = rowLimit - rows.length;
|
||||
if (remaining <= 0) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const preferenceCursorClause =
|
||||
cursor?.source === 'preferences'
|
||||
? or(
|
||||
lt(preferences.createdAt, cursor.createdAt),
|
||||
and(eq(preferences.createdAt, cursor.createdAt), lt(preferences.id, cursor.id)),
|
||||
)
|
||||
: undefined;
|
||||
const preferenceRows = await this.db
|
||||
.select({
|
||||
id: preferences.id,
|
||||
kind: preferences.category,
|
||||
key: preferences.key,
|
||||
value: preferences.value,
|
||||
source: preferences.source,
|
||||
mutable: preferences.mutable,
|
||||
createdAt: preferences.createdAt,
|
||||
updatedAt: preferences.updatedAt,
|
||||
})
|
||||
.from(preferences)
|
||||
.where(and(eq(preferences.userId, filter.subjectUserId), preferenceCursorClause))
|
||||
.orderBy(desc(preferences.createdAt), desc(preferences.id))
|
||||
.limit(remaining);
|
||||
|
||||
rows.push(
|
||||
...(preferenceRows as RowObject[]).map((row) => markCursorSource(row, 'preferences')),
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* Federation list verb (FED-M3-05).
|
||||
*
|
||||
* POST /api/federation/v1/list/:resource
|
||||
*
|
||||
* Pipeline: FederationAuthGuard attaches the active grant context, then
|
||||
* FederationScopeService enforces grant scope + native RBAC intersection, then
|
||||
* the read-only query layer returns capped rows tagged with `_source`. Read
|
||||
* audit-log writes are deferred to M4; this controller does not persist request
|
||||
* or response bodies.
|
||||
*/
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import {
|
||||
FederationInvalidRequestError,
|
||||
FederationScopeViolationError,
|
||||
FederationUnauthorizedError,
|
||||
SOURCE_LOCAL,
|
||||
tagWithSource,
|
||||
type FederationListResponse,
|
||||
type SourceTag,
|
||||
} from '@mosaicstack/types';
|
||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
||||
import '../federation-context.js';
|
||||
import { FederationScopeService } from '../scope.service.js';
|
||||
import { FederationListQueryService } from './list-query.service.js';
|
||||
|
||||
interface FederationListRequestBody {
|
||||
readonly limit?: unknown;
|
||||
readonly cursor?: unknown;
|
||||
}
|
||||
|
||||
type FederatedRow = Record<string, unknown> & SourceTag;
|
||||
|
||||
function parseLimit(body: FederationListRequestBody | undefined): number | undefined {
|
||||
if (body?.limit === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed =
|
||||
typeof body.limit === 'number'
|
||||
? body.limit
|
||||
: typeof body.limit === 'string' && body.limit.trim().length > 0
|
||||
? Number(body.limit)
|
||||
: Number.NaN;
|
||||
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new HttpException(
|
||||
new FederationInvalidRequestError(
|
||||
'Federation list limit must be a positive integer',
|
||||
).toEnvelope(),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseCursor(body: FederationListRequestBody | undefined): string | undefined {
|
||||
if (body?.cursor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof body.cursor === 'string') {
|
||||
return body.cursor;
|
||||
}
|
||||
throw new HttpException(
|
||||
new FederationInvalidRequestError('Federation list cursor must be a string').toEnvelope(),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
@Controller('api/federation/v1/list')
|
||||
@UseGuards(FederationAuthGuard)
|
||||
export class ListController {
|
||||
constructor(
|
||||
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
|
||||
@Inject(FederationListQueryService) private readonly query: FederationListQueryService,
|
||||
) {}
|
||||
|
||||
@Post(':resource')
|
||||
async list(
|
||||
@Param('resource') resource: string,
|
||||
@Req() request: FastifyRequest,
|
||||
@Body() body?: FederationListRequestBody,
|
||||
): Promise<FederationListResponse<FederatedRow>> {
|
||||
if (!request.federationContext) {
|
||||
throw new HttpException(
|
||||
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
const requestedLimit = parseLimit(body);
|
||||
const cursor = parseCursor(body);
|
||||
const scopeResult = await this.scope.evaluateAccess({
|
||||
context: request.federationContext,
|
||||
resource,
|
||||
requestedLimit,
|
||||
nativeRbac: this.query,
|
||||
});
|
||||
|
||||
if (!scopeResult.allowed) {
|
||||
const ErrorClass =
|
||||
scopeResult.deny.statusCode === 400
|
||||
? FederationInvalidRequestError
|
||||
: FederationScopeViolationError;
|
||||
throw new HttpException(
|
||||
new ErrorClass(scopeResult.deny.message, scopeResult.deny).toEnvelope(),
|
||||
scopeResult.deny.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
let result: Awaited<ReturnType<FederationListQueryService['list']>>;
|
||||
try {
|
||||
result = await this.query.list({ filter: scopeResult.filter, cursor });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.message === 'Invalid federation list cursor') {
|
||||
throw new HttpException(
|
||||
new FederationInvalidRequestError('Federation list cursor is invalid').toEnvelope(),
|
||||
400,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const response: FederationListResponse<FederatedRow> = {
|
||||
items: tagWithSource(result.items, SOURCE_LOCAL),
|
||||
};
|
||||
if (result.nextCursor !== undefined) {
|
||||
response.nextCursor = result.nextCursor;
|
||||
}
|
||||
if (result.truncated) {
|
||||
response._truncated = true;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -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,9 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.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 {
|
||||
@@ -24,9 +20,9 @@ export class SystemOverrideService {
|
||||
this.handle = createQueue();
|
||||
}
|
||||
|
||||
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
||||
async set(sessionId: string, override: string): Promise<void> {
|
||||
// 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 +37,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 +49,21 @@ 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> {
|
||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
|
||||
}
|
||||
|
||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
async renew(sessionId: string): Promise<void> {
|
||||
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> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
97
docs/PRD.md
97
docs/PRD.md
@@ -64,7 +64,6 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
|
||||
21. `@mosaicstack/cli` — unified `mosaic` CLI
|
||||
22. Docker Compose deployment + bare-metal capability
|
||||
23. Agent log service — ingest, parse, tier, summarize agent interaction logs
|
||||
24. Local durable agent fleet canary — `mosaic fleet` / `mosaic agent` CLI for an isolated tmux-backed canary fleet using a named socket, with roster-driven local customization and rollback-safe verification
|
||||
|
||||
### Out of Scope (v0.1.0)
|
||||
|
||||
@@ -79,102 +78,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
|
||||
|
||||
@@ -46,48 +45,3 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
||||
- Status: PR open, awaiting maintainer merge ratification (fleet-governing change).
|
||||
- Cut always-injected contract AGENTS+TOOLS+RUNTIME 8,827→4,122 tok (−53%); all 12 hard gates intact.
|
||||
- Validation: deterministic gate-checklist PASS; headless A/B thin 7/9 vs monolith 5/9. Detail: scratchpads/contract-thin-core.md.
|
||||
|
||||
## P5 — Overlay composer + cross-harness (#604) — feat/p5-overlay-composer
|
||||
|
||||
- Status: MERGED to main (#605). R7 (compose-contract) + R8 (cross-harness) + R9 (composer test).
|
||||
- `composeContract({harness, mosaicHome})` pure fn + `.local` overlay deltas-by-value; `mosaic compose-contract <harness>` command; AGENTS bare-launch nudge; composer spec (per-tier anchor + Tier-3 byte-equality). Detail: scratchpads/p5-overlay-composer.md.
|
||||
|
||||
## P6 — Docs, compliance matrix, alpha tag (#606) — feat/p6-docs-compliance-alpha
|
||||
|
||||
- Status: in-repo deliverables done (CONTRIBUTING.md + harness×gate compliance matrix + check-resident-budget.sh + CI wiring + ALPHA-DOD.md). Remaining: alpha tag v0.0.39-alpha (Lead, post-merge). aiguide reconcile merged (#8). Detail: scratchpads/p6-docs-compliance-alpha.md.
|
||||
|
||||
## F3-m3 — mosaic update re-seeds framework + relaunches agents (#609) — feat/f3-m3-update-reseed
|
||||
|
||||
- Status: implemented + tested. Closes R13: `mosaic update` now re-seeds the framework (data-safe MOSAIC_SYNC_ONLY) after the CLI install so shipped launcher/runtime changes activate; `--relaunch` restarts rostered agents; `--no-reseed` opts out. Detail: scratchpads/f3-m3-update-reseed.md.
|
||||
|
||||
## Fleet-polish bundle — boot-survival symmetry (#611) — feat/fleet-polish-bundle
|
||||
|
||||
- Status: MERGED to main. disable-on-remove (boot-resurrection bug, TDD) + add-enable + init-R5 hard guarantee. 4 new + 147 existing fleet tests green. Detail: scratchpads/fleet-polish-bundle.md.
|
||||
|
||||
## Fleet enhancer role + two-agent floor (#614) — feat/fleet-enhancer-floor
|
||||
|
||||
- Status: MERGED to main. enhancer added to 4 presets; init guarantees 1 orchestrator + >=1 enhancer; remove protects the sole enhancer; enhancer role doc. 155 fleet tests green. Detail: scratchpads/fleet-enhancer-floor.md.
|
||||
|
||||
## F4 — Orchestrator chat connector + Matrix (#616) — feat/f4-matrix-connector
|
||||
|
||||
- Status: Phase 1 MERGED (#617: connector interface send/subscribe/health + registry + roster schema + design). Phase 2a (#618): Matrix CS-API client + factory. 20 connector tests green; no fleet.ts changes. Remaining Phase 2: init/configure connector-selection UX + roster wiring, systemd launch wiring, Conduit deploy guide. Detail: scratchpads/f4-matrix-connector.md.
|
||||
|
||||
## Fleet onboarding-injection — comms cheat-sheet + peer roster (#620) — feat/fleet-comms-onboarding
|
||||
|
||||
- Status: implemented + tested. Injects # Fleet Comms (peer roster + cross-host agent-send commands + FLIP-reply + --verify) into each spawned fleet agent via composeContract; optional per-agent host/ssh/socket roster fields (socket: named → -L, unset → default socket no -L). 10 + 2 tests green. Detail: scratchpads/fleet-comms-onboarding.md.
|
||||
|
||||
## Fleet stand-up fixes — model_hint→--model + socket-default trap (#626) — feat/fleet-standup-fixes
|
||||
|
||||
- Status: implemented + tested. FIX1 model_hint→MOSAIC_AGENT_MODEL→--model. FIX2 absent socket = default tmux socket (no -L) across parse/spawn/systemd-unit/observe (socketArgs helper, bare-empty shellEnvValue, conditional -L). 158 fleet tests green; shipped presets unaffected (explicit socket_name). Detail: scratchpads/fleet-standup-fixes.md.
|
||||
|
||||
## north-star doctrine consolidation — doc PR — feat/north-star-doctrine
|
||||
|
||||
- Status: applied Mos's consolidated merge-map to docs/fleet/north-star.md (budget governance + control plane/central register + 200k cap + delegation + unified-identity Fleet + role-based naming + tmux security + drift re-captures). Doctrine only; #622/#623/#625/#628 out-of-scope. Conflict checklist green. Detail: scratchpads/north-star-doctrine.md.
|
||||
|
||||
## #631 — re-seed preserves user fleet data (CRITICAL) — fix/631-reseed-preserves-fleet-data
|
||||
|
||||
- Status: implemented + tested. PRIMARY: install.sh PRESERVE_PATHS += fleet/\*.yaml + fleet/agents + fleet/run (glob-aware cp-fallback); TS parity. SECONDARY: refreshActiveFleetUnits propagates unit fixes to ~/.config/systemd/user on mosaic update. bash F6 + TS + unit tests green. Detail: scratchpads/631-reseed-preserves-fleet.md.
|
||||
|
||||
## #633 — comms-block emitter + FLEET-LAUNCH runbook — feat/633-comms-block-runbook
|
||||
|
||||
- Status: implemented + tested (TDD). `mosaic fleet comms-block <role> [--host]` wraps resolveCommsBlock → readFleetCommsBlock; fails loud (stderr + exit 1) on unknown role / missing roster instead of silent empty. docs/fleet/FLEET-LAUNCH.md runbook: worker path + orchestrator .env fold (MOSAIC_AGENT_COMMAND; line-41 [-z] short-circuits line-44 yolo hardcode) + 3 launch gotchas + #632 preserve note + North-Star 4-field arc (harness ✅/model ✅ roster-native today; yolo + command/channels = PATH B #636). 177 fleet+comms tests green (6 new resolveCommsBlock cases). PATH A of the A→B→webUI arc. Detail: scratchpads/633-comms-block-runbook.md.
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# Constitution Alpha — Definition-of-Done checklist + release notes
|
||||
|
||||
Drafted for the `v0.0.39-alpha` tag (Lead cuts after P5 #605 → P6 #607 → aiguide #8 merge).
|
||||
Maps every DoD §8 acceptance criterion to its merged evidence. Legend:
|
||||
**✅ merged on main** · **⏳ review-ready PR (pending merge)** · **🔲 Lead action**.
|
||||
|
||||
## DoD §8 green-checklist
|
||||
|
||||
| # | Acceptance criterion (DESIGN §8) | Status | Evidence / PR |
|
||||
| --- | ------------------------------------------------------------------------------------------------------ | ------ | ----------------- |
|
||||
| 1 | MIT `LICENSE` (root + framework) + `"license":"MIT"` in package.json | ✅ | P0 #570 |
|
||||
| 2 | Three credential-path sites + hook URL fast-failed (no private paths in `*.sh`/hooks) | ✅ | P0 #570 |
|
||||
| 3 | `verify-sanitized.sh` (two-class, `*.sh`+`*.md`, self-tested) wired **blocking** in CI | ✅ | P1 #572 |
|
||||
| 4 | Operator data purged from the full set (guides / tools / init-generator) | ✅ | P2 #572 |
|
||||
| 5 | `rails/`→`tools/` in **both** template families | ✅ | P2 #572 |
|
||||
| 6 | `jarvis-loop.json` deleted; `defaults/SOUL.md` → **neutral sanitized persona** (Q10 decision) | ✅ | P2 #572 |
|
||||
| 7 | `CONSTITUTION.md` extracted (gates one place, capability-verb, §1.4 split, no false "already loaded") | ✅ | P3 #575 / #577 |
|
||||
| 8 | `AGENTS.md`/`STANDARDS.md` out of `PRESERVE_PATHS` + seed-semantics → overwrite in **both** installers | ✅ | P4 #590 |
|
||||
| 9 | Snapshot + v2→v3 migration moving user edits to `.local`/`.bak`; `FRAMEWORK_VERSION=3` | ✅ | P4 #590 / #593 |
|
||||
| 10 | `mosaic-init --non-interactive` fail-closed persona | ✅ | P4 #590 |
|
||||
| 11 | **5-fixture migration matrix** green against **both** installers asserting **injected bytes** | ✅ | P4 #590 / #593 |
|
||||
| 12 | `compose-contract` built + composer unit test (per-tier anchor + Tier-3 byte-equality) | ⏳ | P5 #605 |
|
||||
| 13 | Resident line-count ceiling enforced (framework-owned resident files) | ⏳ | P6 #607 |
|
||||
| 14 | `CONTRIBUTING.md` + harness×gate compliance matrix | ⏳ | P6 #607 |
|
||||
| 15 | `aiguide` reconciled with the Constitution | ⏳ | aiguide #8 |
|
||||
| 16 | Each phase PR CI-green; alpha tag pushed + Gitea release published | 🔲 | Lead (post-merge) |
|
||||
|
||||
**Note on #6:** the DoD's literal "delete `defaults/SOUL.md`" was superseded by the resolved
|
||||
**Q10** decision — ship a _neutral, operator-agnostic_ example persona instead of deleting it. Main
|
||||
carries the sanitized 2.6 KB neutral SOUL.md ("Mosaic agent", no operator identity); the sanitization
|
||||
gate confirms it is PII-clean. Criterion met in spirit (no operator persona leaks) via the better option.
|
||||
|
||||
**Gate to flip 12–14 → ✅:** merge P5 #605 → P6 #607 (rebase auto-drops the dup format fix
|
||||
`adc7df2`/`9f6da92`) → aiguide #8, with `ci.yml` terminal-green on the merged head.
|
||||
|
||||
---
|
||||
|
||||
## Release notes — `v0.0.39-alpha` (Mosaic Framework Constitution, alpha)
|
||||
|
||||
### Mosaic Framework Constitution — Alpha
|
||||
|
||||
This release makes the Mosaic framework a **safe-to-open-source, fork-and-customize agent
|
||||
operating layer**. It separates the non-negotiable law from operator identity, makes
|
||||
customization survive upgrades, and wires the guarantees into CI.
|
||||
|
||||
**Highlights**
|
||||
|
||||
- **Constitution (L0).** The hard gates now live in one place — `CONSTITUTION.md` — authored in
|
||||
capability verbs, with a thin `AGENTS.md` dispatcher that references the law instead of restating
|
||||
it. Governance model in `constitution/LAYER-MODEL.md`.
|
||||
- **Public & sanitized.** MIT-licensed; all operator identity, private paths, and credential sites
|
||||
removed from shipped files. A self-tested `verify-sanitized.sh` gate (two rule classes) runs
|
||||
**blocking** in CI so re-contamination can't merge.
|
||||
- **Upgrade-safe customization.** Framework-owned files overwrite cleanly on upgrade while
|
||||
`SOUL.md`/`USER.md`/`*.local.md`/`credentials` are preserved. The v2→v3 migration snapshots first
|
||||
and moves any user-edited `AGENTS.md`/`STANDARDS.md` to `.pre-constitution.bak`/`.local.md` —
|
||||
never silently lost. Verified by a 5-fixture matrix across **both** installers.
|
||||
- **Operator overlays.** `mosaic compose-contract <harness>` merges your `*.local.md` deltas into
|
||||
the contract per harness, so customization reaches the model as one pre-merged blob.
|
||||
- **Cross-harness.** Single L0 source referenced (never restated) by Claude / Codex / OpenCode / Pi;
|
||||
tiered injection with a byte-equal Tier-3 fallback read.
|
||||
- **Guardrails in CI.** Resident line-count ceiling over framework-owned resident files; composer
|
||||
unit test; sanitization gate — all blocking.
|
||||
- **Docs.** `CONTRIBUTING.md` with the layer model, dual-installer parity rule, and a harness×gate
|
||||
**compliance matrix** (the Codex/OpenCode/Pi hook-parity gap is tracked for v2).
|
||||
|
||||
**Known limitations (accepted, documented in `CONTRIBUTING.md` §9)**
|
||||
|
||||
- Bare launches that bypass `mosaic` get base contracts only (no `*.local` overlays) and are not
|
||||
drift-checked by `mosaic doctor` — mitigated by the unconditional Tier-3 self-load + a nudge.
|
||||
- Codex/OpenCode/Pi mechanical hook parity, `policy/*.md` composition, and live-launch cross-harness
|
||||
verification are **v2**.
|
||||
|
||||
**Phase lineage:** P0 #570 · P1+P2 #572 · P3 #575/#577 · P4 #590/#593 · P5 #605 · P6 #607 ·
|
||||
aiguide #8 (umbrella #542).
|
||||
@@ -91,22 +91,22 @@ Goal: Two federated gateways exchange real data over mTLS. Inbound requests pass
|
||||
>
|
||||
> **Tracking issue:** #462.
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------------ | --------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| FED-M3-01 | done | `packages/types/src/federation/` — request/response DTOs for `list`, `get`, `capabilities` verbs. Wire-format zod schemas + inferred TS types. Includes `FederationRequest`, `FederationListResponse<T>`, `FederationGetResponse<T>`, `FederationCapabilitiesResponse`, error envelope, `_source` tag. | #462 | sonnet | feat/federation-m3-types | — | 4K | Reusable from gateway server + client + harness. Pure types — no I/O, no NestJS. |
|
||||
| FED-M3-02 | done | `tools/federation-harness/` scaffold: `docker-compose.two-gateways.yml` (Server A + Server B + step-CA), `seed.ts` (provisions grants, peers, sample tasks/notes/credentials per scope variant), `harness.ts` helper (boots stack, returns typed clients). README documents harness use. | #462 | sonnet | feat/federation-m3-harness | DEPLOY-04 (soft) | 8K | Falls back to local docker-compose if `mos-test-1/-2` not yet redeployed (DEPLOY chain blocked on IMG-FIX). Permanent test infra used by M3+. |
|
||||
| FED-M3-03 | done | `apps/gateway/src/federation/server/federation-auth.guard.ts` (NestJS guard). Validates inbound client cert from Fastify TLS context, extracts `grantId` + `subjectUserId` from custom OIDs, loads grant from DB, asserts `status='active'`, attaches `FederationContext` to request. | #462 | sonnet | feat/federation-m3-auth-guard | M3-01 | 8K | Reuses OID parsing logic mirrored from `ca.service.ts` post-issuance verification. 401 on malformed/missing OIDs; 403 on revoked/expired/missing grant. |
|
||||
| FED-M3-04 | in-progress | `apps/gateway/src/federation/server/scope.service.ts`. Pipeline: (1) resource allowlist + excluded check, (2) native RBAC eval as `subjectUserId`, (3) scope filter intersection (`include_teams`, `include_personal`), (4) `max_rows_per_query` cap. Pure service — DB calls injected. | #462 | sonnet | feat/federation-m3-scope-service | M3-01 | 10K | Hardest correctness target in M3. Reuses `parseFederationScope` (M2-03). Returns either `{ allowed: true, filter }` or structured deny reason for audit. |
|
||||
| FED-M3-05 | in-progress | `apps/gateway/src/federation/server/verbs/list.controller.ts`. Wires AuthGuard → ScopeService → tasks/notes/memory query layer; applies row cap; tags rows with `_source`. Resource selector via path param. | #462 | sonnet | feat/federation-m3-verb-list | M3-03, M3-04 | 6K | Routes: `POST /api/federation/v1/list/:resource`. No body persistence. Audit write deferred to M4. |
|
||||
| FED-M3-06 | not-started | `apps/gateway/src/federation/server/verbs/get.controller.ts`. Single-resource fetch by id; same pipeline as list. 404 on not-found, 403 on RBAC/scope deny — both audited the same way. | #462 | sonnet | feat/federation-m3-verb-get | M3-03, M3-04 | 6K | `POST /api/federation/v1/get/:resource/:id`. Mirrors list controller patterns. |
|
||||
| FED-M3-07 | done | `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`. Read-only enumeration: returns `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope. Always allowed for an active grant — no RBAC eval. | #462 | sonnet | feat/federation-m3-verb-capabilities | M3-03 | 4K | `GET /api/federation/v1/capabilities`. Smallest verb; useful sanity check that mTLS + auth guard work end-to-end. |
|
||||
| FED-M3-08 | done | `apps/gateway/src/federation/client/federation-client.service.ts`. Outbound mTLS dialer: picks `(certPem, sealed clientKey)` from `federation_peers`, unwraps key, builds undici Agent with mTLS, calls peer verb, parses typed response, wraps non-2xx into `FederationClientError`. | #462 | sonnet | feat/federation-m3-client | M3-01 | 8K | Independent of server stream — can land in parallel with M3-03/04. Cert/key cached per-peer; flushed by future M5/M6 logic. |
|
||||
| FED-M3-09 | done | `apps/gateway/src/federation/client/query-source.service.ts`. Accepts `source: "local" \| "federated:<host>" \| "all"` from gateway query layer; for `"all"` fans out to local + each peer in parallel; merges results; tags every row with `_source`. | #462 | sonnet | feat/federation-m3-query-source | M3-08 | 8K | Per-peer failure surfaces as `_partial: true` in response, not hard failure (sets up M5 offline UX). M5 adds caching + circuit breaker on top. |
|
||||
| FED-M3-10 | not-started | Integration tests for MILESTONES.md M3 acceptance #6 (malformed OIDs → 401; valid cert + revoked grant → 403) and #7 (`max_rows_per_query` cap). Real PG, mocked TLS context (Fastify req shim). | #462 | sonnet | feat/federation-m3-integration | M3-05, M3-06 | 8K | Vitest profile gated by `FEDERATED_INTEGRATION=1`. Single-gateway suite; no harness required. |
|
||||
| FED-M3-11 | not-started | E2E tests for MILESTONES.md M3 acceptance #1, #2, #3, #4, #5, #8, #9, #10 (8 cases). Uses harness from M3-02; two real gateways, real Step-CA, real mTLS. Each test asserts both happy-path response and audit/no-persist invariants. | #462 | sonnet | feat/federation-m3-e2e | M3-02, M3-04, M3-05, M3-06, M3-09 | 12K | Largest single task. Each acceptance gets its own `it(...)` for clear failure attribution. |
|
||||
| FED-M3-12 | not-started | Independent security review (sonnet, not author of M3-03/04/05/06/07/08/09): focus on cert-SAN spoofing, OID extraction edge cases, scope-bypass via filter manipulation, RBAC-bypass via subjectUser swap, response leakage when scope deny. | #462 | sonnet | feat/federation-m3-security-review | M3-11 | 10K | Two review rounds budgeted. PRD requires explicit test for every 401/403 path — review verifies coverage. |
|
||||
| FED-M3-13 | not-started | Docs update: `docs/federation/SETUP.md` mTLS handshake section, new `docs/federation/HARNESS.md` for federation-harness usage, OID reference table in SETUP.md, scope enforcement pipeline diagram. Runbook still M7-deferred. | #462 | haiku | feat/federation-m3-docs | M3-12 | 5K | One ASCII diagram for the auth-guard → scope → RBAC pipeline; helps future reviewers reason about denial paths. |
|
||||
| FED-M3-14 | not-started | PR aggregate close, CI green, merge to main, close #462. Release tag `fed-v0.3.0-m3`. Update mission manifest M3 row → done; M4 row → in-progress when work begins. | #462 | sonnet | chore/federation-m3-close | M3-13 | 3K | Same close pattern as M1-12 / M2-13. |
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------------ | ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| FED-M3-01 | not-started | `packages/types/src/federation/` — request/response DTOs for `list`, `get`, `capabilities` verbs. Wire-format zod schemas + inferred TS types. Includes `FederationRequest`, `FederationListResponse<T>`, `FederationGetResponse<T>`, `FederationCapabilitiesResponse`, error envelope, `_source` tag. | #462 | sonnet | feat/federation-m3-types | — | 4K | Reusable from gateway server + client + harness. Pure types — no I/O, no NestJS. |
|
||||
| FED-M3-02 | not-started | `tools/federation-harness/` scaffold: `docker-compose.two-gateways.yml` (Server A + Server B + step-CA), `seed.ts` (provisions grants, peers, sample tasks/notes/credentials per scope variant), `harness.ts` helper (boots stack, returns typed clients). README documents harness use. | #462 | sonnet | feat/federation-m3-harness | DEPLOY-04 (soft) | 8K | Falls back to local docker-compose if `mos-test-1/-2` not yet redeployed (DEPLOY chain blocked on IMG-FIX). Permanent test infra used by M3+. |
|
||||
| FED-M3-03 | not-started | `apps/gateway/src/federation/server/federation-auth.guard.ts` (NestJS guard). Validates inbound client cert from Fastify TLS context, extracts `grantId` + `subjectUserId` from custom OIDs, loads grant from DB, asserts `status='active'`, attaches `FederationContext` to request. | #462 | sonnet | feat/federation-m3-auth-guard | M3-01 | 8K | Reuses OID parsing logic mirrored from `ca.service.ts` post-issuance verification. 401 on malformed/missing OIDs; 403 on revoked/expired/missing grant. |
|
||||
| FED-M3-04 | not-started | `apps/gateway/src/federation/server/scope.service.ts`. Pipeline: (1) resource allowlist + excluded check, (2) native RBAC eval as `subjectUserId`, (3) scope filter intersection (`include_teams`, `include_personal`), (4) `max_rows_per_query` cap. Pure service — DB calls injected. | #462 | sonnet | feat/federation-m3-scope-service | M3-01 | 10K | Hardest correctness target in M3. Reuses `parseFederationScope` (M2-03). Returns either `{ allowed: true, filter }` or structured deny reason for audit. |
|
||||
| FED-M3-05 | not-started | `apps/gateway/src/federation/server/verbs/list.controller.ts`. Wires AuthGuard → ScopeService → tasks/notes/memory query layer; applies row cap; tags rows with `_source`. Resource selector via path param. | #462 | sonnet | feat/federation-m3-verb-list | M3-03, M3-04 | 6K | Routes: `POST /api/federation/v1/list/:resource`. No body persistence. Audit write deferred to M4. |
|
||||
| FED-M3-06 | not-started | `apps/gateway/src/federation/server/verbs/get.controller.ts`. Single-resource fetch by id; same pipeline as list. 404 on not-found, 403 on RBAC/scope deny — both audited the same way. | #462 | sonnet | feat/federation-m3-verb-get | M3-03, M3-04 | 6K | `POST /api/federation/v1/get/:resource/:id`. Mirrors list controller patterns. |
|
||||
| FED-M3-07 | not-started | `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`. Read-only enumeration: returns `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope. Always allowed for an active grant — no RBAC eval. | #462 | sonnet | feat/federation-m3-verb-capabilities | M3-03 | 4K | `GET /api/federation/v1/capabilities`. Smallest verb; useful sanity check that mTLS + auth guard work end-to-end. |
|
||||
| FED-M3-08 | not-started | `apps/gateway/src/federation/client/federation-client.service.ts`. Outbound mTLS dialer: picks `(certPem, sealed clientKey)` from `federation_peers`, unwraps key, builds undici Agent with mTLS, calls peer verb, parses typed response, wraps non-2xx into `FederationClientError`. | #462 | sonnet | feat/federation-m3-client | M3-01 | 8K | Independent of server stream — can land in parallel with M3-03/04. Cert/key cached per-peer; flushed by future M5/M6 logic. |
|
||||
| FED-M3-09 | not-started | `apps/gateway/src/federation/client/query-source.service.ts`. Accepts `source: "local" \| "federated:<host>" \| "all"` from gateway query layer; for `"all"` fans out to local + each peer in parallel; merges results; tags every row with `_source`. | #462 | sonnet | feat/federation-m3-query-source | M3-08 | 8K | Per-peer failure surfaces as `_partial: true` in response, not hard failure (sets up M5 offline UX). M5 adds caching + circuit breaker on top. |
|
||||
| FED-M3-10 | not-started | Integration tests for MILESTONES.md M3 acceptance #6 (malformed OIDs → 401; valid cert + revoked grant → 403) and #7 (`max_rows_per_query` cap). Real PG, mocked TLS context (Fastify req shim). | #462 | sonnet | feat/federation-m3-integration | M3-05, M3-06 | 8K | Vitest profile gated by `FEDERATED_INTEGRATION=1`. Single-gateway suite; no harness required. |
|
||||
| FED-M3-11 | not-started | E2E tests for MILESTONES.md M3 acceptance #1, #2, #3, #4, #5, #8, #9, #10 (8 cases). Uses harness from M3-02; two real gateways, real Step-CA, real mTLS. Each test asserts both happy-path response and audit/no-persist invariants. | #462 | sonnet | feat/federation-m3-e2e | M3-02, M3-09 | 12K | Largest single task. Each acceptance gets its own `it(...)` for clear failure attribution. |
|
||||
| FED-M3-12 | not-started | Independent security review (sonnet, not author of M3-03/04/05/06/07/08/09): focus on cert-SAN spoofing, OID extraction edge cases, scope-bypass via filter manipulation, RBAC-bypass via subjectUser swap, response leakage when scope deny. | #462 | sonnet | feat/federation-m3-security-review | M3-11 | 10K | Two review rounds budgeted. PRD requires explicit test for every 401/403 path — review verifies coverage. |
|
||||
| FED-M3-13 | not-started | Docs update: `docs/federation/SETUP.md` mTLS handshake section, new `docs/federation/HARNESS.md` for federation-harness usage, OID reference table in SETUP.md, scope enforcement pipeline diagram. Runbook still M7-deferred. | #462 | haiku | feat/federation-m3-docs | M3-12 | 5K | One ASCII diagram for the auth-guard → scope → RBAC pipeline; helps future reviewers reason about denial paths. |
|
||||
| FED-M3-14 | not-started | PR aggregate close, CI green, merge to main, close #462. Release tag `fed-v0.3.0-m3`. Update mission manifest M3 row → done; M4 row → in-progress when work begins. | #462 | sonnet | chore/federation-m3-close | M3-13 | 3K | Same close pattern as M1-12 / M2-13. |
|
||||
|
||||
**M3 estimate:** ~100K tokens (vs MILESTONES.md 40K — same per-task breakdown pattern as M1/M2: tests, review, and docs split out from implementation cost). Largest milestone in the federation mission.
|
||||
|
||||
@@ -118,10 +118,6 @@ Goal: Two federated gateways exchange real data over mTLS. Inbound requests pass
|
||||
|
||||
**Test bed fallback:** If `mos-test-1.woltje.com` / `mos-test-2.woltje.com` are still blocked on `FED-M2-DEPLOY-IMG-FIX` when M3-11 is ready to run, the harness's local `docker-compose.two-gateways.yml` is a sufficient stand-in. Production-host validation moves to M7 acceptance suite (PRD AC-12).
|
||||
|
||||
**Backlog sync — 2026-06-24 (orchestrator):** Status reconciled against `origin/main` (release 0.0.48). Landed on main: **FED-M3-01** (DTOs, PR #506), **FED-M3-02** (harness scaffold, PR #505), **FED-M3-03** (mTLS auth-guard, PR #509 — CRIT-1/2 + HIGH-1..4 remediated in-PR), **FED-M3-08** (outbound mTLS client, PR #508). With M3-01/03/08 merged, three cards became dependency-clear and were dispatched to the idle coder lane: **FED-M3-04** scope.service → coder0 (`feat/federation-m3-scope-service`); **FED-M3-09** query-source + **FED-M3-07** capabilities verb → coder1 (`feat/federation-m3-query-source` first). Reviewer warmed for the M3 trust-boundary PRs. Remaining blocked-by-DAG: M3-05/06 (await M3-04), M3-10 (await M3-05/06), M3-11 (await M3-09), M3-12→14 (tail). Deploy chain (DEPLOY-IMG-FIX → 03/04) still independent of M3 code — harness local docker-compose fallback covers M3-11.
|
||||
|
||||
**Backlog sync #2 — 2026-06-24 (orchestrator):** **FED-M3-09** (query-source) merged via PR #673 and **FED-M3-07** (capabilities) merged via PR #674 — both squash-merged on independent agent review-of-record + green CI (formal Gitea approve unavailable under the shared service account; merge is not gated by the self-approve guard). **FED-M3-05** (list verb) dispatched to coder1 (based on the M3-04 branch, rebase onto main once #672 lands). **FED-M3-04** (scope.service, PR #672) is in review-changes (one include_personal no-leak test outstanding). **DAG fix:** corrected `FED-M3-11` depends_on from `M3-02, M3-09` → `M3-02, M3-04, M3-05, M3-06, M3-09` — the E2E acceptance cases (#1–#5, #8–#10) exercise list/get over mTLS, so the server verbs + scope service are hard prerequisites; the original edge set omitted them and caused a premature M3-11 dispatch. Note: M3 read-path invariant for M3-11 is **no-persist + existing enrollment audit only** — read-verb audit-log writes are deferred to M4 (see M3-05/06 notes), so M3-11 must not assert read-audit-log entries.
|
||||
|
||||
## Milestone 4 — search + audit + rate limit (FED-M4)
|
||||
|
||||
_Deferred. Issue #463._
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
# Fleet Launch Runbook
|
||||
|
||||
How every Mosaic fleet agent — workers **and** the orchestrator — is launched, and how to
|
||||
configure each one. The guiding principle: **one roster-driven launcher**. There is no bespoke
|
||||
per-agent launch script; the roster plus per-agent `.env` files are the single source of launch
|
||||
config.
|
||||
|
||||
## The launch chain
|
||||
|
||||
| Layer | File | Responsibility |
|
||||
| ---------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| systemd unit | `mosaic-agent@<role>.service` | One templated unit per role; `ExecStart` runs the session launcher with the instance name `%i`. Defaults `MOSAIC_AGENT_RUNTIME=pi`, `MOSAIC_AGENT_NAME=%i`. |
|
||||
| session launcher | `tools/fleet/start-agent-session.sh <role>` | Builds the launch command, opens the tmux pane, wires the heartbeat. |
|
||||
| launch command | `mosaic yolo <runtime>` (or a per-agent override) | Replaces the pane's foreground process with the runtime, fully seeded. |
|
||||
| seeding | `mosaic`'s `composeContract()` | Injects the Constitution/USER/TOOLS/runtime contract, `*.local` overlays, **and** the Fleet-Comms cheat-sheet — all via `--append-system-prompt`. |
|
||||
|
||||
Per-agent overrides live in `fleet/agents/<role>.env`, generated from `roster.yaml` by
|
||||
`generateAgentEnv` (`packages/mosaic/src/commands/fleet.ts`) and consumed by the launcher.
|
||||
|
||||
## Worker launch path (default)
|
||||
|
||||
1. `roster.yaml` carries each agent's `runtime` and optional `model_hint`.
|
||||
2. `generateAgentEnv` emits `fleet/agents/<role>.env` with `MOSAIC_AGENT_NAME`,
|
||||
`MOSAIC_AGENT_RUNTIME`, and `MOSAIC_AGENT_MODEL`.
|
||||
3. `start-agent-session.sh` has no `MOSAIC_AGENT_COMMAND` set, so it falls through to the default
|
||||
(line ~44):
|
||||
```sh
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}"
|
||||
```
|
||||
4. The launcher bakes `MOSAIC_AGENT_NAME` into the pane command (line ~118), so `composeContract`
|
||||
can inject the Fleet-Comms cheat-sheet for that role.
|
||||
|
||||
That is the whole worker path: roster → `.env` → `mosaic yolo <runtime>` → seeded pane.
|
||||
|
||||
## Orchestrator fold (PATH A — ships today)
|
||||
|
||||
The orchestrator is **just another roster agent** launched through the canonical path — not a
|
||||
snowflake script.
|
||||
|
||||
| Piece | Value |
|
||||
| ------------------ | ----------------------------------- |
|
||||
| host-side launcher | `orchestrator-launch.sh` |
|
||||
| systemd unit | `mosaic-fleet-orchestrator.service` |
|
||||
| tmux session | `orchestrator` (role-named) |
|
||||
|
||||
Set its launch command via `fleet/agents/orchestrator.env`:
|
||||
|
||||
```sh
|
||||
MOSAIC_AGENT_COMMAND='mosaic yolo claude --channels plugin:discord@<channel>'
|
||||
```
|
||||
|
||||
When `MOSAIC_AGENT_COMMAND` is set, `start-agent-session.sh`'s `if [ -z "$MOSAIC_AGENT_COMMAND" ]`
|
||||
guard (line ~41) is false, so the line-44 default — **including its hardcoded `yolo`** — is skipped
|
||||
entirely. The override fully controls the runtime and flags. Routing through `mosaic yolo claude`
|
||||
(rather than a raw `claude` invocation) is what gives the orchestrator the same full
|
||||
`composeContract` seeding + Fleet-Comms cheat-sheet as every worker, with `--channels` and any
|
||||
other flags passed straight through to the `claude` binary.
|
||||
|
||||
## Launch gotchas
|
||||
|
||||
1. **Flag conflict.** `mosaic yolo claude` already injects `--dangerously-skip-permissions`. Do
|
||||
**not** also pass `--permission-mode bypassPermissions` — the `claude` binary would receive both.
|
||||
Use `mosaic yolo claude …` alone (yolo covers the unattended posture), **or** non-yolo
|
||||
`mosaic claude --permission-mode bypassPermissions …`. Never mix the two.
|
||||
2. **`MOSAIC_AGENT_NAME` must reach the pane.** The launcher bakes it from the instance name, and
|
||||
`composeContract` gates the Fleet-Comms block on it (`launch.ts`, in `composeContract`) — **and**
|
||||
the role must be a member of `roster.yaml`, or the block resolves empty.
|
||||
3. **`launchRuntime` guards.** `mosaic yolo claude` runs `checkSoul` / `checkRuntime` /
|
||||
`checkSequentialThinking`. The host needs `SOUL.md` and the sequential-thinking MCP, or the
|
||||
launch aborts (a raw `claude` invocation skipped these checks). Dry-run the composed command in a
|
||||
throwaway tmux session before swapping a live launcher.
|
||||
|
||||
## Why per-agent `.env` survives upgrades (#632)
|
||||
|
||||
`install.sh` `PRESERVE_PATHS` includes `fleet/*.yaml`, `fleet/agents`, and `fleet/run`, so
|
||||
`mosaic update`'s framework re-seed **preserves** your roster and per-agent `.env` overrides
|
||||
(glob-aware `cp` fallback; matching TS parity in `file-adapter.ts`). Before #632, an auto re-seed
|
||||
could wipe them — which is exactly why PATH A's `.env` override is safe to rely on now.
|
||||
|
||||
## Inspecting the comms wiring
|
||||
|
||||
- `mosaic fleet comms-block <role>` prints the Fleet-Comms cheat-sheet a given role receives at
|
||||
launch — its `[host:session]` identity, the exact `agent-send.sh` command for each peer, and the
|
||||
FLIP / `--verify` conventions. `--host <h>` previews a cross-host view. An unknown role or missing
|
||||
roster **fails loud** (stderr + non-zero exit), so a typo is never a silent no-op.
|
||||
- Versus `mosaic compose-contract <runtime>`: that emits the **whole** system prompt and reads the
|
||||
role from `MOSAIC_AGENT_NAME` (a full-prompt smoke test). `comms-block` is the targeted,
|
||||
explicit-arg, comms-only view — e.g. `mosaic fleet comms-block coder0-0` to preview a peer.
|
||||
|
||||
## North Star / future direction
|
||||
|
||||
**Vision:** a webUI lets the user edit each agent's launch config — switch **harness**
|
||||
(claude / pi / codex / opencode), toggle **yolo**, pick a **model**, set a **command/channels**
|
||||
override — with no terminal.
|
||||
|
||||
**Continuity — this is not a new launch path.** It is a data-model + UI-binding layer over the
|
||||
existing roster-driven launcher. Field-by-field status today:
|
||||
|
||||
| Launch-config field | Roster-native today? | Mechanism / gap |
|
||||
| ------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **harness** (`runtime`) | ✅ end-to-end | `roster.runtime` → `generateAgentEnv` emits `MOSAIC_AGENT_RUNTIME` → launcher line 44. UI just writes the field. |
|
||||
| **model** (`model_hint`) | ✅ end-to-end | `roster.model_hint` → `MOSAIC_AGENT_MODEL` → launcher line 44 `--model`. UI just writes the field. |
|
||||
| **yolo** | ❌ new | Launcher line 44 **hardcodes** `mosaic yolo`. A non-yolo toggle needs a roster `yolo` field → emit `MOSAIC_AGENT_YOLO` → make line 44 conditional. |
|
||||
| **command / channels** | ❌ new | `MOSAIC_AGENT_COMMAND` is **consumed** (launcher line ~12) but `generateAgentEnv` does not emit it. Needs a roster `command`/`channels` field → emitted. |
|
||||
|
||||
**The arc:**
|
||||
|
||||
- **A** — `.env` `MOSAIC_AGENT_COMMAND` hatch: manual, ships now, kept safe across upgrades by #632.
|
||||
- **B** — roster-native launch-config: harness + model are already there; add the **yolo** toggle
|
||||
(line-44 conditional) and **command/channels** emission to complete the data model.
|
||||
- **webUI** — binds dropdowns/toggles directly to those four roster fields.
|
||||
|
||||
PATH A's `.env` override is the **manual form** of exactly what PATH B makes roster-native and the
|
||||
webUI edits — one continuous arc, not three separate features. PATH B is tracked as #636.
|
||||
@@ -1,79 +0,0 @@
|
||||
# Mosaic Fleet — NORTH STAR
|
||||
|
||||
> **Generated file — do not edit by hand.**
|
||||
> Projected deterministically from [`NORTH_STAR.yaml`](./NORTH_STAR.yaml) by the pure
|
||||
> generator in `packages/mosaic/src/commands/fleet.ts` (`renderNorthStarMarkdown`).
|
||||
> Edit the YAML, then regenerate. Self-contained Mosaic — no Hermes dependency.
|
||||
|
||||
## Mission
|
||||
|
||||
A self-driving Mosaic system that 24/7 unattended converts a machine-readable goal set into merged, CI-green, budget-bounded change — looping plan→backlog→assign→execute→verify→merge→reassess — on Mosaic's OWN native backlog/dispatch engine. Mosaic is general-purpose: the user declares the system type they want (software delivery, personal assistant, research, business/operations, …) and the orchestrator provisions the matching persona roster and structure; the delivery fleet is one profile among many.
|
||||
|
||||
## Substrate
|
||||
|
||||
The Mosaic Backlog is the backlog of record + dispatch engine, built on Mosaic's native Postgres storage service (@mosaicstack/db drizzle; PGlite-embedded by default, full Postgres by config). NOT Hermes.
|
||||
|
||||
## Standing objectives
|
||||
|
||||
- **NS-1** — Single machine-readable source (this file) drives planning; prose docs are projections.
|
||||
- **NS-2** — Every backlog item is an independently-shippable unit with stable id, priority, depends_on DAG, represented as a Mosaic Backlog card; spend tracked as advisory projection.
|
||||
- **NS-3** — The supervisor guarantees movement: no idle agent while ready dependency-satisfied work exists; no empty backlog without a replan request; assignment via Mosaic native dispatch/claim.
|
||||
- **NS-4** — Exactly one merge-gate approver; nothing reaches main except via pr-merge.sh after pr-ci-wait.sh success; Gitea branch protection is the backstop.
|
||||
- **NS-5** — Every unit bounded by wall-clock TTL on its claim; token caps enforced only where a real meter exists, else advisory.
|
||||
- **NS-6** — Context cleared between tasks for ephemeral runners (reset_between_tasks); persona+mission re-injected per task.
|
||||
- **NS-7** — Meta-loop (session-review + enhancer) continuously proposes small fleet-improvement PRs.
|
||||
- **NS-8** — Single operator-flippable PAUSE kill-switch (fleet/run/PAUSED) honored before every dispatch and every merge.
|
||||
- **NS-9** — Mosaic is a general-purpose multi-agent system: the user declares the SYSTEM TYPE to run (e.g. software delivery, personal assistant, research, business/operations) and the orchestrator provisions the matching persona roster and org structure from a cross-domain baseline persona library; the delivery/coding fleet is one profile among many.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- **AC-NS-1** — The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer) healthy across reboot.
|
||||
- **AC-NS-2** — A goal added to this YAML is decomposed to cards and either merged or escalated, with no human in the loop.
|
||||
- **AC-NS-3** — No PR merges with failure/error/no-status/timeout CI, and none bypass pr-merge.sh.
|
||||
- **AC-NS-4** — TTL is enforced on claims; token caps remain advisory until a real meter exists.
|
||||
- **AC-NS-5** — Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
|
||||
- **AC-NS-6** — A user can declare a system type and the fleet provisions the matching persona roster + topology from the baseline library, with no code change.
|
||||
- **AC-NS-7** — A user-customized persona (edited or added via the orchestrator) survives `mosaic update`: baseline reseed never clobbers user overrides.
|
||||
|
||||
## Workstreams
|
||||
|
||||
| id | title |
|
||||
| --- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| A | Substrate — Mosaic Backlog on native Postgres storage service |
|
||||
| B | Supervisor — movement guarantee, two-agent floor, dispatch/claim |
|
||||
| C | Planner — goal decomposition into independently-shippable cards |
|
||||
| D | Merge-gate — single approver, pr-merge.sh after CI wait |
|
||||
| E | Meta-loop — session-review + enhancer improvement PRs |
|
||||
| F | Safety-rails — TTL claims, advisory spend, PAUSE kill-switch |
|
||||
| H | Personas & system profiles — cross-domain library, system-type provisioning, update-surviving customization |
|
||||
|
||||
## Goals (backlog projection)
|
||||
|
||||
| id | title | phase | priority | depends_on |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ---------- |
|
||||
| A1 | Machine-readable NORTH_STAR.yaml + Markdown projection | 1 | must-have | — |
|
||||
| A2 | Mosaic Backlog schema + storage-service card store (drizzle/PGlite) | 1 | must-have | A1 |
|
||||
| A3a | Card lifecycle — create/claim/release with stable ids + depends_on DAG | 1 | must-have | A2 |
|
||||
| A3b | TTL-bounded claim enforcement (wall-clock) on cards | 1 | must-have | A3a |
|
||||
| A4 | Advisory spend projection per card (degrades to TTL, no real meter) | 1 | should-have | A3a |
|
||||
| B1 | Supervisor tick — readiness scan, two-agent-floor health check | 2 | must-have | A3a |
|
||||
| B2 | Native dispatch/claim — assign ready dependency-satisfied work | 2 | must-have | A3b, B1 |
|
||||
| B3a | Planner decompose — goal added to YAML → cards | 2 | must-have | A2, B1 |
|
||||
| B3b | Replan request on empty backlog; escalate on no-decompose | 2 | should-have | B3a |
|
||||
| G1 | PAUSE kill-switch + merge-gate honored before dispatch and merge | 2 | must-have | B2 |
|
||||
| H1 | Cross-domain baseline persona library (exec, marketing, ops, research, assistant + engineering roles) | 1 | must-have | A1 |
|
||||
| H2 | System-type profiles — declarative mapping of system type to persona roster + topology | 2 | must-have | H1 |
|
||||
| H3 | System-type provisioning — user declares type; orchestrator instantiates the matching roster + structure | 2 | must-have | H2 |
|
||||
| H4 | Update-surviving persona customization — ad-hoc edits/additions persisted in a PRESERVE-protected override layer (baseline merged with overrides) | 2 | must-have | H1 |
|
||||
|
||||
## Assumptions (vetoable)
|
||||
|
||||
- **ASM-1** (vetoable) — The Mosaic Backlog on the native Postgres storage service is the backlog of record.
|
||||
- **ASM-2** (vetoable) — Claude gate roles have no native busy status, so readiness = pane-idle + heartbeat.
|
||||
- **ASM-3** (vetoable) — Two-agent floor = 1 orchestrator + >=1 enhancer.
|
||||
- **ASM-4** (vetoable) — Baseline personas ship in framework/fleet/roles/ (reseeded on update); user overrides live in a separate PRESERVE_PATHS-protected layer and win on merge.
|
||||
|
||||
## Spend
|
||||
|
||||
- **advisory:** true
|
||||
- No per-task token meter yet; budgets degrade to TTL. Spend is tracked only as an advisory projection alongside each card.
|
||||
@@ -1,215 +0,0 @@
|
||||
# Mosaic Fleet — NORTH_STAR (machine-readable source of truth)
|
||||
#
|
||||
# This file is the single machine-readable source of truth for fleet planning.
|
||||
# Prose docs (including NORTH_STAR.md) are deterministic PROJECTIONS of this file.
|
||||
# Regenerate the Markdown projection with the pure generator in
|
||||
# packages/mosaic/src/commands/fleet.ts (renderNorthStarMarkdown). Edit the YAML,
|
||||
# never the .md.
|
||||
#
|
||||
# Self-contained Mosaic. NO Hermes runtime dependency. The backlog of record is
|
||||
# the Mosaic Backlog on Mosaic's OWN native Postgres storage service.
|
||||
|
||||
version: 1
|
||||
|
||||
mission: >-
|
||||
A self-driving Mosaic system that 24/7 unattended converts a machine-readable
|
||||
goal set into merged, CI-green, budget-bounded change — looping
|
||||
plan→backlog→assign→execute→verify→merge→reassess — on Mosaic's OWN native
|
||||
backlog/dispatch engine. Mosaic is general-purpose: the user declares the
|
||||
system type they want (software delivery, personal assistant, research,
|
||||
business/operations, …) and the orchestrator provisions the matching persona
|
||||
roster and structure; the delivery fleet is one profile among many.
|
||||
|
||||
substrate:
|
||||
note: >-
|
||||
The Mosaic Backlog is the backlog of record + dispatch engine, built on
|
||||
Mosaic's native Postgres storage service (@mosaicstack/db drizzle;
|
||||
PGlite-embedded by default, full Postgres by config). NOT Hermes.
|
||||
|
||||
standing_objectives:
|
||||
- id: NS-1
|
||||
text: >-
|
||||
Single machine-readable source (this file) drives planning; prose docs are
|
||||
projections.
|
||||
- id: NS-2
|
||||
text: >-
|
||||
Every backlog item is an independently-shippable unit with stable id,
|
||||
priority, depends_on DAG, represented as a Mosaic Backlog card; spend
|
||||
tracked as advisory projection.
|
||||
- id: NS-3
|
||||
text: >-
|
||||
The supervisor guarantees movement: no idle agent while ready
|
||||
dependency-satisfied work exists; no empty backlog without a replan
|
||||
request; assignment via Mosaic native dispatch/claim.
|
||||
- id: NS-4
|
||||
text: >-
|
||||
Exactly one merge-gate approver; nothing reaches main except via
|
||||
pr-merge.sh after pr-ci-wait.sh success; Gitea branch protection is the
|
||||
backstop.
|
||||
- id: NS-5
|
||||
text: >-
|
||||
Every unit bounded by wall-clock TTL on its claim; token caps enforced
|
||||
only where a real meter exists, else advisory.
|
||||
- id: NS-6
|
||||
text: >-
|
||||
Context cleared between tasks for ephemeral runners
|
||||
(reset_between_tasks); persona+mission re-injected per task.
|
||||
- id: NS-7
|
||||
text: >-
|
||||
Meta-loop (session-review + enhancer) continuously proposes small
|
||||
fleet-improvement PRs.
|
||||
- id: NS-8
|
||||
text: >-
|
||||
Single operator-flippable PAUSE kill-switch (fleet/run/PAUSED) honored
|
||||
before every dispatch and every merge.
|
||||
- id: NS-9
|
||||
text: >-
|
||||
Mosaic is a general-purpose multi-agent system: the user declares the
|
||||
SYSTEM TYPE to run (e.g. software delivery, personal assistant, research,
|
||||
business/operations) and the orchestrator provisions the matching persona
|
||||
roster and org structure from a cross-domain baseline persona library; the
|
||||
delivery/coding fleet is one profile among many.
|
||||
|
||||
success_criteria:
|
||||
- id: AC-NS-1
|
||||
text: >-
|
||||
The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer)
|
||||
healthy across reboot.
|
||||
- id: AC-NS-2
|
||||
text: >-
|
||||
A goal added to this YAML is decomposed to cards and either merged or
|
||||
escalated, with no human in the loop.
|
||||
- id: AC-NS-3
|
||||
text: >-
|
||||
No PR merges with failure/error/no-status/timeout CI, and none bypass
|
||||
pr-merge.sh.
|
||||
- id: AC-NS-4
|
||||
text: >-
|
||||
TTL is enforced on claims; token caps remain advisory until a real meter
|
||||
exists.
|
||||
- id: AC-NS-5
|
||||
text: >-
|
||||
Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
|
||||
- id: AC-NS-6
|
||||
text: >-
|
||||
A user can declare a system type and the fleet provisions the matching
|
||||
persona roster + topology from the baseline library, with no code change.
|
||||
- id: AC-NS-7
|
||||
text: >-
|
||||
A user-customized persona (edited or added via the orchestrator) survives
|
||||
`mosaic update`: baseline reseed never clobbers user overrides.
|
||||
|
||||
workstreams:
|
||||
- id: A
|
||||
title: Substrate — Mosaic Backlog on native Postgres storage service
|
||||
- id: B
|
||||
title: Supervisor — movement guarantee, two-agent floor, dispatch/claim
|
||||
- id: C
|
||||
title: Planner — goal decomposition into independently-shippable cards
|
||||
- id: D
|
||||
title: Merge-gate — single approver, pr-merge.sh after CI wait
|
||||
- id: E
|
||||
title: Meta-loop — session-review + enhancer improvement PRs
|
||||
- id: F
|
||||
title: Safety-rails — TTL claims, advisory spend, PAUSE kill-switch
|
||||
- id: H
|
||||
title: Personas & system profiles — cross-domain library, system-type provisioning, update-surviving customization
|
||||
|
||||
goals:
|
||||
- id: A1
|
||||
title: Machine-readable NORTH_STAR.yaml + Markdown projection
|
||||
phase: 1
|
||||
priority: must-have
|
||||
depends_on: []
|
||||
- id: A2
|
||||
title: Mosaic Backlog schema + storage-service card store (drizzle/PGlite)
|
||||
phase: 1
|
||||
priority: must-have
|
||||
depends_on: [A1]
|
||||
- id: A3a
|
||||
title: Card lifecycle — create/claim/release with stable ids + depends_on DAG
|
||||
phase: 1
|
||||
priority: must-have
|
||||
depends_on: [A2]
|
||||
- id: A3b
|
||||
title: TTL-bounded claim enforcement (wall-clock) on cards
|
||||
phase: 1
|
||||
priority: must-have
|
||||
depends_on: [A3a]
|
||||
- id: A4
|
||||
title: Advisory spend projection per card (degrades to TTL, no real meter)
|
||||
phase: 1
|
||||
priority: should-have
|
||||
depends_on: [A3a]
|
||||
- id: B1
|
||||
title: Supervisor tick — readiness scan, two-agent-floor health check
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: [A3a]
|
||||
- id: B2
|
||||
title: Native dispatch/claim — assign ready dependency-satisfied work
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: [A3b, B1]
|
||||
- id: B3a
|
||||
title: Planner decompose — goal added to YAML → cards
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: [A2, B1]
|
||||
- id: B3b
|
||||
title: Replan request on empty backlog; escalate on no-decompose
|
||||
phase: 2
|
||||
priority: should-have
|
||||
depends_on: [B3a]
|
||||
- id: G1
|
||||
title: PAUSE kill-switch + merge-gate honored before dispatch and merge
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: [B2]
|
||||
- id: H1
|
||||
title: Cross-domain baseline persona library (exec, marketing, ops, research, assistant + engineering roles)
|
||||
phase: 1
|
||||
priority: must-have
|
||||
depends_on: [A1]
|
||||
- id: H2
|
||||
title: System-type profiles — declarative mapping of system type to persona roster + topology
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: [H1]
|
||||
- id: H3
|
||||
title: System-type provisioning — user declares type; orchestrator instantiates the matching roster + structure
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: [H2]
|
||||
- id: H4
|
||||
title: Update-surviving persona customization — ad-hoc edits/additions persisted in a PRESERVE-protected override layer (baseline merged with overrides)
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: [H1]
|
||||
|
||||
assumptions:
|
||||
- id: ASM-1
|
||||
vetoable: true
|
||||
text: >-
|
||||
The Mosaic Backlog on the native Postgres storage service is the backlog
|
||||
of record.
|
||||
- id: ASM-2
|
||||
vetoable: true
|
||||
text: >-
|
||||
Claude gate roles have no native busy status, so readiness = pane-idle +
|
||||
heartbeat.
|
||||
- id: ASM-3
|
||||
vetoable: true
|
||||
text: 'Two-agent floor = 1 orchestrator + >=1 enhancer.'
|
||||
- id: ASM-4
|
||||
vetoable: true
|
||||
text: >-
|
||||
Baseline personas ship in framework/fleet/roles/ (reseeded on update);
|
||||
user overrides live in a separate PRESERVE_PATHS-protected layer and win
|
||||
on merge.
|
||||
|
||||
spend:
|
||||
advisory: true
|
||||
note: >-
|
||||
No per-task token meter yet; budgets degrade to TTL. Spend is tracked only
|
||||
as an advisory projection alongside each card.
|
||||
@@ -1,109 +0,0 @@
|
||||
# PRD — Mosaic Fleet Suite (init, configure, operate)
|
||||
|
||||
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312` · **Phase:** 3→4 productization
|
||||
> **North star:** [docs/fleet/north-star.md](./north-star.md) · prior: Phase-2 observability (#579), durable launch (#581), real-agent enablement (#583/#584/#586), releases 0.0.35–0.0.37
|
||||
> **Lead:** Jarvis @ `w-jarvis`. **Collaborator:** coder agent @ `dragon-lin` (jwoltje@10.1.10.37:coder0-0).
|
||||
> Owner of this file: Fleet workstream lead. Does not modify MVP single-writer control-plane files.
|
||||
|
||||
## Mission
|
||||
|
||||
Turn the proven fleet primitives into a **user-installable, AI-free-configurable fleet product**:
|
||||
a user runs `mosaic fleet init`, answers a few questions (general / coding / research / hybrid),
|
||||
gets a recommended set of agents plus one always-on orchestrator wired for chat-ops, and can
|
||||
operate, mutate, re-create, and observe the fleet — over tmux today and Matrix tomorrow — from
|
||||
CLI/TUI and (designed-for) the webUI.
|
||||
|
||||
**Immediate tangible goal:** the **"Mos"** orchestrator agent running on `w-jarvis`, reachable
|
||||
in **Discord channel `1517622518662434996`** (server `1112631390438166618`). Once the fleet is
|
||||
functional, we use the fleet itself to continue the work.
|
||||
|
||||
## Requirements
|
||||
|
||||
### A. Configure-without-AI CLI
|
||||
|
||||
| ID | Requirement |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| R1 | `mosaic fleet` command set is functional end-to-end (init/install/start/stop/status/ps/verify + agent verbs). |
|
||||
| R2 | `mosaic fleet init` is an interactive, **AI-free** CLI wizard. |
|
||||
| R3 | Init asks the **configuration type**: `general`, `coding`, `research`, `hybrid`, … (extensible). |
|
||||
| R4 | Based on the answer, the fleet is populated with a **recommended set of agents** (a preset). |
|
||||
| R5 | **Exactly one main orchestrator agent** is always configured, regardless of type. |
|
||||
| R10 | A set of **recommended configurations (presets)** ships for easy duplication. |
|
||||
| R8 | User can **re-create** the fleet when config needs change (idempotent re-init / reconfigure). |
|
||||
| R17 | Fleet controls are **simple and intuitive**. |
|
||||
|
||||
### B. Comms & orchestrator chat-ops
|
||||
|
||||
| ID | Requirement |
|
||||
| --- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| R6 | Init can wire the orchestrator to a chat connector — **Telegram / Discord / Matrix / Slack** — for command + comms. |
|
||||
| R7 | Designed with the end-goal of **Matrix comms on a locally-controlled server**. |
|
||||
| R16 | Fleet supports **tmux AND Matrix** comms, **user-configurable** at init or any time. Not all users want Matrix. |
|
||||
| R19 | **"Mos" orchestrator on Discord** (`chan 1517622518662434996` / `srv 1112631390438166618`) on `w-jarvis` — the first live target. |
|
||||
|
||||
### C. Runtime, health, lifecycle
|
||||
|
||||
| ID | Requirement |
|
||||
| --- | ---------------------------------------------------------------------------------- |
|
||||
| R9 | Fleet is **mutable by the orchestrator agent** — add/remove agents per need. |
|
||||
| R13 | Fleet **gracefully handles Pi + Claude harness updates** — keep harnesses current. |
|
||||
| R14 | The **Pi harness is customized** for proper tool usage, etc. |
|
||||
| R15 | **Agent heartbeat** properly configured for **Claude AND GPT/Pi** agents. |
|
||||
|
||||
### D. Surfaces, testing, docs
|
||||
|
||||
| ID | Requirement |
|
||||
| --- | ----------------------------------------------------------------------------------- |
|
||||
| R18 | Fleet built so the **webUI can view / monitor / terminate / butt-in** on a session. |
|
||||
| R11 | Installed and **tested on both `w-jarvis` and `dragon-lin`**. |
|
||||
| R12 | **Documentation**: how to install, configure, and use the fleet. |
|
||||
|
||||
## Architecture / approach
|
||||
|
||||
- **Config model:** `roster.yaml` is the source of truth (already exists). Add **presets** (`general`/`coding`/`research`/`hybrid`) as shipped example rosters; `init` selects a preset, always injects the orchestrator, and writes the roster. Re-init = regenerate roster (preserve user/site overrides — mirrors install env-merge from #567).
|
||||
- **Orchestrator agent:** always present; carries the chat connector config (connector type + target IDs) so it can be commanded over chat. tmux is the substrate; the connector bridges chat ↔ the orchestrator session.
|
||||
- **Comms layers (R16):** (1) **tmux** inter-agent (`agent-send`, proven) — default, always available. (2) **chat connector** for human↔orchestrator (Discord now; Matrix the strategic target). (3) **Matrix** as the locally-controlled cross-agent bus (future). Connector is pluggable + reconfigurable.
|
||||
- **Heartbeat (R15):** runtime-agnostic launcher sidecar already covers pi/claude/codex (#584). Refine per-runtime (native HB) with the **custom Pi harness** (R14) + a Claude path.
|
||||
- **Updates (R13):** `mosaic update` (CLI) + a fleet-aware harness-update step that refreshes pi/claude/codex and re-launches agents safely (drain → update → relaunch via the durable launcher).
|
||||
- **webUI (R18):** the fleet exposes machine-readable state (`fleet ps --json` already carries tenant/host/heartbeat/managed) + control verbs (start/stop/watch/send); webUI consumes these (control plane rides federation per north star). Ensure a stable JSON contract + a terminate/attach(butt-in) path.
|
||||
|
||||
## Phases (incremental, each shippable)
|
||||
|
||||
| Phase | Deliverable | Notes |
|
||||
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
|
||||
| **F1 Presets + init wizard** | preset rosters (general/coding/research/hybrid) + always-orchestrator + AI-free `fleet init` selecting a preset; re-init idempotent | R1–R5, R8, R10, R17 |
|
||||
| **F2 Connector + Mos-on-Discord** | orchestrator chat-connector config (Discord first) + **Mos live on Discord `1517…`/`1112…`** on w-jarvis | R6, R19, partial R16 |
|
||||
| **F3 Heartbeat + harness** | HB confirmed for claude + pi/gpt; **custom Pi harness** (tool usage, native HB, model self-report); graceful harness updates | R13, R14, R15 |
|
||||
| **F4 Matrix + comms toggle** | Matrix connector (local server) + user toggle tmux/Matrix at init/anytime | R7, R16 |
|
||||
| **F5 Orchestrator-mutable fleet** | orchestrator can add/remove agents at runtime | R9 |
|
||||
| **F6 webUI hooks** | stable JSON contract + terminate/attach surface for webUI view/monitor/terminate/butt-in | R18 |
|
||||
| **F7 Test + docs** | install+test on w-jarvis AND dragon-lin; user docs (install/configure/use) | R11, R12 (runs alongside every phase) |
|
||||
|
||||
## Work division (proposed — confirm with dragon-lin)
|
||||
|
||||
- **Jarvis @ w-jarvis (Lead):** F1 presets+wizard, F2 connector+Mos-on-Discord, F5 mutability, F6 webUI hooks; merge authority + dual-engine reviews; co-testing on w-jarvis.
|
||||
- **coder @ dragon-lin:** F3 custom Pi harness + harness-update flow (pi/codex-savvy); plus its in-flight constitution P4–P6 (P4 installer rework underpins `fleet init`/updates — coordinate the install path). Co-testing on dragon-lin (R11).
|
||||
- **Shared:** F4 Matrix (whoever has bandwidth); F7 testing/docs continuous.
|
||||
|
||||
## Immediate target: Mos on Discord (F2 first slice)
|
||||
|
||||
The discord plugin is available (`~/.claude.json`). Path: configure the **orchestrator** as a durable
|
||||
fleet session running Claude Code with the discord plugin bridged to channel `1517622518662434996`
|
||||
(server `1112631390438166618`) on w-jarvis, with the existing Discord Bridge Protocol (ack within
|
||||
~3s, reply via `mcp__discord__reply`, no `AskUserQuestion`). Heartbeat via the launcher sidecar.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- A non-AI user can `mosaic fleet init`, pick a type, and get a working fleet + orchestrator.
|
||||
- **Mos answers in Discord `1517…`** on w-jarvis.
|
||||
- Fleet runs + is observable (`fleet ps`) on **both** w-jarvis and dragon-lin.
|
||||
- Harness updates handled gracefully; HB healthy for claude + pi/gpt agents.
|
||||
- Docs let a new operator install/configure/use the fleet.
|
||||
- Re-init + orchestrator mutation work.
|
||||
|
||||
## Assumptions (veto-able)
|
||||
|
||||
- `ASSUMPTION:` presets ship as example rosters under the framework (`fleet/examples/*.yaml`), selected by `init`.
|
||||
- `ASSUMPTION:` chat connectors are pluggable; Discord first (target exists), Matrix is the strategic default later.
|
||||
- `ASSUMPTION:` "Mos" = a Claude Code orchestrator session with the discord plugin (reuses the documented Discord Bridge Protocol).
|
||||
- `ASSUMPTION:` per north star, runtimes default to Codex/pi-on-Codex for workers; the orchestrator "Mos" runs Claude Code (in Claude Code, which is allowed).
|
||||
@@ -1,109 +0,0 @@
|
||||
# PRD — Fleet Phase 2: Operator Observability
|
||||
|
||||
> **Workstream:** W-FLEET under `mvp-20260312` · **Phase:** 2
|
||||
> **North star:** [docs/fleet/north-star.md](./north-star.md)
|
||||
> **Source umbrella PRD:** [docs/PRD.md](../PRD.md) (Mosaic Stack v0.1.0)
|
||||
> **Tracks task:** `fleet-observability-1` — restore operator observability into fleet agent sessions.
|
||||
|
||||
## Problem
|
||||
|
||||
The durable tmux fleet runs on the isolated `mosaic-fleet` socket. That isolation
|
||||
(which protects the operator's default tmux) makes the fleet **invisible** to default
|
||||
tooling, and truth is split across three planes no single command joins — systemd
|
||||
(`systemctl --user`), tmux (`-L mosaic-fleet`), and the process tree (`pstree`).
|
||||
`agent tail` (`capture-pane`) returns **blank for full-screen TUIs**, and `agent send`
|
||||
confirms only keystroke injection, not acceptance. Net: the operator has near-zero
|
||||
observability and no safe way to watch a session.
|
||||
|
||||
## Goals
|
||||
|
||||
1. One command shows the **whole fleet's** real state, joining all three planes.
|
||||
2. **Liveness is truthful**: healthy = answered a heartbeat, not "pane alive".
|
||||
3. The operator can **watch** any session read-only without disrupting it.
|
||||
4. `send` reports **delivered-and-accepted**, not just injected.
|
||||
5. Every record/address carries **`tenant_id` + `host`** (zero foreclosure for multi-tenant/multi-host).
|
||||
|
||||
## Non-goals (this phase)
|
||||
|
||||
- No webUI (Phase 5; rides federation for cross-host).
|
||||
- No `fleetd` daemon or persistent history store.
|
||||
- No real-runtime swap (Phase 3) — instrument the live **dogfood stub** fleet.
|
||||
- No cross-host aggregation yet (addressing is host-tagged but queries stay local).
|
||||
|
||||
## Functional requirements
|
||||
|
||||
| ID | Requirement |
|
||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| FR-1 | `mosaic fleet ps [--json]` prints one row per roster agent joining: name · tenant · host · runtime · systemd(active/enabled) · pane(alive/dead) · pid · idle · **last-heartbeat age** · **drift** flag (roster runtime ≠ actual pane command) · **boot-enable** warning (active but `UnitFileState=disabled`). |
|
||||
| FR-2 | **Heartbeat protocol v1** (see below); `dogfood-agent.py` implements the responder. `fleet ps` issues probes (or reads last-seen) and reports health per FR-1. |
|
||||
| FR-3 | `mosaic agent watch <name>` opens a **read-only** view of the pane (grouped session or `tmux attach -r`) that cannot send keystrokes and does not shrink the agent's window. |
|
||||
| FR-4 | `mosaic agent attach <name>` remains the **explicit** interactive-takeover path (separate verb, documented as the only one that can type). |
|
||||
| FR-5 | `mosaic agent send <name> --verify` confirms the message was **accepted** (not left as an unsubmitted draft) and returns non-zero if delivery cannot be verified. |
|
||||
| FR-6 | All structured output (`--json`) includes `tenant_id` and `host` fields. |
|
||||
|
||||
## Heartbeat protocol v1
|
||||
|
||||
- **Probe:** operator/`fleet ps` writes a sentinel line to the agent's input or a
|
||||
well-known per-agent heartbeat file path `~/.config/mosaic/fleet/run/<agent>.hb`.
|
||||
- **Response:** the runtime updates `<agent>.hb` with `ts=<iso8601> pid=<pid> status=<ok|busy>`
|
||||
on a fixed interval (default 15s) and on demand when probed.
|
||||
- **Health rule:** `healthy` if `now - ts <= 3 × interval`; else `stale`; missing file = `unknown`.
|
||||
- **Contract:** every runtime (dogfood stub now; claude/codex/pi/opencode in Phase 3)
|
||||
MUST emit the heartbeat. The protocol is file-based so it works for headless stubs and
|
||||
full-screen TUIs alike (no `capture-pane` dependency).
|
||||
- `ASSUMPTION:` file-based heartbeat (vs in-pane echo) — chosen because it is TUI-safe and
|
||||
uid-scoped, fitting per-tenant isolation. Open to an OTEL-span variant in Phase 3 (MVP-X6).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `mosaic fleet ps` shows all 5 live sessions on `mosaic-fleet` with correct
|
||||
pane/pid/idle and flags the dogfood **drift** (`canary-pi` runtime=pi but pane runs
|
||||
`dogfood-agent.py`) and the **boot-enable** gap (active but disabled).
|
||||
- Killing one agent's pane flips its row to dead/stale within one `interval`.
|
||||
- `agent watch` shows live output and provably cannot type into the pane; detaching
|
||||
leaves the agent's window size unchanged.
|
||||
- `agent send --verify` returns success on an accepting pane and non-zero on a wedged/draft pane.
|
||||
- Quality gates green: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, plus
|
||||
`pnpm --filter @mosaicstack/mosaic test`.
|
||||
- Independent review passed; dogfood evidence captured against the live fleet.
|
||||
|
||||
## Test plan
|
||||
|
||||
- Unit/CLI specs in `packages/mosaic/src/commands/fleet.spec.ts` (and a new
|
||||
`fleet-ps`/`watch`/`send-verify` spec) using the injected `CommandRunner` to assert
|
||||
exact tmux/systemd command construction and JSON shape (tenant+host present).
|
||||
- Situational: run against the live `mosaic-fleet` fleet; capture `fleet ps` output,
|
||||
a kill-and-detect cycle, a read-only `watch`, and a `send --verify` pass/fail pair.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Verify heuristic is best-effort:** `agent send --verify` uses a `>` -prefix draft
|
||||
heuristic that is specific to pi/claude TUIs. Draft detection for codex and opencode
|
||||
TUIs is best-effort only; those runtimes may not use the same input-line indicator.
|
||||
- **Pane-change check is the best Phase-2 signal; verify now polls up to a bounded
|
||||
timeout:** `agent send --verify` captures a BEFORE snapshot, sends the message, then
|
||||
polls `capture-pane` every ~400 ms up to a configurable total timeout (default ~6 s,
|
||||
controlled by `--verify-timeout <ms>`). On each poll it runs classifySendResult: if
|
||||
the pane shows 'accepted' or 'draft' the loop exits immediately; while the result is
|
||||
'unverifiable' (no pane change yet) it keeps polling. After the timeout with no
|
||||
definitive result, it fails closed: exit 1 with "no pane change after send". This
|
||||
eliminates false 'unverifiable' failures for slow/loaded TUIs that were previously
|
||||
caused by the old fixed 300 ms single-capture. Definitive acceptance ultimately
|
||||
requires a runtime acknowledgement (Phase-3 heartbeat-ack); the bounded pane-change
|
||||
poll is the best signal available against an opaque TUI for Phase-2.
|
||||
- **Blank AFTER capture fails closed:** Full-screen TUIs (claude, codex, opencode, pi)
|
||||
render blank for `tmux capture-pane`. When the AFTER snapshot is empty, `send --verify`
|
||||
returns non-zero with an "unverifiable" message rather than silently succeeding. This
|
||||
is an intentional fail-closed design (FR-5).
|
||||
- **`agent watch` uses a grouped viewer session:** `tmux attach -r` directly against the
|
||||
agent session lets the viewer terminal shrink the agent's window. `agent watch` instead
|
||||
creates a throwaway grouped session (`tmux new-session -d -t '=<agent>' -s
|
||||
'<agent>-watch-<pid>'`), attaches read-only to that session, and kills it on detach.
|
||||
The grouped session shares the agent's windows but has independent sizing, so the
|
||||
agent's window is never affected. `tmux attach` is still interactive and requires
|
||||
inherited stdio; the `interactiveRunner` handles TTY passthrough.
|
||||
|
||||
## Surfaces & parity (MVP-X1)
|
||||
|
||||
CLI lands this phase. TUI surface follows in the `packages/mosaic` wizard; webUI in
|
||||
Phase 5 via federation. PRD records the parity debt explicitly so it is not lost.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Tasks — W-FLEET (Fleet) Phase 2: Observability
|
||||
|
||||
> Workstream task file for the Fleet. Single-writer: Fleet workstream lead (orchestrator).
|
||||
> Workers read but never modify. This is **not** the MVP rollup (`docs/TASKS.md`) — a
|
||||
> rollup row is proposed to the MVP orchestrator, not written here.
|
||||
>
|
||||
> Mission: `mvp-20260312` · PRD: [docs/fleet/PRD.md](./PRD.md) · North star: [docs/fleet/north-star.md](./north-star.md)
|
||||
> Status: `not-started` | `in-progress` | `done` | `blocked` | `failed`
|
||||
|
||||
| id | status | description | depends_on | agent | pr | notes |
|
||||
| ------------- | ----------- | ------------------------------------------------------------------------------------------------------------------ | --------------------- | ----------- | --- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| FLEET-OBS-000 | done | Plan: north-star + Phase-2 PRD + workstream scaffolding | — | lead | — | persisted 2026-06-20 on `feat/fleet-observability` |
|
||||
| FLEET-OBS-001 | done | Heartbeat protocol v1 spec finalized in PRD + framework doc | FLEET-OBS-000 | lead | — | file-based `~/.config/mosaic/fleet/run/<agent>.hb`; spec in PRD |
|
||||
| FLEET-OBS-002 | in-progress | Implement heartbeat responder in `dogfood-agent.py` | FLEET-OBS-001 | fleet-coder | — | dispatched to ad-hoc `mosaic yolo` fleet agent (dogfood) |
|
||||
| FLEET-OBS-003 | done | `mosaic fleet ps` — join systemd+tmux+proc+idle+heartbeat; tenant+host tagged; drift + boot-enable flags; `--json` | FLEET-OBS-001 | worker | — | commit ab47831; LIVE-verified on mosaic-fleet; caught canary-pi DRIFT + BOOT-ENABLE. Polish: idleSeconds parse returns null |
|
||||
| FLEET-OBS-004 | done | `mosaic agent watch <name>` — read-only join (no resize, no keystrokes) | FLEET-OBS-000 | worker | — | `attach -r`; verb wired |
|
||||
| FLEET-OBS-005 | done | `mosaic agent send --verify` — delivery/acceptance receipt | FLEET-OBS-000 | worker | — | --verify flag; draft-heuristic verify |
|
||||
| FLEET-OBS-006 | done | CLI specs for ps/watch/send-verify (tenant+host shape, command construction) | FLEET-OBS-003,004,005 | worker | — | 62 tests green (31 new); re-verified by lead |
|
||||
| FLEET-OBS-007 | not-started | Framework doc: fleet observability guide + verbs | FLEET-OBS-003,004,005 | lead | — | `docs/guides/` or `framework/tools/.../README` |
|
||||
| FLEET-OBS-008 | not-started | Independent review + dogfood verification on live fleet | FLEET-OBS-002..007 | reviewer | — | author ≠ reviewer; capture evidence in scratchpad |
|
||||
| FLEET-OBS-009 | not-started | Open PR → green CI (queue guard) → squash-merge → close `fleet-observability-1` | FLEET-OBS-008 | lead | — | trunk merge; no direct push to main |
|
||||
|
||||
## Proposed MVP rollup row (for the MVP orchestrator — not written by this workstream)
|
||||
|
||||
```
|
||||
| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |
|
||||
```
|
||||
@@ -1,138 +0,0 @@
|
||||
# Fleet Backlog Conventions
|
||||
|
||||
The **backlog** is Mosaic's native backlog-of-record for fleet work. It is built
|
||||
end-to-end on Mosaic's own storage layer (`@mosaicstack/db`, drizzle/Postgres)
|
||||
and surfaced as `mosaic fleet backlog <sub> --json`.
|
||||
|
||||
> **Mosaic-native, no Hermes.** This backlog REPLACES the former Hermes adapter.
|
||||
> There is **no** runtime dependency on Hermes, `hermes kanban`, or `~/.hermes`
|
||||
> anywhere in this feature. Anything previously delegated to Hermes is recreated
|
||||
> here on Mosaic's own Postgres storage layer.
|
||||
|
||||
## Storage tier — PGlite by default, Postgres by config
|
||||
|
||||
The backlog uses the existing Mosaic storage layer; there is **no** new database
|
||||
engine (no sqlite, no raw client).
|
||||
|
||||
| Condition | Tier | Data location |
|
||||
| ------------------------------ | -------------------- | -------------------------------- |
|
||||
| `DATABASE_URL` set | Full server Postgres | the configured database |
|
||||
| `PGLITE_DATA_DIR` set (no URL) | Embedded PGlite | that directory |
|
||||
| neither (default) | Embedded PGlite | `~/.config/mosaic/fleet/backlog` |
|
||||
|
||||
PGlite is real Postgres semantics in-process — including the row locks the atomic
|
||||
claim relies on — so the **same code** runs on a laptop (embedded, single-host
|
||||
default) and on a full Postgres deployment. Switching tiers is config-only.
|
||||
|
||||
The schema (`backlog` table) is created automatically on first CLI use:
|
||||
`runMigrations()` for Postgres, `runPgliteMigrations()` for embedded PGlite.
|
||||
|
||||
### Update safety
|
||||
|
||||
The embedded PGlite store lives under `~/.config/mosaic/fleet/backlog`, which is
|
||||
listed in `PRESERVE_PATHS` in `packages/mosaic/framework/install.sh`. This means
|
||||
`mosaic update` (which runs the framework sync with `rsync --delete`) will **not**
|
||||
wipe the operator's backlog — same protection as the roster, per-agent env, and
|
||||
heartbeat run dir.
|
||||
|
||||
## Card schema
|
||||
|
||||
A card is one row in the `backlog` table:
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ------------------- | ------------------- | ------------------------------------------------------------- |
|
||||
| `id` | text (PK) | Stable, caller-supplied id (e.g. `A4`, `fleet-001`). |
|
||||
| `title` | text | Required. |
|
||||
| `body` | text (nullable) | Free-form description. |
|
||||
| `phase` | text (nullable) | Board/phase grouping (see below). |
|
||||
| `priority` | int (default 0) | **Higher = sooner.** Claim picks the max-priority ready card. |
|
||||
| `status` | enum | `ready` \| `claimed` \| `blocked` \| `done`. |
|
||||
| `depends_on` | jsonb `string[]` | DAG edges — ids of cards this one depends on. |
|
||||
| `claim_owner` | text (nullable) | Owner token of the active claim. |
|
||||
| `claim_ttl_seconds` | int (nullable) | TTL of the active claim. |
|
||||
| `claimed_at` | timestamptz (null) | When the claim was taken. `claimed_at + ttl` = expiry. |
|
||||
| `attempts` | int (default 0) | Incremented each time the card is claimed. |
|
||||
| `idempotency_key` | text (unique, null) | Dedups `create`; NULLs are distinct in Postgres. |
|
||||
| `acceptance` | jsonb (nullable) | Acceptance criteria (array of strings or object). |
|
||||
| `created_at` | timestamptz | |
|
||||
| `updated_at` | timestamptz | |
|
||||
|
||||
`depends_on` is modeled as a `jsonb` array column rather than a separate edge
|
||||
table. Justification: it matches the repo's existing style (e.g. `tasks.tags`,
|
||||
`agents.skills`, `routing_rules.conditions` are all jsonb arrays), keeps a card
|
||||
self-contained, and the DAG is small (per-card dependency lists), so a join table
|
||||
would add ceremony without benefit.
|
||||
|
||||
### Board / phase convention
|
||||
|
||||
`phase` is a free-form grouping string used as the board column / milestone label
|
||||
(e.g. `M1`, `fleet`, `infra`). `list --phase <phase>` filters to one board lane.
|
||||
`priority` orders cards **within** the ready pool regardless of phase.
|
||||
|
||||
## Status lifecycle
|
||||
|
||||
```
|
||||
create
|
||||
│
|
||||
▼
|
||||
┌──────► ready ───── claim ─────► claimed ───── complete ─────► done
|
||||
│ │ │
|
||||
│ block reclaim (TTL expiry or --id)
|
||||
│ ▼ │
|
||||
│ blocked └──────────────────────────┘ (back to ready)
|
||||
└──────────┘ (reclaim / re-create can return a card to ready)
|
||||
```
|
||||
|
||||
- **ready** — eligible to be claimed once every `depends_on` card is `done`.
|
||||
- **claimed** — a worker holds it; `claim_owner` + `claimed_at` set.
|
||||
- **blocked** — explicitly parked; never auto-claimed.
|
||||
- **done** — completed; satisfies dependents.
|
||||
|
||||
## Atomic claim (`FOR UPDATE SKIP LOCKED`) + TTL
|
||||
|
||||
`claim` is atomic. Inside a single transaction it locks candidate `ready` rows
|
||||
with `SELECT ... FOR UPDATE SKIP LOCKED` (via the drizzle `sql` operator), picks
|
||||
the highest-priority deps-satisfied card, and flips it to `claimed`. Because a row
|
||||
already locked by a concurrent claimer is **skipped**, two claimers can **never**
|
||||
both win the same card — the loser falls through to the next candidate or gets
|
||||
`null`. (Proven by the concurrency tests in `packages/db/src/backlog.spec.ts`.)
|
||||
|
||||
- **Deps gate:** a card is only claimable when every id in `depends_on` is `done`.
|
||||
- **TTL:** `claim --ttl <sec>` (default **900s**) records `claim_ttl_seconds`.
|
||||
- **reclaim:** releases claims whose `claimed_at + ttl` is in the past (expired)
|
||||
back to `ready`, clearing the claim fields. `reclaim --id <id>` force-releases a
|
||||
specific card regardless of expiry. This is how a crashed worker's card returns
|
||||
to the pool.
|
||||
|
||||
## CLI — `mosaic fleet backlog <sub> --json`
|
||||
|
||||
All subcommands support `--json`.
|
||||
|
||||
| Subcommand | Purpose |
|
||||
| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `create --id --title [--body --phase --priority --depends-on --acceptance --idempotency-key]` | Create a card; `idempotency_key` dedups (repeat returns the existing card). |
|
||||
| `list [--status --phase --ready-only]` | List cards. `--ready-only` = status `ready` AND all deps `done`. |
|
||||
| `claim --owner [--ttl <sec> --id <id>]` | Atomically claim the highest-priority ready card (or `--id`). Returns the card or `null`. |
|
||||
| `reclaim [--id <id>]` | Release expired claims (or a specific card) back to `ready`. |
|
||||
| `link --from --to` | Add a `depends_on` edge (`--from` depends on `--to`). |
|
||||
| `stats` | Counts by status, oldest-ready age, expired-claim count. |
|
||||
| `block --id` | Set a card to `blocked`. |
|
||||
| `complete --id` | Set a card to `done` (releases any claim). |
|
||||
|
||||
### Example
|
||||
|
||||
```sh
|
||||
# Seed two cards, the second depends on the first.
|
||||
mosaic fleet backlog create --id A1 --title "schema" --priority 5
|
||||
mosaic fleet backlog create --id A2 --title "service" --depends-on A1 --priority 9
|
||||
|
||||
# A2 is gated on A1, so claim returns A1 first.
|
||||
mosaic fleet backlog claim --owner worker-1 --ttl 600 --json
|
||||
|
||||
# Finish A1; now A2 is ready.
|
||||
mosaic fleet backlog complete --id A1
|
||||
mosaic fleet backlog list --ready-only --json
|
||||
|
||||
# Recover stalled work.
|
||||
mosaic fleet backlog reclaim --json
|
||||
```
|
||||
@@ -1,92 +0,0 @@
|
||||
# F4 — Orchestrator chat connector + Matrix (local homeserver)
|
||||
|
||||
> **Issue:** #616 · **Doctrine:** `docs/fleet/north-star.md` (#613) — orchestrator-chat-connector decision.
|
||||
> **Status:** Phase 1 (abstraction + scaffold) in this PR; Phase 2+ are follow-ups (below).
|
||||
|
||||
## Goal
|
||||
|
||||
The fleet **orchestrator** is the operator's single point of contact. The north-star makes the
|
||||
chat channel a **user-chosen connector** — tmux today, Discord live ("Mos"), with Matrix /
|
||||
Telegram / Slack configurable. F4 adds **Matrix** (local homeserver) as a **peer** connector and,
|
||||
first, the small **connector abstraction** that makes connectors pluggable without touching fleet
|
||||
core.
|
||||
|
||||
## The abstraction (Phase 1 — this PR)
|
||||
|
||||
Connectors implement one small, uniform interface (`src/fleet/connectors/types.ts`):
|
||||
|
||||
```ts
|
||||
interface OrchestratorConnector {
|
||||
readonly kind: 'tmux' | 'discord' | 'matrix';
|
||||
send(message: OutboundMessage): Promise<SendResult>; // orchestrator → human
|
||||
subscribe(handler: (m: InboundMessage) => void): Unsubscribe; // human → orchestrator
|
||||
health(): Promise<ConnectorHealth>; // reachable + authenticated
|
||||
}
|
||||
```
|
||||
|
||||
- **send / subscribe / health** — the only surface fleet core depends on. `SendResult` is the
|
||||
ack half; `health()` is the liveness half.
|
||||
- **Thread-aware by metadata** — `OutboundMessage.threadId` / `InboundMessage.threadId` are
|
||||
optional, so thread-capable connectors (Matrix rooms/threads, the future first-party Mosaic
|
||||
Discord plugin) fit **without an interface change**.
|
||||
- **Registry** (`registry.ts`) — implementations register a factory by kind; `createConnector(config)`
|
||||
resolves one from roster config. Phase 1 ships the registry + `resolveConnectorKind` (defaults
|
||||
`tmux` when a roster declares no connector — **back-compat**); the factories land in Phase 2.
|
||||
|
||||
### Config model
|
||||
|
||||
A roster may carry an optional `connector` block (`roster.schema.json`); absent ⇒ tmux.
|
||||
|
||||
```yaml
|
||||
connector:
|
||||
kind: matrix # tmux | discord | matrix
|
||||
matrix:
|
||||
homeserver_url: https://matrix.example.internal
|
||||
user_id: '@mos:example.internal'
|
||||
room_id: '!abc:example.internal'
|
||||
```
|
||||
|
||||
**Secrets are never in the roster.** `MATRIX_ACCESS_TOKEN` / `DISCORD_BOT_TOKEN` come from the
|
||||
environment (the gateway env-config pattern that already masks them). The sanitization gate would
|
||||
reject a token committed to a shipped file anyway.
|
||||
|
||||
## Matrix connector (Phase 2)
|
||||
|
||||
The connector speaks the **Matrix client-server API** directly over HTTPS (`fetch` — no SDK needed
|
||||
for MVP), so it is **homeserver-agnostic**:
|
||||
|
||||
| Op | Matrix CS-API |
|
||||
| ----------- | ------------------------------------------------------------------------ |
|
||||
| `send` | `PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}` |
|
||||
| `subscribe` | `GET /_matrix/client/v3/sync` (long-poll, `since` token) → room timeline |
|
||||
| `health` | `GET /_matrix/client/versions` (reachable) + `…/account/whoami` (authed) |
|
||||
| threads | `m.thread` relations ↔ `threadId` |
|
||||
|
||||
## Local homeserver (infra, not connector code)
|
||||
|
||||
Strategic default: a **self-hosted** homeserver on our own infra — no third-party gateway.
|
||||
|
||||
- **Default: Conduit** (Rust, single binary, low resource) — trivial to stand up for a fleet/dev
|
||||
homeserver.
|
||||
- **Alternative: Synapse** (mature, feature-complete) for scale.
|
||||
|
||||
The connector only needs `homeserver_url` + `user_id` + `room_id` + an access token, so the
|
||||
homeserver choice is a **deployment** concern (a Phase-2 deploy guide), not connector code.
|
||||
|
||||
## Phasing
|
||||
|
||||
| Phase | Scope | This PR |
|
||||
| ----- | --------------------------------------------------------------------------------------- | ------- |
|
||||
| **1** | Connector interface + types, registry + kind resolution, roster `connector` schema, doc | ✅ yes |
|
||||
| 2 | Matrix CS-API client (fetch-based send/sync/health) + registered factory + tests | follow |
|
||||
| 2 | `fleet init` / `configure` connector-selection UX; roster parse wires the block | follow |
|
||||
| 2 | systemd launch wiring so the orchestrator starts on the chosen connector | follow |
|
||||
| 3 | Conduit deploy guide; first-party Mosaic Discord (threads) registers as a connector | follow |
|
||||
|
||||
## Back-compat & boundaries
|
||||
|
||||
- Existing rosters (no `connector`) resolve to tmux — **zero change**.
|
||||
- Fleet core never branches on connector kind; it depends only on the interface.
|
||||
- Cross-host reach rides the **federation** layer (W1), not a bespoke broker (north-star assumption).
|
||||
- Phase 1 touches **no** `fleet.ts` core (a self-contained `connectors/` module), so it is
|
||||
independent of the in-flight fleet-config PRs.
|
||||
@@ -1,430 +0,0 @@
|
||||
# Mosaic Fleet — North Star
|
||||
|
||||
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312`
|
||||
> **Umbrella:** [docs/MISSION-MANIFEST.md](../MISSION-MANIFEST.md) · [docs/PRD.md](../PRD.md) (Mosaic Stack v0.1.0)
|
||||
> **Status:** doctrine — authored 2026-06-20. Owner of this file: Fleet workstream lead.
|
||||
> This document does **not** modify the MVP rollup; a rollup row is proposed, not written here.
|
||||
|
||||
## Vision
|
||||
|
||||
A **customizable, multi-tenant fleet of always-on AI agents** — each defined by role,
|
||||
materialized as a durable, joinable runtime session, coordinated by the proven
|
||||
orchestrator/worker model, and observable end-to-end across hosts. Coding today;
|
||||
finance, analytics, research as roster entries tomorrow — same primitives, different
|
||||
roster. The fleet is the **agent-session execution layer** of the Mosaic Stack MVP:
|
||||
the thing federation makes reachable across hosts and the webUI/TUI/CLI make visible.
|
||||
|
||||
The USC tmux PoC (durable sessions + `agent-send` comms) proved the model. This
|
||||
workstream makes it an official, observable, multi-tenant Mosaic Stack capability.
|
||||
|
||||
## The Fleet as means of production (bootstrapping)
|
||||
|
||||
The Fleet has a **dual role**, and that is the point:
|
||||
|
||||
- **As product** — a multi-tenant agent-fleet capability of Mosaic Stack (this workstream).
|
||||
- **As means of production** — the orchestrator/worker fleet that _actually builds the
|
||||
entire MVP_ (federation W1, webUI, TUI, CLI, and the Fleet itself).
|
||||
|
||||
We are **building the system that builds the system.** Every other MVP workstream is
|
||||
delivered _by_ the fleet, so fleet observability and control are not merely product
|
||||
features — they are the **operational floor of the whole delivery effort**. If we cannot
|
||||
see and steer the agents, we cannot trust what they ship. This is why Phase 2
|
||||
(observability) leads: it is the instrument panel for the factory, dogfooded on the live
|
||||
fleet that is, recursively, building Mosaic Stack.
|
||||
|
||||
The discipline that makes great power safe is the same gate chain the fleet enforces:
|
||||
independent review before merge, green CI, honest completion, decide-and-inform cadence,
|
||||
and no irreversible action without authority. The bootstrap is only as trustworthy as
|
||||
those gates.
|
||||
|
||||
## Alignment with MVP cross-cutting requirements
|
||||
|
||||
The Fleet inherits — does not re-invent — the MVP's hard requirements:
|
||||
|
||||
| MVP req | What it means for the Fleet |
|
||||
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| MVP-X1 three-surface parity | fleet observability/control reachable via **CLI + TUI + webUI** (CLI first; webUI is required for parity, not optional) |
|
||||
| MVP-X2 multi-tenant isolation | one tenant = one **Linux uid** (own `systemd --user`, socket, `~/.config/mosaic`); no cross-tenant leakage |
|
||||
| MVP-X3 auth (BetterAuth/SSO) | operator→fleet and cross-host views are auth-gated through the platform's existing auth |
|
||||
| MVP-X4 quality gates | `pnpm typecheck`/`lint`/`format:check` green before any push |
|
||||
| MVP-X5 federated topology | cross-host fleet visibility rides the **federation** boundary (W1), not a bespoke broker |
|
||||
| MVP-X6 OTEL tracing | heartbeats, sends, and lifecycle events emit spans; `traceparent` crosses the federation boundary |
|
||||
| MVP-X7 trunk merge | branch from `main`, squash-merge via PR, never push to `main` |
|
||||
|
||||
## The stack — where every concern lives
|
||||
|
||||
One **definition** is the source of truth; the **session** is how it runs.
|
||||
|
||||
| Layer | Owner | Phase-2 reality | Destination |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Definition + identity + auth** | gateway / `mosaic-as` (scoped tokens, #541) | `roster.yaml` (tenant-tagged) | one definition; `mosaic agent --new` materializes it |
|
||||
| **Tenancy boundary** | **Linux uid per tenant** (linger, own `systemd --user`, own socket, own `~/.config/mosaic`) | one tenant: `jarvis` = tenant zero | uid-per-tenant; federation aggregates across hosts |
|
||||
| **Runtime** | per-tenant tmux session on isolated socket | dogfood stub sessions (live now on `mosaic-factory`) | claude/codex/pi/opencode TUIs |
|
||||
| **Liveness** | **heartbeat protocol** every runtime answers | protocol defined + dogfood stub answers it | all runtimes answer; "healthy" ≠ "pane alive" |
|
||||
| **Observation** | read-only `watch` (native tmux) + `pipe-pane` stream | CLI `watch`/`ps`; explicit opt-in `attach` for control | + auth-gated webUI streams |
|
||||
| **Control plane** | **federation** across hosts × tenants | records already carry `tenant_id` + `host` | federated gateways expose fleet state; webUI in Phase 5 |
|
||||
| **Central register** | Postgres `fleet` schema (gateway instance); access via gateway API only | _none in PoC_ (files + `roster.yaml`) | agents, missions, tasks, heartbeats, spend — single network-accessible SSOT; docs = generated projections |
|
||||
| **Budget / spend governance** | **per-tenant budget policy** ingested by the orchestrator + routing layer | none today (spend is unmetered) | usage-vs-limit feedback ingested; spend auto-paced to the limit window; per-provider/per-account/concurrency/API-$ budgets enforced |
|
||||
|
||||
> **PoC socket hygiene:** the PoC fleet runs on the **default tmux socket** (no `-L`).
|
||||
> The named production-isolation socket is **`mosaic-fleet`** (matches the product brand);
|
||||
> an absent roster `socket_name` means the default socket everywhere (spawn, `fleet ps`,
|
||||
> onboarding cheat-sheet). The legacy dogfood canary still runs on the old `mosaic-factory`
|
||||
> socket pending migration.
|
||||
|
||||
## Operating model (inherited, not reinvented)
|
||||
|
||||
The AI-guide law stands: one accountable **orchestrator**, isolated **workers** that
|
||||
stop at PR-open, the serialized **gate chain** (independent review → green CI →
|
||||
diff-sanity → squash-merge → verify), **decide-and-inform** cadence, and a durable
|
||||
**board** so missions survive session death. The Fleet is the infrastructure _under_
|
||||
this model. See `mosaicstack-aiguide` whitepapers 01 (inter-agent comms) and 03
|
||||
(orchestration model) for the rationale.
|
||||
|
||||
## Fleet roster — the two-agent floor and the role library
|
||||
|
||||
A fleet is **never a single agent**. The minimum viable fleet is **two**:
|
||||
|
||||
| Role | Mandate | Boundaries |
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| **Orchestrator** | The user's **single point of contact**. Owns the general flow, keeps agentic actions on-target, and **adds/removes agents from the fleet at will** to meet goals and user needs. Exactly **one** per fleet (the existing R5 invariant). | Delegates source work; never the sole worker. |
|
||||
| **Enhancer** | The fleet's **continuous-improvement loop**. Monitors fleet activity, analyzes for enhancements/optimizations, builds a **plan of remediation**, and — **with the orchestrator** — upgrades fleet capability: tool creation/repair, skills, harness improvements, and **bug reports filed to Mosaic Stack** for proper remediation. Recommends which agents are needed. | **Does not code, review code, or perform delivery tasks.** Improvement and diagnosis only. |
|
||||
|
||||
> **Why two, not one:** the orchestrator drives delivery; the enhancer makes the fleet
|
||||
> _get better at delivering_ over time. The enhancer is how the fleet self-heals its tools,
|
||||
> skills, and harnesses, and how real defects flow back to Mosaic Stack as bug reports.
|
||||
> Together they are the irreducible core — every other role is added on demand.
|
||||
|
||||
A **general** fleet starts at this floor: the orchestrator (advised by the enhancer)
|
||||
materializes whatever roles prove necessary over the mission's life. Specialized presets
|
||||
(coding, research, etc.) seed additional roles up front, but all reduce to the same two-agent
|
||||
spine plus an on-demand **role library**:
|
||||
|
||||
| Role profile | Purpose |
|
||||
| ------------------- | --------------------------------------------------------------------------------- |
|
||||
| **orchestrator** | point of contact, flow control, fleet composition (1 per fleet) |
|
||||
| **enhancer** | fleet monitoring, optimization, tool/skill/harness upgrades, upstream bug reports |
|
||||
| **coder** | implementation (worker; stops at PR-open) |
|
||||
| **code review** | independent code review gate |
|
||||
| **security review** | security/auth/secret review gate |
|
||||
| **research** | investigation, synthesis, options analysis |
|
||||
| **board** | deliberation panel — moonshot, contrarian, technical, business, financial lenses |
|
||||
| **operations** | infra, deploy, health, incident response |
|
||||
| _…extensible_ | new profiles added as missions demand (orchestrator + enhancer decide) |
|
||||
|
||||
## Invariants — "maximal vision, incremental delivery, zero foreclosure"
|
||||
|
||||
Every artifact, starting Phase 2, MUST:
|
||||
|
||||
1. Carry **`tenant_id` + `host`** in schema and message addressing — even with one of each today.
|
||||
2. Treat **isolation socket ≠ invisibility** — anything isolated is surfaced by one command.
|
||||
3. Define **healthy = answered a heartbeat within N seconds**, never just "pane alive".
|
||||
4. Make **observation read-only by default**; control is an explicit, separate, opt-in verb.
|
||||
|
||||
> **OPS INVARIANT — runtime agents need a real TTY.** Claude/Codex/pi/opencode agents
|
||||
> cannot be bare-launched from a systemd `ExecStart`; a durable harness with a real PTY is
|
||||
> required. This is **why `start-agent-session.sh` launches into tmux** and uses a
|
||||
> `MOSAIC_AGENT_COMMAND` override rather than running the runtime directly under systemd.
|
||||
|
||||
## Budget & token governance (first-class fleet concern)
|
||||
|
||||
Spend is a fleet-level resource, not a per-agent afterthought. The fleet treats token
|
||||
and API-dollar budget the way it treats liveness: a signal every runtime exposes and the
|
||||
control plane is accountable for. This rides the same primitives as everything else —
|
||||
`tenant_id` + `host` on every spend record, **read-only metering by default**, and the
|
||||
**federation** layer as the cross-host aggregation point (W1) — so budgeting is zero-foreclosure
|
||||
from day one even while one tenant exists.
|
||||
|
||||
**Two spend regimes, one policy surface:**
|
||||
|
||||
| Regime | Feedback signal | Fleet obligation |
|
||||
| ------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
||||
| **OAuth-subscription runtimes** (Claude sub, Codex sub) | runtime exposes **current-usage-vs-limit** within a rolling limit window | **ingest** the signal per sub-account; **auto-pace** agentic spend so the window is not exhausted early |
|
||||
| **API-token runtimes** (metered per token) | provider billing / token counts | enforce **hard $-spend ceilings**; on breach, **downgrade → queue → refuse** (below) |
|
||||
|
||||
**Auto-pacing law (OAuth subs) — EVEN-SPREAD default (Jason override, 2026-06-22):** the fleet
|
||||
paces agentic token spend to consume the limit window **evenly over remaining time**:
|
||||
target rate = _(remaining usage available)_ ÷ _(remaining time in the window)_. Example: 100% of
|
||||
a 7-day window = **~14.285%/day**; the system tracks current usage and continuously re-splits the
|
||||
remainder evenly to hold pace. **Anticipated token-spend-per-task is the budgeting informant** —
|
||||
tasks are scheduled against the daily pace, not run until the quota is gone. Rationale: spreading
|
||||
delivery evenly beats rapidly exhausting usage and losing **multiple days of momentum**.
|
||||
**Rapid pacing / overspend requires EXPLICIT user authorization;** absent it, even-spread holds.
|
||||
Pacing is a control-plane decision, surfaced read-only before it throttles a lane.
|
||||
|
||||
**Hard-cap breach behavior (ladder):** when a budget ceiling is hit mid-work, the fleet
|
||||
**downgrades first** (opus → sonnet → haiku, then Claude → Codex), **queues** the lane at the
|
||||
cheapest floor until the window resets, and **refuses** only as a last resort. Refusal is never
|
||||
the first response to a breach.
|
||||
|
||||
**Spend accounting, learning & telemetry:**
|
||||
|
||||
- **Multi-subscription auto-routing:** a tenant with multiple subscriptions may let the fleet
|
||||
**auto-route work to the account with the most available usage** (within budget policy).
|
||||
- **Historical spend learning:** every task's token spend is **recorded**; historical data
|
||||
continuously updates known **spend-per-task**, **typical daily spend**, and projections — so
|
||||
estimates self-correct and pacing stays on target.
|
||||
- **Projected + actual spend on artifacts (Mosaic Stack mandate):** PRDs, missions, and task
|
||||
decomposition **MUST note projected AND actual token spend** — a Mosaic Stack process standard
|
||||
(template-level), tracked separately as **#622**.
|
||||
- **Anonymized telemetry → mosaicstack.dev:** spend data is reported (anonymous) to the
|
||||
mosaicstack.dev telemetry endpoint so other agents/fleets budget and optimize from real,
|
||||
anonymized data. Product workstream, tracked separately as **#623**.
|
||||
|
||||
**User-settable budgets (the policy surface).** A tenant operator can set budgets for every
|
||||
configured **provider** (per-provider ceilings), the **account-to-task mapping**, the **agentic
|
||||
routing flow**, **concurrency** (the spend multiplier), and **hard API-token $-limits**. Budgets
|
||||
are enforced at the orchestrator + routing boundary, not inside individual workers (a worker never
|
||||
decides its own budget — see delegation discipline).
|
||||
|
||||
**Budget CLI UX (#558):** `mosaic budget set --reset-at` sets the window reset; reset-datetimes
|
||||
carry **confidence tags** (`user` / `provider` / `estimated` / `unknown`); and **urgency/criticality
|
||||
is a dispatch-gate modifier** — high-urgency work may override even-spread pacing **within
|
||||
authorization**. (Also feeds the budgeting workstream, not only this doc.)
|
||||
|
||||
## Observation model
|
||||
|
||||
| Verb | Behavior |
|
||||
| ----------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `mosaic fleet ps` | one table joining systemd + tmux + process + idle + last-heartbeat, with drift + boot-enable flags |
|
||||
| `mosaic agent watch <name>` | **read-only** join (grouped session / `-r`), no resize tyranny, no keystrokes |
|
||||
| `mosaic agent attach <name>` | explicit interactive takeover (the only path that can type) |
|
||||
| `mosaic agent send <name> --verify` | confirms message **accepted**, not merely keystroke-injected |
|
||||
|
||||
> Why the current PoC blocks observation: sessions live on the isolated `mosaic-factory`
|
||||
> socket (invisible to default `tmux ls`), the only sanctioned read is `capture-pane`
|
||||
> (blank for full-screen TUIs), and `attach` is read-write + resizes the session. The
|
||||
> verbs above restore "join and observe" safely.
|
||||
|
||||
## Control plane & central register
|
||||
|
||||
### Why the register must be Postgres
|
||||
|
||||
The fleet is multi-host (w-jarvis + dragon-lin + future). A SQLite file is a local
|
||||
file — it is not a network service and cannot be shared across hosts. Beyond topology,
|
||||
Postgres MVCC eliminates the concurrent-writer corruption class Hermes hit with SQLite
|
||||
under multi-agent access.
|
||||
|
||||
Access is exclusively through the **gateway API** (`apps/gateway` — typed, auth-gated,
|
||||
scoped tokens). No agent or dispatcher pane ever holds a raw DB credential; a
|
||||
compromised pane cannot corrupt or exfiltrate the register.
|
||||
|
||||
### Architecture (layers)
|
||||
|
||||
| Layer | Responsibility | Implementation |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Register** | Source of truth: agents, missions, tasks, heartbeats, spend | Postgres `fleet` schema — existing stack instance (`@mosaicstack/db`) |
|
||||
| **Access** | Typed, auth-gated API | Gateway `fleet/*` routes |
|
||||
| **Dispatcher** | Brief classification, BOD review, planning/coding/review/test/deploy sequencing + gates → fleet task dispatch | **forge pipeline engine** (`runPipeline`/`resumePipeline`, brief classifier, BOD) **+ thin `forge-exec` adapter → `agent-send.sh`**; NOT a new daemon — forge is reused, only stage→agent dispatch is new |
|
||||
| **Orchestrator (Mos)** | Goals, missions, judgment, user/PA interface | Context-light; sets intent → re-engages only for decisions |
|
||||
|
||||
### Dispatcher = forge (reuse, do not rebuild)
|
||||
|
||||
The dispatcher is **not new work**: it is `@mosaicstack/forge`, a fully-implemented
|
||||
software-factory pipeline engine (brief → Board-of-Directors review → 3 planning stages →
|
||||
coding → review/remediation → testing → deploy). Forge already provides
|
||||
`runPipeline`/`resumePipeline`, a brief classifier, and a BOD persona loader, so the fleet
|
||||
does **not** re-implement sequencing, gate logic, or brief classification. The only new
|
||||
fleet-owned code is a thin **`forge-exec` TaskExecutor adapter** (`ForgeTask` →
|
||||
`agent-send.sh` to a named agent) — forge's single missing piece — tracked as a Gitea
|
||||
issue and built post-PoC. The Postgres register backs forge's pipeline state (durable
|
||||
`resumePipeline`, cross-host) in addition to cross-project missions/tasks/Kanban. The
|
||||
north-star **'board' role IS forge's Board-of-Directors** — reused from forge, not a new
|
||||
role implementation.
|
||||
|
||||
### Docs as projections
|
||||
|
||||
`docs/TASKS.md` and `MISSION-MANIFEST.md` are **generated projections** of the DB,
|
||||
not hand-maintained. The dispatcher (or a scheduled job) renders Markdown from
|
||||
`fleet.*` tables and commits the output. DB is authoritative; docs are for human
|
||||
reference.
|
||||
|
||||
### Spend
|
||||
|
||||
`fleet.spend_ledger` records projected and actual token spend per agent/mission/task
|
||||
(ties to issue #622). The dispatcher enforces budget caps before dispatching. Mos reads
|
||||
the roll-up via API — no raw DB access, no context-bloating dumps.
|
||||
|
||||
### Federation
|
||||
|
||||
Cross-host fleet state flows through federated gateway queries (existing
|
||||
`federation_peers` / `federation_grants` machinery). This is the existing north-star
|
||||
invariant: **control plane rides federation (W1), not a bespoke broker.** No new
|
||||
broker introduced.
|
||||
|
||||
### Scope
|
||||
|
||||
This is Phase 4–5 of this roadmap, materialized. It MUST NOT block the PoC (which
|
||||
runs correctly on files + `roster.yaml`). Begin when Phase 2 heartbeat protocol is
|
||||
stable and concurrent-agent count makes file coordination the bottleneck.
|
||||
|
||||
### Open sub-decision
|
||||
|
||||
Dedicated Postgres **instance** vs. dedicated **schema** in the existing instance.
|
||||
Recommendation: dedicated schema, existing instance (a migration file, not new infra);
|
||||
re-evaluate if isolation or write-volume demands it.
|
||||
|
||||
## Phased roadmap
|
||||
|
||||
| Phase | Outcome | Status |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| 0–1 | tmux PoC, hardening, published CLI v0.0.34 (#565–#568) | ✅ done |
|
||||
| **2 — Observability** | `fleet ps` (host+tenant aware join), heartbeat protocol + dogfood stub answers it, `agent watch` (read-only), `agent send --verify` receipts | ▶ now |
|
||||
| 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: **orchestrator + enhancer**; ephemeral workers per lane) | planned |
|
||||
| 4 — Unified definition | one agent schema in gateway; `mosaic agent --new` → materialized per-tenant session; uid-tenant provisioning; **`fleet` schema migration + `forge-exec` TaskExecutor adapter (forge → `agent-send.sh`)** | planned |
|
||||
| 5 — Control plane | federation-backed cross-host × cross-tenant fleet view; **webUI** (surface chosen then) for MVP-X1 parity; **central register live (spend ledger, docs-as-projections, multi-host Kanban)** | planned |
|
||||
|
||||
## Decisions of record (2026-06-20, with Jason)
|
||||
|
||||
- Agent model: **config defines, session runs** (gateway = definition/identity/auth; tmux = runtime).
|
||||
- Tenancy: **multi-tenant from the start**; isolation = **per-tenant Linux uid**.
|
||||
- Health: **heartbeat required** (dogfood stub implements the protocol now).
|
||||
- Lifecycle: **hybrid** — core always-on + ephemeral workers per lane.
|
||||
- Observation: **read-only default, opt-in takeover**.
|
||||
- Multi-host: **designed-for from day one**; control plane **rides federation (W1)**.
|
||||
- Delivery: **CLI-first now**, dogfood against the live stub fleet; webUI deferred to Phase 5.
|
||||
- Runtimes: fleet agents default to **Codex / pi-on-Codex**; **Claude is reserved for Claude
|
||||
Code only** (avoid alternate-harness API pricing). Validated durable recipe:
|
||||
`mosaic yolo pi --model openai-codex/gpt-5.5:high`. Durable detached launch requires the
|
||||
runtime-bin on PATH (baked into the pane command) + boot-survival (`enable` + linger),
|
||||
which `fleet init` should automate.
|
||||
|
||||
## Decisions of record (2026-06-22, with Jason)
|
||||
|
||||
- **Two-agent floor:** every fleet has, at minimum, an **orchestrator** and an **enhancer**.
|
||||
The orchestrator is the user's point of contact and composes the fleet; the enhancer runs the
|
||||
continuous-improvement loop (monitor → analyze → remediate → upgrade tools/skills/harness →
|
||||
file Mosaic Stack bug reports) and **does not code or review**.
|
||||
- **Role library:** orchestrator, enhancer, coder, code review, security review, research,
|
||||
board (moonshot/contrarian/technical/business/financial), operations — extensible; the
|
||||
orchestrator (advised by the enhancer) adds roles as missions demand.
|
||||
- **Orchestrator chat connector:** the orchestrator is reachable over a user-chosen connector
|
||||
(tmux now; Telegram/Discord/Matrix/Slack configurable). Validated live: **"Mos" orchestrator
|
||||
on Discord** via the Claude Code discord channel plugin (w-jarvis).
|
||||
- **Session context cap = 200k tokens (GLOBAL to all Claude sessions):** Claude Code sessions are
|
||||
capped at a **max 200k-token context window**. Long-running sessions extended toward 1M tokens
|
||||
have proven **worse in practice** (degraded steering, off-plan divergence); 200k is the standard.
|
||||
**Enforcement split:** the _window_ lives in **`~/.claude/settings.json`** (host-global) as
|
||||
`"autoCompactWindow": 200000` + `"autoCompactEnabled": true`; the _1M-disable_ lives in **launch
|
||||
ENV** (`CLAUDE_CODE_DISABLE_1M_CONTEXT=1`, plus `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000`) wherever
|
||||
a `[1m]` model can be selected (`mos-claude.service` + the fleet Claude launcher), so every Claude
|
||||
agent is capped at spawn. (settings = window; env = 1M-disable.)
|
||||
- **Worker context bound (#8):** workers are kept context-bounded via the **ephemeral-per-lane
|
||||
lifecycle + native compaction**, not via the 200k knob. The explicit `autoCompactWindow` 200k knob
|
||||
**stays Claude-specific** — the _principle_ (bounded context) extends to workers, the _knob_ does not.
|
||||
- **Orchestrator delegation discipline:** the orchestrator **delegates all delivery work** to
|
||||
subagents / workflows / ultracode / coder agents and confines its own context to \*\*orchestration
|
||||
- the personal-assistant lane\*\*. Keeping delivery out of the orchestrator's window keeps its
|
||||
context unpolluted and measurably reduces off-plan divergence. The orchestrator coordinates and
|
||||
decides; it does not implement.
|
||||
- **Budget governance is fleet doctrine:** token/API-dollar budgeting is a first-class fleet concern
|
||||
(see "Budget & token governance"). OAuth-sub usage-vs-limit feedback is ingested per account, spend
|
||||
is **auto-paced EVEN-SPREAD over remaining time** (rapid/overspend only on explicit authorization),
|
||||
spend is **tracked historically** to self-correct per-task/daily estimates, multi-sub tenants may
|
||||
**auto-route by available usage**, and operators set budgets per provider, per account-to-task
|
||||
mapping, per routing flow, per concurrency level, and as hard API-$ ceilings.
|
||||
- **Spend accounting is a Mosaic Stack process mandate:** PRDs, missions, and task decomposition
|
||||
**MUST carry projected + actual token spend**; used locally for pacing and reported as **anonymized
|
||||
telemetry to mosaicstack.dev**. The template standard (#622) and telemetry product (#623) are
|
||||
tracked separately.
|
||||
- **Unified identity = "Fleet" (Jason, 2026-06-22):** the product is **Mosaic Fleet** — one unified
|
||||
user-facing identity and CLI surface. **forge** is the Fleet's **internal** delivery/orchestration
|
||||
engine (not a separate product); the control-plane **Postgres register is the Fleet's register**;
|
||||
workers/runtime are the **Fleet substrate**. **"factory" is RETIRED as a product term** — it was
|
||||
only ever the software-factory concept (which forge implements) and the old `mosaic-factory` tmux
|
||||
socket name. The production-isolation socket is now **`mosaic-fleet`** (matches the product brand);
|
||||
the legacy dogfood canary remains on the old `mosaic-factory` socket pending migration. **Code stays
|
||||
layered** (forge + fleet + control-plane as internal layers);
|
||||
only the **identity + CLI surface unify under Fleet.**
|
||||
- **Role-based session naming (Jason, 2026-06-22):** agent tmux sessions are named by **role**
|
||||
(`orchestrator`, `enhancer`, `research`, `coder0-0`, …), not by persona. **Persona lives in
|
||||
`SOUL.md`**; the front-end / Discord presents a **friendly alias** (e.g. "Mos" = the orchestrator's
|
||||
alias). The session name is the stable addressing handle; the alias is presentation.
|
||||
|
||||
### Control plane & central register
|
||||
|
||||
- **Store:** Postgres (existing stack instance, dedicated `fleet` schema via `@mosaicstack/db`). SQLite rejected: (1) it is a local file — structurally incompatible with a multi-host fleet; (2) concurrent multi-agent writes caused repeated corruption in Hermes. "SQLite + access service" rejected as reinventing a DB server badly; "LLM agent gating DB access" rejected as slow, expensive, and a single point of failure.
|
||||
- **Access:** gateway API only (`apps/gateway`, `fleet/*` routes). No raw DB credentials in any agent/dispatcher pane — directly mitigates the tmux attack-surface concern.
|
||||
- **Dispatcher = forge (reuse, not a new build):** the dispatcher IS `@mosaicstack/forge`'s pipeline engine (`runPipeline`/`resumePipeline` + brief classifier + BOD persona loader), a fully-implemented software-factory pipeline (brief → BOD review → 3 planning stages → coding → review/remediation → testing → deploy). We do **not** design/build a new dispatcher and do **not** re-implement sequencing, gate logic, or brief classification. The only new fleet-owned piece is a thin **`forge-exec` TaskExecutor adapter** (suggested package `packages/forge-exec`) mapping a `ForgeTask` → `agent-send.sh` dispatch to a named fleet agent — forge's single missing piece. It is tracked as a Gitea issue and built **post-PoC** (not now).
|
||||
- **Register backs forge:** the Postgres `fleet` register is genuinely new (neither forge nor the fleet has cross-project state). It BACKS forge's pipeline state (durable `resumePipeline`, cross-host) plus cross-project missions/tasks/Kanban.
|
||||
- **'board' role = forge BOD:** the north-star role-library 'board' role IS forge's Board-of-Directors — reused, not reinvented.
|
||||
- **Orchestration vs. dispatch:** Orchestrator (Mos) sets intent and handles judgment; forge works the mechanical pipeline (sequencing, gates, status transitions, spend ledger). LLM escalation reserved for judgment: mission decomposition, re-planning on failure.
|
||||
- **Spend in the register:** `fleet.spend_ledger` tracks projected vs. actual tokens per agent/mission/task; ties to issue #622.
|
||||
- **Docs as projections:** `docs/TASKS.md` and `MISSION-MANIFEST.md` become generated exports of the DB, not hand-maintained.
|
||||
- **Sub-decision pending:** dedicated schema in existing PG instance (recommended) vs. dedicated PG instance. Revisit if isolation or write-volume demands it.
|
||||
|
||||
## Decisions of record (2026-06-24, with Jason)
|
||||
|
||||
- **Per-agent model switch (operator-configurable, NOT a global lock):** model selection is
|
||||
**per-agent**, never a host-global pin. Claude sessions MUST NOT be locked to a single model in
|
||||
`~/.claude/settings.json`; each agent chooses its model independently. The plumbing already exists —
|
||||
roster `model_hint` → `MOSAIC_AGENT_MODEL` → `start-agent-session.sh` appends `--model <hint>` to that
|
||||
agent's harness (claude or pi); settable today via `mosaic fleet add|edit <agent> --model <hint>`.
|
||||
**North-star target:** surface this as a **per-agent model switch in the webUI** (with CLI/TUI parity
|
||||
per MVP-X1) — read the roster, expose a per-agent model dropdown, write `model_hint` back, and restart
|
||||
that one agent to apply. Unset = inherit the harness default. This **composes with** the budget
|
||||
downgrade ladder (opus → sonnet → haiku, then Claude → Codex): the operator sets the per-agent model
|
||||
_intent/ceiling_; budget pacing may downgrade within policy. Tracked as a Fleet `TASKS.md` entry under
|
||||
the Phase-5 webUI surface.
|
||||
- **Orchestrator runtime (confirmed live):** the **orchestrator and enhancer run Claude Opus 4.8 in the
|
||||
Claude Code harness**; only workers (coder/reviewer) run pi/gpt-5.5. Consistent with the 2026-06-20
|
||||
"Claude reserved for Claude Code only" decision (the orchestrator runs _in_ Claude Code, not an
|
||||
alternate Claude harness). Pi/gpt-5.5 as the orchestrator is permitted **only if proven** at least as
|
||||
satisfactory; absent that proof, the orchestrator stays on Claude Opus 4.8.
|
||||
|
||||
## Future enhancements (north-star, post-MVP — not on the MVP track)
|
||||
|
||||
- **Mosaic Claude Discord Plugin** — a first-party Mosaic Discord connector that properly
|
||||
implements the basic Discord functions **and native Discord threads**. Threads let a user
|
||||
separate conversation topics with the orchestrator (the pattern proven by the Hermes agent).
|
||||
A major enhancement over the current third-party channel plugin; **not required for the MVP**,
|
||||
but a committed north-star target. `ASSUMPTION:` ships as a Mosaic-owned plugin so the fleet
|
||||
controls Discord UX (threads, reactions, attachments, per-thread context) end-to-end.
|
||||
- **Matrix on a local homeserver — strategic future transport.** **F4 (in progress) IS the Matrix
|
||||
connector**: an orchestrator chat connector speaking the Matrix client-server API against a
|
||||
self-hosted homeserver (Conduit default, Synapse alt). Matrix is named here as the strategic
|
||||
future transport — peer to tmux/Discord, not superseded by them.
|
||||
- **tmux fleet attack-surface hardening.** Many always-on tmux sessions are an attack surface;
|
||||
`tmux send-keys` / socket access could enable malicious action against agents directly.
|
||||
Mitigations to build toward: socket ownership/perms, per-tenant socket isolation (already an
|
||||
invariant), authenticated `agent-send`, and an audit of who can write to any pane. **Post-MVP
|
||||
unless a P0 surfaces.** The control-plane register reinforces this (gateway-API access = no raw
|
||||
DB creds in panes). A not-started risk-assessment + mitigation-plan task rides the Fleet `TASKS.md`.
|
||||
|
||||
## Assumptions (veto-able)
|
||||
|
||||
- `ASSUMPTION:` first-class runtimes = claude, codex, pi, opencode; a "role" (analyst,
|
||||
finance, researcher) = persona + skills + tools on top of a runtime, shipped as a
|
||||
starter role library in the framework.
|
||||
- `ASSUMPTION:` the cross-host control plane is the **federation** layer (W1), not a
|
||||
separate `fleetd` daemon.
|
||||
- `ASSUMPTION:` Fleet is workstream **W-FLEET** under `mvp-20260312`; a rollup row in
|
||||
`docs/TASKS.md` and a workstream declaration in `MISSION-MANIFEST.md` are proposed to
|
||||
the MVP orchestrator, not written by this workstream.
|
||||
- `ASSUMPTION:` OAuth-subscription runtimes (Claude sub, Codex sub) expose a machine-readable
|
||||
current-usage-vs-limit signal the fleet can poll/ingest; if a provider exposes no such signal,
|
||||
that provider's accounts fall back to API-style hard-ceiling budgeting only (no auto-pacing).
|
||||
- `ASSUMPTION:` budget policy lives at the orchestrator + routing layer and is surfaced through the
|
||||
same CLI→TUI→webUI parity (MVP-X1) as the rest of fleet state — not a separate budgeting daemon.
|
||||
- `ASSUMPTION:` the 200k session cap is enforced by Claude Code settings/env composition (model
|
||||
variant + `autoCompactWindow`), not by a Mosaic wrapper; a wrapper is the fallback only if the
|
||||
harness later removes those knobs.
|
||||
- `ASSUMPTION:` The central register (Postgres `fleet` schema + gateway API + forge as dispatcher) is
|
||||
the Phase 4–5 control plane, begun after Phase 2 observability is proven. It is a dedicated
|
||||
**W-FLEET** sub-workstream entry, not a separate mission. The dispatcher is `@mosaicstack/forge`
|
||||
(reused, not a new daemon); the only new fleet-owned code is the thin **`forge-exec` TaskExecutor
|
||||
adapter** (suggested package `packages/forge-exec`, `ForgeTask` → `agent-send.sh`), tracked as a
|
||||
Gitea issue and built post-PoC.
|
||||
|
||||
---
|
||||
|
||||
> **Release procedure (drift re-capture, 2026-06-22):** `mosaic update` only propagates new fleet
|
||||
> commands when the **CLI version is bumped** — without a version bump, fleet command changes never
|
||||
> reach installed hosts. The release/version-bump procedure (bump → publish → `mosaic update`
|
||||
> [→ `--relaunch`]) must be documented so fleet changes actually land. (Also feeds the budgeting
|
||||
> workstream.)
|
||||
>
|
||||
> **Tracked separately (not in scope for this doc PR):** **#622** PRD/mission/task projected+actual
|
||||
> spend template standard · **#623** anonymized spend telemetry → mosaicstack.dev (product) ·
|
||||
> **#625** `tenant_id` roster-schema field (multi-tenant; invariant #1 home) · **#628** `forge-exec`
|
||||
> TaskExecutor adapter (post-PoC). This PR records **doctrine only** — no implementation.
|
||||
@@ -7,7 +7,6 @@
|
||||
3. [Provider Configuration](#provider-configuration)
|
||||
4. [MCP Server Configuration](#mcp-server-configuration)
|
||||
5. [Environment Variables Reference](#environment-variables-reference)
|
||||
6. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
||||
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
||||
7. [API Endpoint Reference](#api-endpoint-reference)
|
||||
8. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
# Local Fleet Canary
|
||||
|
||||
The local fleet canary runs a small tmux-backed Mosaic agent fleet on an
|
||||
isolated tmux socket. The default socket is `mosaic-fleet`; the commands do
|
||||
not use or stop the default tmux server.
|
||||
|
||||
## Files
|
||||
|
||||
Product-owned defaults:
|
||||
|
||||
- `packages/mosaic/framework/fleet/roster.schema.json`
|
||||
- `packages/mosaic/framework/fleet/examples/minimal.yaml`
|
||||
- `packages/mosaic/framework/fleet/examples/local-canary.yaml`
|
||||
- `packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service`
|
||||
- `packages/mosaic/framework/systemd/user/mosaic-agent@.service`
|
||||
- `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
|
||||
- `packages/mosaic/framework/tools/tmux/agent-send.sh`
|
||||
- `packages/mosaic/framework/tools/tmux/send-message.sh`
|
||||
|
||||
These files are published through `packages/mosaic/package.json`, whose `files`
|
||||
allowlist includes `framework` along with `dist`.
|
||||
|
||||
Site-owned local roster:
|
||||
|
||||
```text
|
||||
~/.config/mosaic/fleet/roster.yaml
|
||||
```
|
||||
|
||||
Do not put a host-specific full roster into product defaults. Start from an
|
||||
example and edit the local roster after `mosaic fleet init --write`.
|
||||
|
||||
## Install
|
||||
|
||||
Minimal canary:
|
||||
|
||||
```bash
|
||||
mosaic fleet init --profile minimal --write
|
||||
# If a site-owned roster already exists, inspect it first; overwrite only explicitly:
|
||||
# mosaic fleet init --profile minimal --write --force
|
||||
mosaic fleet install-systemd
|
||||
systemctl --user daemon-reload
|
||||
mosaic fleet start
|
||||
mosaic fleet verify
|
||||
```
|
||||
|
||||
Small dogfood roster:
|
||||
|
||||
```bash
|
||||
mosaic fleet init --profile local-canary --write
|
||||
# Use --force only after preserving any site-owned roster changes.
|
||||
mosaic fleet install-systemd
|
||||
systemctl --user daemon-reload
|
||||
mosaic fleet start
|
||||
mosaic fleet status
|
||||
```
|
||||
|
||||
## Agent Operations
|
||||
|
||||
```bash
|
||||
mosaic agent roster
|
||||
mosaic agent status
|
||||
mosaic agent status canary-pi
|
||||
mosaic agent send canary-pi --message "status check"
|
||||
mosaic agent reset canary-pi --new
|
||||
mosaic agent tail canary-pi -n 80
|
||||
```
|
||||
|
||||
These commands read the roster and target the configured tmux socket. The
|
||||
generated systemd agent services use `start-agent-session.sh`; message delivery
|
||||
uses the tmux send tools with `-L mosaic-fleet`.
|
||||
|
||||
`mosaic agent send` is operator-origin traffic unless a caller explicitly says
|
||||
otherwise. The CLI always passes a deterministic source label to
|
||||
`agent-send.sh` with `-S`, defaulting to `<hostname>:operator`, so it does not
|
||||
query the target tmux socket and accidentally identify as an active agent pane.
|
||||
Use `--source-label <label>` or `--source <label>` only when deliberately
|
||||
impersonating a known handoff lane. The lower-level inter-agent wrapper
|
||||
`agent-send.sh -S <label>` remains the explicit source override for scripts.
|
||||
|
||||
## Verification
|
||||
|
||||
Use these checks before expanding the roster:
|
||||
|
||||
```bash
|
||||
tmux -L mosaic-fleet ls
|
||||
tmux ls
|
||||
mosaic fleet verify
|
||||
systemctl --user status mosaic-tmux-holder.service
|
||||
```
|
||||
|
||||
Expected results:
|
||||
|
||||
- `tmux -L mosaic-fleet ls` shows `_holder` and roster agent sessions.
|
||||
- `tmux ls` shows only the default tmux server sessions and is not changed by
|
||||
fleet start/stop operations.
|
||||
- `mosaic fleet verify` checks exact session targets on the isolated socket.
|
||||
- `systemctl --user status ...` may show `active (exited)` for oneshot units;
|
||||
that means the unit ran, not that an agent pane is live. Treat tmux
|
||||
`has-session`, `list-panes`, process tree, and logs as the liveness evidence.
|
||||
|
||||
## Release Preflight
|
||||
|
||||
Run this checklist before cutting or dogfooding a fleet release:
|
||||
|
||||
- Real AI dogfood: send at least one task through `mosaic agent send`, then
|
||||
confirm the agent accepted/responded using pane, process, or log evidence.
|
||||
- Restart/stop/idempotency: run `mosaic fleet start`, `restart`, `stop`, and a
|
||||
repeated `start` against the named socket; verify the default tmux server is
|
||||
unchanged.
|
||||
- Liveness verification: run `mosaic fleet verify` and confirm roster sessions
|
||||
with `tmux -L mosaic-fleet ls` or exact `has-session` checks.
|
||||
- Package dry-run: run `npm pack --dry-run --json` from `packages/mosaic` and
|
||||
confirm `framework/fleet`, `framework/systemd/user`,
|
||||
`framework/tools/fleet`, and `framework/tools/tmux` assets are included.
|
||||
- Mosaic update test: install or upgrade from the packed artifact in a temporary
|
||||
Mosaic home and confirm `mosaic update` or the release upgrade path does not
|
||||
remove local roster/config files.
|
||||
|
||||
## Rollback
|
||||
|
||||
Stop the local canary:
|
||||
|
||||
```bash
|
||||
mosaic fleet stop
|
||||
systemctl --user disable mosaic-agent@canary-pi.service
|
||||
systemctl --user disable mosaic-tmux-holder.service
|
||||
systemctl --user daemon-reload
|
||||
```
|
||||
|
||||
For a full local cleanup of generated canary files:
|
||||
|
||||
```bash
|
||||
rm -f ~/.config/systemd/user/mosaic-agent@.service
|
||||
rm -f ~/.config/systemd/user/mosaic-tmux-holder.service
|
||||
rm -rf ~/.config/mosaic/fleet
|
||||
rm -rf ~/.config/mosaic/tools/fleet
|
||||
```
|
||||
|
||||
This rollback leaves the default tmux server untouched. If a canary session is
|
||||
still present after service stop, remove only the isolated socket server:
|
||||
|
||||
```bash
|
||||
tmux -L mosaic-fleet kill-server
|
||||
```
|
||||
@@ -10,7 +10,6 @@
|
||||
6. [CLI Usage](#cli-usage)
|
||||
7. [Sub-package Commands](#sub-package-commands)
|
||||
8. [Telemetry](#telemetry)
|
||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
# PRD — Agent Reflection Loop (durable kernel)
|
||||
|
||||
**Issue:** [#544](http://git.mosaicstack.dev/mosaicstack/stack/issues/544)
|
||||
**Source design:** jarvis-brain `docs/planning/AGENT-REFLECTION-LOOP.md` (commit df6576fc, debate-hardened v2)
|
||||
**Status:** in-progress
|
||||
**Scope rule:** Build the **durable kernel** only. The closed calibration/skill-synthesis loop
|
||||
(design §7–§8) is **gated** behind Phase-0 experiments P1/P2/P3 and is explicitly out of scope here.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
At end-of-run an agent holds context that never reaches the diff or the "done" message —
|
||||
assumptions, shortcuts, untested paths, the single most-likely way the work is wrong. That context
|
||||
is what a lead/human needs to judge trust, and it evaporates when the session ends. Capture it
|
||||
mechanically as **structured data** (`reflection.v1`), and derive a **review risk-floor** from the
|
||||
change surface so risky diffs are flagged for independent review.
|
||||
|
||||
## 2. Non-goals (gated on Phase-0)
|
||||
|
||||
- No closed calibration loop (predicted-vs-actual scoring as a routing input).
|
||||
- No skill synthesis.
|
||||
- No automated reviewer routing/dispatch. The kernel **writes** the sidecar; pickup is future work.
|
||||
|
||||
## 3. Components & exact placement (main-branch truth)
|
||||
|
||||
| # | Component | Path | Mirror |
|
||||
| --- | -------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------- |
|
||||
| a | Stop hook (capture) | `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh` | `tools/qa/prevent-memory-write.sh` |
|
||||
| a | Hook registration | `packages/mosaic/framework/runtime/claude/settings.json` (`hooks.Stop`) | existing `PreToolUse`/`PostToolUse` |
|
||||
| b | JSON Schema | `packages/macp/src/schemas/reflection.v1.schema.json` | `schemas/task.schema.json` |
|
||||
| b | TS types (zod) + DTO | `packages/types/src/reflection/{index.ts,reflection.dto.ts}` + re-export from `src/index.ts` | `packages/types/src/federation/*` |
|
||||
| c | Diff risk-floor | `packages/macp/src/risk-floor.ts` (+ `__tests__/risk-floor.test.ts`, export from `src/index.ts`) | `packages/macp/src/gate-runner.ts` |
|
||||
| d | Phase-0 scripts | `scripts/analysis/reflect-{git-history,board-history,calibration}.sh` | `scripts/publish-npmjs.sh` |
|
||||
|
||||
**Activation note (deliberate deviation):** the `settings-overlays/` directory has **no merge
|
||||
mechanism** (referenced only in docs), so a hooks overlay there would be inert. The Stop hook is
|
||||
registered in the canonical `runtime/claude/settings.json` — the same file the `mosaic` launcher
|
||||
reflects into `~/.claude/settings.json` (verified byte-identical hooks live there). Still fully
|
||||
vendored in-repo.
|
||||
|
||||
## 4. `reflection.v1` schema (authoritative field list)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema": "reflection.v1", // literal
|
||||
"task_ref": "string", // canonical task ref; kernel derives from REFLECTION_TASK_REF or repo+branch
|
||||
"agent": "string", // persona/runtime id (REFLECTION_AGENT or "unknown")
|
||||
"session_id": "string", // from Stop payload session_id, else "unknown"
|
||||
"timestamp": "string", // ISO-8601 UTC
|
||||
"repo": "string", // repo root basename
|
||||
"confidence": 0.0, // FLOAT [0,1] — SELF-REPORTED (optional; null if not supplied)
|
||||
"most_likely_wrong": {
|
||||
// SELF-REPORTED (optional)
|
||||
"surface": "auth|data|infra|ui|build|test|docs|none",
|
||||
"description": "string",
|
||||
},
|
||||
"known_not_in_diff": "string|null", // SELF-REPORTED: "what I know that isn't visible in the diff"
|
||||
"risk": {
|
||||
// MECHANICAL — from risk-floor
|
||||
"needs_review": true,
|
||||
"score": 0.0, // [0,1]
|
||||
"surface": "auth|data|infra|ui|build|test|docs|none",
|
||||
"reason": "string",
|
||||
},
|
||||
"files_changed": ["string"], // MECHANICAL — git diff name-only
|
||||
"provenance": {
|
||||
"source": "stop-hook",
|
||||
"reflection_attempt": 1,
|
||||
"degraded": false, // true if self-report inputs missing/unreadable
|
||||
"reflection_mode": "off|solo|orchestrated",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Mechanical vs self-reported.** A bash Stop hook cannot author the agent's self-assessment. The
|
||||
hook populates the **mechanical** fields deterministically (risk, files_changed, provenance, ids).
|
||||
The **self-reported** fields are read from an optional agent-supplied input file
|
||||
(`$REFLECTION_INPUT`, default `<repo>/.mosaic/reflection-input.json`) and merged if present;
|
||||
absent/unreadable → those fields null and `provenance.degraded=true`. This realizes the design's
|
||||
"hook is a pre-seed, not the asker" (§4).
|
||||
|
||||
## 5. Stop hook behavior (fail-closed, non-blocking)
|
||||
|
||||
1. Read Stop payload JSON from stdin.
|
||||
2. **Fail-closed:** if `REFLECTION_MODE` is unset or `off` → `exit 0` immediately (strict no-op). This
|
||||
is the global-registration safety guarantee.
|
||||
3. **Sentinel guard:** if `<sidecar>.lock` exists → `exit 0` (prevents re-fire loops). Create it,
|
||||
`trap` cleanup.
|
||||
4. Determine output dir: `$REFLECTION_DIR` else `<repo>/.mosaic/reflections/`. `mkdir -p`.
|
||||
5. Compute mechanical fields: `git diff --name-only` (HEAD + staged + worktree, best-effort),
|
||||
call risk-floor logic (inline bash port OR `node -e` into `@mosaicstack/macp` — see §6), session
|
||||
ids from payload + env.
|
||||
6. Merge optional `$REFLECTION_INPUT` self-report if readable JSON.
|
||||
7. Write `reflection.v1` to a temp file, `mv` (atomic) to `<dir>/<session>-<ts>.reflection.json`.
|
||||
8. Always `exit 0`. **Never** emit a `decision` field (Stop hooks are observational).
|
||||
|
||||
Hook must never fail the session: wrap risky steps, default to `degraded:true` on any error, exit 0.
|
||||
|
||||
## 6. Risk-floor (`packages/macp/src/risk-floor.ts`)
|
||||
|
||||
Pure, deterministic, no IO. Single source of truth for the verdict; the hook calls it via
|
||||
`node --input-type=module -e` (importing the built package) **or**, to avoid a node dependency in the
|
||||
hook path, the hook ports the same surface table. **Decision:** implement the canonical logic in TS
|
||||
(tested), and have the hook shell out to node when available, else fall back to a minimal inline
|
||||
classifier flagged `degraded:true`. (Keep the TS the authority; the inline path is a safety net.)
|
||||
|
||||
```ts
|
||||
export type ReviewSurface = 'auth' | 'data' | 'infra' | 'ui' | 'build' | 'test' | 'docs' | 'none';
|
||||
export interface RiskFloorInput {
|
||||
filesChanged: string[];
|
||||
insertions?: number;
|
||||
deletions?: number;
|
||||
}
|
||||
export interface RiskFloorVerdict {
|
||||
needs_review: boolean;
|
||||
score: number;
|
||||
surface: ReviewSurface;
|
||||
reason: string;
|
||||
}
|
||||
export function evaluateRiskFloor(input: RiskFloorInput): RiskFloorVerdict;
|
||||
```
|
||||
|
||||
Surface classification by path regex (first match wins, highest-risk surface dominates):
|
||||
|
||||
- `auth` (weight 1.0): `auth`, `login`, `session`, `token`, `permission`, `rbac`, `credential`, `secret`
|
||||
- `data` (0.9): `migration`, `prisma`, `schema`, `\.sql`, `entity`, `repository`, `seed`
|
||||
- `infra` (0.85): `docker`, `\.woodpecker`, `compose`, `traefik`, `deploy`, `helm`, `k8s`, `terraform`
|
||||
- `build` (0.6): `package.json`, `tsconfig`, `turbo.json`, `pnpm-`, `\.config\.`, `eslint`, `vite`
|
||||
- `ui` (0.4): `\.tsx`, `\.css`, `components/`, `apps/web/`
|
||||
- `test` (0.2): `\.spec\.`, `\.test\.`, `__tests__/`
|
||||
- `docs` (0.1): `\.md`, `docs/`
|
||||
- `none` (0.0): anything else
|
||||
|
||||
`needs_review = score >= THRESHOLD` (default `0.5`, overridable). `reason` names the files+surface
|
||||
that tripped it. **Subordinate to CI:** this is a _floor_ (minimum review requirement) only;
|
||||
consumers MUST treat CI/tests as authoritative above the floor (precedence: CI/tests > human merge >
|
||||
reviewer verdict > self-reflection). Documented in the module header.
|
||||
|
||||
## 7. Phase-0 experiment scripts (`scripts/analysis/`)
|
||||
|
||||
Offline, no-infra bash. Each script: `#!/usr/bin/env bash`, `set -euo pipefail`, header `Usage:` +
|
||||
`Requirements:`, flag parsing, **prints its pre-registered kill condition**, emits structured
|
||||
(JSON/markdown) output. They are harnesses + rubrics — real corpora are wired later.
|
||||
|
||||
- `reflect-git-history.sh` (**P2** — only-self-reflection bucket): scan `git log` for failure signals
|
||||
(reverts, `fix:`/`hotfix` shortly after a feature merge) over a window; classify each by which gate
|
||||
would catch it (CI / human-review / only-self-reflection) via a pre-registered heuristic; tally.
|
||||
Kill: bucket-3 near-empty → no §7/§8.
|
||||
- `reflect-board-history.sh` (**P3** — outcome detectability): given a task/board export (or the
|
||||
git history of `data/` task files), measure the fraction of completed tasks with a
|
||||
machine-detectable correct/wrong signal within 30 days. Kill: base-rate < 20% → caveat-notes only.
|
||||
- `reflect-calibration.sh` (**P1** — confidence signal): consume a labeled corpus (JSONL of
|
||||
`{confidence, correct}`), compute discrimination (AUC/lift) on the self-rated-high subset, print
|
||||
the metric vs the pre-registered chance threshold. Kill: AUC ≈ chance on the high subset → no §7/§8.
|
||||
|
||||
## 8. CI / quality gates
|
||||
|
||||
- TS packages: `pnpm typecheck` (tsc --noEmit), `pnpm lint` (eslint), `pnpm format:check`
|
||||
(prettier), `pnpm test` (vitest). ESM, NodeNext, `.js` import specifiers, `*.dto.ts` at boundaries.
|
||||
- New files in existing packages need no CI config change; add ≥1 vitest spec per new TS module.
|
||||
- Bash scripts/hook are dev/runtime tooling, not CI-built; keep them `shellcheck`-clean.
|
||||
|
||||
## 9. Acceptance criteria
|
||||
|
||||
1. `REFLECTION_MODE` unset → hook is a strict no-op (`exit 0`, no file written). **(test)**
|
||||
2. With `REFLECTION_MODE=solo`, hook writes a schema-valid `reflection.v1` with correct mechanical
|
||||
fields; self-report merged when `$REFLECTION_INPUT` present, `degraded:true` when absent.
|
||||
3. `evaluateRiskFloor` deterministic across all surfaces; unit-tested incl. auth/data/infra → review,
|
||||
docs/test → no review, empty → `none`/no review.
|
||||
4. `reflection.v1` zod type + JSON Schema agree; sidecar validates against the schema.
|
||||
5. Phase-0 scripts run offline, print kill conditions, emit structured output, shellcheck-clean.
|
||||
6. `pnpm typecheck && pnpm lint && pnpm format:check && pnpm test` green; independent review passed.
|
||||
@@ -1,52 +0,0 @@
|
||||
# Fleet CLI Local Canary Dogfood — 2026-06-20
|
||||
|
||||
## Objective
|
||||
|
||||
Move the durable tmux fleet PoC into a functional local canary on this server. This is **not** production deployment. It is a canary/dogfood path for a small local agent fleet using an isolated tmux socket.
|
||||
|
||||
## Issue
|
||||
|
||||
- Gitea issue: #562 — `feat(fleet): local CLI canary dogfood`
|
||||
|
||||
## Scope
|
||||
|
||||
Implement enough product surface to use the fleet locally:
|
||||
|
||||
- `mosaic fleet init/install/start/stop/restart/status/verify`
|
||||
- `mosaic agent roster/status/send/reset/tail`
|
||||
- roster schema and examples
|
||||
- local canary docs and rollback instructions
|
||||
- tests for CLI behavior where practical
|
||||
- canary verification on named tmux socket `mosaic-fleet`
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No production rollout.
|
||||
- No migration of existing default tmux sessions.
|
||||
- No image build/deploy work.
|
||||
- No hardcoded USC/local roster as product default.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- CLI can initialize a minimal roster outside product defaults.
|
||||
- CLI can install user systemd units and fleet helper scripts to a configurable Mosaic home.
|
||||
- CLI can start/stop/status/verify a canary fleet using `mosaic-fleet`.
|
||||
- `mosaic agent send` uses existing named-socket/exact-target tmux tooling.
|
||||
- `mosaic agent reset` targets only the named agent session on the named socket.
|
||||
- Verification proves default tmux sessions remain untouched.
|
||||
- Baseline repo gates pass.
|
||||
- PR CI is green before merge.
|
||||
- Local canary evidence is captured after merge/install.
|
||||
|
||||
## Budget / Routing
|
||||
|
||||
- Agent: codex preferred.
|
||||
- Estimate: 25K-40K tokens.
|
||||
- Worker owns implementation/tests/docs in branch `feat/fleet-cli-local-canary`.
|
||||
- Orchestrator owns `docs/TASKS.md`, issue/PR/merge, and local canary install verification.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-06-20: #557 PoC primitives merged to `main` as `45e2c2a`.
|
||||
- 2026-06-20: issue #562 created for local CLI canary dogfood.
|
||||
- 2026-06-20: worktree created at `/home/jarvis/src/mosaicstack-stack-worktrees/fleet-cli-local-canary`.
|
||||
@@ -1,35 +0,0 @@
|
||||
# Fleet release hardening
|
||||
|
||||
## Objective
|
||||
|
||||
Harden the Mosaic local fleet release path for operator sends, tmux/systemd verification, package contents, and dogfood release documentation.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not edit `docs/TASKS.md`.
|
||||
- Do not change production deployment refs.
|
||||
- Keep fleet transport generic and named-socket safe.
|
||||
- Preserve strict roster validation.
|
||||
- Add tests first or alongside fixes.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add regression tests for deterministic `mosaic agent send` source labels.
|
||||
2. Strengthen fleet status/verify/package/install-systemd coverage.
|
||||
3. Implement focused CLI/source-label changes.
|
||||
4. Update local canary documentation with dogfood preflight.
|
||||
5. Run formatting, targeted tests, typecheck, lint, and package dry-run evidence.
|
||||
|
||||
## Evidence Log
|
||||
|
||||
- Started from existing `docs/PRD.md`; durable local fleet canary is in v0.1.0 scope.
|
||||
- Loaded `mosaic-fleet-operations` skill; key constraints are isolated tmux sockets, no default tmux positive tests, and `active (exited)` is not liveness.
|
||||
- TDD red: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts` initially failed because `node_modules` was absent; after `pnpm install`, the new source-label tests failed on missing `-S`, missing helper, and unknown `--source-label`.
|
||||
- Green implementation: `mosaic agent send` now passes `-S <hostname>:operator` by default and accepts `--source-label` / `--source` overrides.
|
||||
- Test coverage added for tmux-based fleet verify liveness, package `files` allowlist containing `framework`, and explicit operator source-label command construction.
|
||||
- Formatting: `pnpm exec prettier --write packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts docs/guides/fleet-local-canary.md docs/scratchpads/2026-06-20-fleet-release-hardening.md`.
|
||||
- Targeted tests: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts src/cli-smoke.spec.ts` passed with 49 tests.
|
||||
- Typecheck: `pnpm typecheck` passed.
|
||||
- Lint: `pnpm lint` passed.
|
||||
- Package dry-run: `npm pack --dry-run --json` from `packages/mosaic` included `framework/fleet`, `framework/systemd/user`, `framework/tools/fleet/start-agent-session.sh`, and `framework/tools/tmux/{agent-send.sh,send-message.sh}`.
|
||||
- Review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` approved the supplied diff with no findings; the review tool noted its read-only sandbox could not inspect files directly.
|
||||
@@ -1,60 +0,0 @@
|
||||
# Scratchpad — FED-M3-04 Scope Service
|
||||
|
||||
## Objective
|
||||
|
||||
Implement `apps/gateway/src/federation/server/scope.service.ts` for the M3 inbound federation scope-enforcement pipeline.
|
||||
|
||||
## Scope / Constraints
|
||||
|
||||
- Task: FED-M3-04, issue #462.
|
||||
- Branch: `feat/federation-m3-scope-service` from `origin/main` @ 0.0.48.
|
||||
- Pure service: no direct DB access; native RBAC/data access is injected per evaluation call.
|
||||
- Reuse `parseFederationScope` from M2-03.
|
||||
- Workers do not edit `docs/federation/TASKS.md` per repo AGENTS.md.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Resource allowlist and `excluded_resources` enforced.
|
||||
2. Native RBAC evaluated as `subjectUserId` through an injected evaluator.
|
||||
3. Scope filter intersection supports `include_teams` and `include_personal` without widening native RBAC.
|
||||
4. `max_rows_per_query` caps requested limits.
|
||||
5. Service returns `{ allowed: true, filter }` or a structured deny reason usable by M4 audit.
|
||||
6. Unit tests cover every deny path.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Inspect existing federation scope/schema/auth guard contracts.
|
||||
2. Add pure `FederationScopeService` plus typed result/filter/deny interfaces.
|
||||
3. Add focused unit tests for happy paths, filter intersection, row cap, and deny paths.
|
||||
4. Export/register service for future verb controllers.
|
||||
5. Run situational tests, baseline gates, code review, then PR.
|
||||
|
||||
## Budget
|
||||
|
||||
- Provided model tier: sonnet.
|
||||
- Estimate from task row: 10K tokens.
|
||||
- Working cap assumption: keep implementation focused to FED-M3-04 surfaces only.
|
||||
|
||||
## Progress
|
||||
|
||||
- Intake complete; dirty base worktree avoided by creating isolated worktree at `/home/jarvis/src/mosaic-mono-v1-fed-m3-04`.
|
||||
- Project PRD and federation task spec reviewed.
|
||||
- Added `FederationScopeService` with structured allow/deny result types and injected native RBAC evaluator contract.
|
||||
- Added unit coverage for happy path, row cap, filter intersection, and every deny path.
|
||||
- Exported/registered the service for upcoming M3 verb controllers.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/__tests__/scope.service.spec.ts` — pass (10 tests before review update; 11 tests after adding include_personal no-leak coverage).
|
||||
- `pnpm build` — pass (23 successful tasks).
|
||||
- `pnpm typecheck` — pass (41 successful tasks; re-run after review update).
|
||||
- `pnpm lint` — pass (23 successful tasks; re-run after review update).
|
||||
- `pnpm format:check` — pass (re-run after review update).
|
||||
- `pnpm test` — pass after starting local `postgres`/`valkey` and running `pnpm --filter @mosaicstack/db db:push` for the DB-backed cross-user isolation suite (41 successful tasks; gateway 477 passed / 11 skipped).
|
||||
- Code review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings.
|
||||
- Security review: `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — risk none, 0 findings.
|
||||
|
||||
## Risks / Blockers
|
||||
|
||||
- Issue #462 is already closed in provider output; likely milestone tracking mismatch. Will still reference #462 in PR body unless orchestrator redirects.
|
||||
- Local full-test setup required `docker compose up -d postgres valkey` + `db:push`; containers were stopped with `docker compose down` after verification.
|
||||
@@ -1,50 +0,0 @@
|
||||
# Issue 536 Wrapper Login Pin Scratchpad
|
||||
|
||||
## Metadata
|
||||
|
||||
- Date: 2026-06-12
|
||||
- Worktree: `/home/hermes/agent-work/536-wrapper-audit`
|
||||
- Branch: `fix/536-wrapper-login-pin`
|
||||
- Coordinator: `mos-claude`
|
||||
- Issue: `mosaicstack/stack#536`
|
||||
- Scope: Audit and fix Gitea git wrappers that hardcode or incorrectly inherit tea login/instance selection.
|
||||
|
||||
## Objective
|
||||
|
||||
Fix the framework git wrappers so Gitea issue/PR operations resolve the tea login from the target repository host instead of pinning `mosaicstack`. The fix must cover the class of bug across `packages/mosaic/framework/tools/git/`, not only `issue-close.sh`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. `issue-close.sh` no longer uses `--login mosaicstack` for non-mosaic hosts.
|
||||
2. All wrappers in `packages/mosaic/framework/tools/git/` avoid hardcoded Gitea login fallback where host-specific resolution is available.
|
||||
3. Host-specific resolution works for `git.mosaicstack.dev` and `git.uscllc.com` using configured credentials / tea login data.
|
||||
4. Read-only verification runs against both Gitea instances where possible.
|
||||
5. Queue guard passes before push, PR is opened referencing #536, and merge is left to the coordinator.
|
||||
|
||||
## Progress Log
|
||||
|
||||
- Read required Mosaic hard-gate docs and coordinator briefing.
|
||||
- Read issue #536 via Gitea API with mosaicstack credentials.
|
||||
- Initial audit found hardcoded `${GITEA_LOGIN:-mosaicstack}` in issue and PR wrappers, plus shared `get_gitea_repo_args`.
|
||||
- Added host-aware Gitea login resolution in `detect-platform.sh`, including exact host matching for `tea login list` entries and HTTPS remotes with embedded credentials.
|
||||
- Updated Gitea issue, PR, milestone, and CI wrappers to use resolved host-specific tea login arguments instead of defaulting to `mosaicstack`.
|
||||
- Added authenticated API fallbacks for close/reopen paths so wrappers can still operate when a matching `tea` login is absent but token credentials are available.
|
||||
- Added regression coverage for stale `GITEA_LOGIN`, exact host matching, `--repo` override flows, USC issue close routing, mosaicstack API fallback, and PR metadata/merge fallbacks.
|
||||
- Delta after PR #538 review: extended host-aware login/repo resolution to PowerShell wrappers, Bash milestone wrappers, and API-only `--repo` fallback paths.
|
||||
- Delta after live USC `pr-create.sh` repro: tightened `GITEA_LOGIN` trust so stale login names are ignored unless the tea login itself matches the target host, and added USC API fallback coverage for `pr-create.sh`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bash -n packages/mosaic/framework/tools/git/*.sh`
|
||||
- `packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh`
|
||||
- `packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh`
|
||||
- `packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh`
|
||||
- `pwsh -NoProfile` parse check for all `packages/mosaic/framework/tools/git/*.ps1`
|
||||
- `pnpm typecheck`
|
||||
- `pnpm lint`
|
||||
- `pnpm format:check`
|
||||
- `pnpm --filter @mosaicstack/mosaic test -- src/commands/git-wrapper-redirects.spec.ts`
|
||||
- `pnpm test` progressed past wrapper redirect assertions; local run then stopped on `apps/gateway` Postgres connection refused at `localhost:5433`, which CI provides as a service.
|
||||
- Live read-only: direct Gitea API read of `mosaicstack/stack#536` with `User-Agent: curl/8`.
|
||||
- Live read-only: USC temporary repo remote to `https://git.uscllc.com/USC/uconnect.git`; `issue-list.sh -n 1` resolved the USC login and returned USC issues.
|
||||
- Independent Codex review final verdict: approve, no findings.
|
||||
@@ -1,55 +0,0 @@
|
||||
# Scratchpad — #544 Agent Reflection Loop (durable kernel)
|
||||
|
||||
**Started:** 2026-06-16 · **Branch:** `feat/agent-reflection-loop` · **Base:** `main` @ c461380
|
||||
|
||||
## Goal
|
||||
|
||||
Bake the durable kernel of the agent reflection loop into the Mosaic Stack
|
||||
monorepo through full delivery gates. Kernel only; closed loop (§7–§8) gated on
|
||||
Phase-0. Authoritative spec: `docs/plans/agent-reflection-loop-PRD.md`. Task
|
||||
breakdown: `docs/tasks/544-agent-reflection-loop.md`.
|
||||
|
||||
## Timeline / decisions
|
||||
|
||||
- Mapped house style against `main` truth (the earlier recon had mapped a dirty
|
||||
feature branch and returned non-existent paths; re-cloned `main` clean).
|
||||
- macp uses co-located `*.spec.ts`; types uses `src/<mod>/{*.ts, *.dto.ts, __tests__/*.spec.ts}`.
|
||||
- zod v4 + class-validator/class-transformer present in `@mosaicstack/types`;
|
||||
`packages/types/tsconfig.json` enables `experimentalDecorators`/`emitDecoratorMetadata`.
|
||||
- **Gotcha (fixed):** `class-transformer`'s `@Type` calls `Reflect.getMetadata`
|
||||
at module-load time; the types vitest env has no `reflect-metadata`, so any test
|
||||
importing the reflection barrel crashed on import. `chat.dto.ts` avoids this by
|
||||
using class-validator only. Fix: dropped `@Type`/`@ValidateNested` from the DTO;
|
||||
zod owns deep nested validation.
|
||||
- **Gotcha (fixed):** Stop hook `EXIT` trap referenced a `main`-local `lock` →
|
||||
`unbound variable` under `set -u` at exit. Promoted to a global `LOCKFILE`.
|
||||
- **Gotcha (fixed):** the hook's own lock + `.mosaic/` scratch leaked into
|
||||
`files_changed`. Excluded `^\.mosaic/` from the change-surface scan.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
- macp: typecheck OK, lint OK, **88 tests pass** (15 new risk-floor).
|
||||
- types: typecheck OK, lint OK, **64 tests pass** (10 new reflection).
|
||||
- Root: `pnpm typecheck` (41 tasks), `pnpm lint` (23), `pnpm format:check`, `pnpm build` (23) — all green.
|
||||
- Stop hook smoke (throwaway git repo): TEST1 no-op (mode unset, 0 files);
|
||||
TEST2 solo degraded, `.mosaic/` excluded, auth→needs_review; TEST3 self-report
|
||||
merged, degraded=false; TEST4 lock suppresses re-fire. All pass, always exit 0.
|
||||
- shellcheck clean: hook + `reflect-{git-history,board-history,calibration}.sh`.
|
||||
- Phase-0 smoke: P2 on this repo (142 failures classified), P1 AUC=0.875 on a
|
||||
synthetic fixture, P3 base-rate on a synthetic board — all emit structured output
|
||||
- kill conditions.
|
||||
|
||||
## Open risks / follow-ups
|
||||
|
||||
- Full `pnpm test` (DB-bound packages) validated via CI's postgres service, not
|
||||
locally; affected packages (macp, types) are DB-independent and green here.
|
||||
- sequential-thinking MCP was registered mid-session (effective next session);
|
||||
this session compensated with the written PRD as the planning artifact.
|
||||
- Phase-0 corpora are not yet wired — scripts are harnesses + pre-registered
|
||||
rubrics (P1/P2/P3 tasks tracked in jarvis-brain `agent-reflection-loop` project).
|
||||
|
||||
## Gate status
|
||||
|
||||
- [x] PRD authored · [x] issue #544 created + linked · [x] code + tests
|
||||
- [x] local gates green · [ ] independent code review · [ ] PR opened
|
||||
- [ ] CI terminal green · [ ] merged to main · [ ] issue closed
|
||||
@@ -1,87 +0,0 @@
|
||||
# Wrapper hardening fold-in: #559 (eval removal) + #560 (host-derived login)
|
||||
|
||||
**Branch:** `fix/wrapper-hardening-tls-credpath-cicwait` (PR #551)
|
||||
**Worker:** coderlite0 (Sonnet lane) · coordinated by mos-claude
|
||||
**Date:** 2026-06-20
|
||||
**Scope:** `packages/mosaic/framework/tools/git/*.sh` only
|
||||
|
||||
## What the issues asked for vs. what was already landed
|
||||
|
||||
Both issues were largely satisfied by prior merged work; this fold-in closes the
|
||||
remaining gaps (regression tests + a loud diagnostic + one residual word-split site)
|
||||
rather than re-implementing finished functionality.
|
||||
|
||||
### #559 — remove `eval` from issue-create.sh (and siblings)
|
||||
|
||||
- `eval`-based command construction was already removed across the wrapper surface
|
||||
(landed in #549). A full scan of `tools/git/*.sh` finds **zero** `eval` usages.
|
||||
- `issue-create.sh`, `pr-create.sh`, `issue-edit.sh`, `issue-assign.sh` already build
|
||||
their `tea`/`gh` invocations as argv arrays (`CMD=(...)`, `"${CMD[@]}"`), so Markdown
|
||||
bodies pass through verbatim.
|
||||
- **Residual found & fixed:** `issue-comment.sh` still used unquoted
|
||||
`$(get_gitea_repo_args)` word-splitting (the comment body itself was already safely
|
||||
quoted, so no injection bug — but it was the inconsistent, fragile pattern #559 targets,
|
||||
and it failed silently when no login resolved). Converted to an argv array with an
|
||||
explicit, loud login-resolution error.
|
||||
- **Added regression test:** `test-issue-create-body-safety.sh` — feeds a hostile
|
||||
Markdown body (`$(touch SENTINEL)`, backticks, single/double quotes, `$HOME`/`${PATH}`,
|
||||
pipes/`&&`/`;`) through `issue-create.sh` and asserts (1) no command substitution
|
||||
executes (sentinel file never created) and (2) the `--description` `tea` receives is
|
||||
byte-for-byte the original body.
|
||||
|
||||
### #560 — auto-detect Gitea `--login` from repo origin host
|
||||
|
||||
- Centralized host→login resolution already exists in `detect-platform.sh`
|
||||
(`get_gitea_login_for_host` → `find_tea_login_for_host`, matching `urlparse(url).hostname`).
|
||||
Every wrapper routes through it (or `get_gitea_login` / `get_gitea_login_for_repo_override`);
|
||||
**no wrapper hardcodes `${GITEA_LOGIN:-mosaicstack}`**. Explicit `GITEA_LOGIN` wins only
|
||||
when it matches the host (`tea_login_matches_host`), so stale overrides are rejected.
|
||||
- **Gap fixed — silent failure → loud diagnostic:** the failure path of
|
||||
`get_gitea_login_for_host` returned non-zero with no message. Added
|
||||
`print_gitea_login_diagnostic`, emitted to **stderr** on resolution failure: names the
|
||||
unresolved host, lists available tea logins (name + host), and gives the `GITEA_LOGIN`
|
||||
override + `tea login add` fix. Stderr-only, so it never contaminates stdout (the
|
||||
resolved login name) or the log-grep assertions in the existing harnesses. Callers with
|
||||
an API fallback (pr-merge, issue-close, pr-create, issue-create) still follow with their
|
||||
own "using API fallback" line, giving a clear "no login → fallback" trail.
|
||||
- **Extended test:** `test-gitea-login-resolution.sh` now also asserts (a) the loud
|
||||
diagnostic fires and lists available logins for an unresolved host, (b) login is derived
|
||||
from origin host for **both** instances (mosaicstack + usc) via a scoped second `tea`
|
||||
mock, and (c) a valid `GITEA_LOGIN` override is honored. The scoped mock keeps the
|
||||
existing API-fallback assertions (which require mosaicstack to have _no_ tea login) valid.
|
||||
|
||||
## Files changed (wrapper surface only)
|
||||
|
||||
- `detect-platform.sh` — add `print_gitea_login_diagnostic`; call it on the
|
||||
`get_gitea_login_for_host` failure path.
|
||||
- `issue-comment.sh` — argv array + loud login-resolution error (was unquoted
|
||||
`$(get_gitea_repo_args)`).
|
||||
- `test-issue-create-body-safety.sh` — **new** (#559 regression).
|
||||
- `test-gitea-login-resolution.sh` — extended (#560 diagnostic + both-host + override).
|
||||
|
||||
## Verification
|
||||
|
||||
All wrapper harnesses pass locally:
|
||||
|
||||
- `test-issue-create-body-safety.sh` — PASS
|
||||
- `test-gitea-login-resolution.sh` — PASS
|
||||
- `test-pr-merge-gitea-empty-uid.sh` — PASS
|
||||
- `test-pr-metadata-gitea.sh` — PASS
|
||||
- `test-lane-brief-pr-linkage.sh` — PASS
|
||||
|
||||
## Open items flagged to mos-claude (orchestrator decisions)
|
||||
|
||||
1. **CHANGELOG absent.** The task said "update CHANGELOG (append-only), keep the existing
|
||||
#550/#551 entry." No CHANGELOG file exists anywhere in the repo, and #550/#551 are not
|
||||
recorded in one. **ASSUMPTION:** documenting #559/#560 in this scratchpad + the PR
|
||||
description (`Closes #559 Closes #560`) follows the repo's actual convention
|
||||
(`docs/scratchpads/`). Did not invent a new CHANGELOG structure.
|
||||
2. **`docs/TASKS.md` is orchestrator single-writer.** It carries a "Workers read but never
|
||||
modify" banner. As a worker I did **not** edit it; task tracking is via the linked Gitea
|
||||
issues #559/#560 + this scratchpad. Orchestrator may add a rollup row if desired.
|
||||
3. **Wrapper `test-*.sh` are not CI-wired.** `.woodpecker/ci.yml` runs `pnpm
|
||||
typecheck/lint/format:check/test` (`turbo run test`); the framework dir has no
|
||||
`package.json`, so these shell harnesses run **locally/manually only** — they do not gate
|
||||
the PR in Woodpecker. **ASSUMPTION:** out of scope to wire a shell-test step into CI in
|
||||
this PR (would broaden the diff beyond the wrapper surface). Flagging for a follow-up if
|
||||
the fleet wants these gated.
|
||||
@@ -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,32 +0,0 @@
|
||||
# #631 — re-seed must preserve user fleet data (CRITICAL data-loss)
|
||||
|
||||
- **Issue:** #631 · **Branch:** `fix/631-reseed-preserves-fleet-data`
|
||||
|
||||
## Root cause
|
||||
|
||||
`mosaic update` auto-runs `install.sh` keep-mode sync (#610). install.sh's rsync `--delete` (keep mode)
|
||||
honored PRESERVE_PATHS, but `fleet/` wasn't listed → the sync WIPED `~/.config/mosaic/fleet/roster.yaml`
|
||||
(+ run/, agents/). Any user running `mosaic update` lost their roster. (overwrite mode wipes by design;
|
||||
the live loss was keep mode.)
|
||||
|
||||
## Fix (PRIMARY)
|
||||
|
||||
- install.sh PRESERVE_PATHS += `fleet/*.yaml`, `fleet/agents`, `fleet/run` — the framework still SEEDS
|
||||
fleet/examples + fleet/roles + fleet/roster.schema.json (synced), but user files survive.
|
||||
- Made the cp-fallback (no-rsync) GLOB-AWARE so `fleet/*.yaml` preserves every user roster there too;
|
||||
fixed the restore to re-glob per-pattern (so only the user file is restored, not the whole fleet/ dir).
|
||||
- file-adapter.ts (TS installer): mirrored the preserve list for parity. (TS syncDirectory is copy-only,
|
||||
never --delete, so it never had the bug — belt-and-suspenders + parity.)
|
||||
|
||||
## Fix (SECONDARY)
|
||||
|
||||
- `refreshActiveFleetUnits()` (update-checker.ts): the re-seed updates ~/.config/mosaic/systemd/user but
|
||||
systemd runs ~/.config/systemd/user, so unit fixes (#627) didn't take effect. After the re-seed,
|
||||
`mosaic update` now copies the fresh mosaic-\*.service → the active dir + daemon-reload (best-effort,
|
||||
only when a fleet is already installed). Wired into the cli.ts update flow.
|
||||
|
||||
## Verification
|
||||
|
||||
- bash F6 fixture (6 checks: roster/custom-yaml/agents/run survive + examples refreshed + schema seeded);
|
||||
20/20 migration matrix green. TS file-adapter test (roster/run/agents survive keep sync). 2 unit tests
|
||||
for refreshActiveFleetUnits. tsc/eslint/prettier/sanitize clean.
|
||||
@@ -1,54 +0,0 @@
|
||||
# #633 — comms-block emitter + FLEET-LAUNCH runbook
|
||||
|
||||
Branch: `feat/633-comms-block-runbook` (off `bf2a6745`, post-#632 merge)
|
||||
Issue: #633 · Follow-up filed: #636 (PATH B)
|
||||
|
||||
## Goal
|
||||
|
||||
PATH A of the orchestrator-launch fix: give every launch path the Fleet-Comms onboarding, and
|
||||
document the canonical roster-driven launcher so the orchestrator stops being a bespoke snowflake.
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. **`mosaic fleet comms-block <role> [--host <h>]`** — explicit-arg, comms-block-only emitter.
|
||||
- Backed by new `resolveCommsBlock(mosaicHome, role, fleetHost?)` in `fleet/comms-onboarding.ts`
|
||||
returning `{ ok, output, error }`.
|
||||
- Unlike `readFleetCommsBlock` (returns `''` on any miss so `composeContract` can no-op silently
|
||||
during launch), the emitter **fails loud**: unknown role / missing roster → `ok:false` → CLI
|
||||
prints to stderr + sets `process.exitCode = 1`. A typo is never a silent no-op.
|
||||
- Distinct from `mosaic compose-contract <runtime>` (whole prompt, env-coupled via
|
||||
`MOSAIC_AGENT_NAME`); comms-block is the targeted, explicit-arg, comms-only view.
|
||||
2. **`docs/fleet/FLEET-LAUNCH.md`** — worker path + orchestrator `.env` fold + 3 launch gotchas +
|
||||
#632 preserve note + North-Star 4-field arc.
|
||||
|
||||
## Key findings (drove the design)
|
||||
|
||||
- `mosaic yolo claude` **already** forwards `--channels`/`--permission-mode` to the binary
|
||||
(`launch.ts` claude case `cliArgs.push(...args)`) AND injects the comms block via
|
||||
`composeContract` → `readFleetCommsBlock(home, env.MOSAIC_AGENT_NAME)`. So no `launch.ts` change
|
||||
was needed — PATH A is `.env` + doc only.
|
||||
- `start-agent-session.sh` line ~41 `[ -z "$MOSAIC_AGENT_COMMAND" ]` short-circuits the line-44
|
||||
default, so an `.env` `MOSAIC_AGENT_COMMAND` override bypasses the hardcoded `yolo` entirely — the
|
||||
yolo-conditional is therefore a PATH B (default-path) concern, not PATH A.
|
||||
- `generateAgentEnv` (`fleet.ts` ~202-207) emits NAME/RUNTIME/MODEL but **not** `MOSAIC_AGENT_COMMAND`
|
||||
— the seam PATH B (#636) closes.
|
||||
|
||||
## A → B → webUI arc (North Star)
|
||||
|
||||
- A = `.env` `MOSAIC_AGENT_COMMAND` hatch (manual, ships now, #632-safe).
|
||||
- B (#636) = roster-native launch-config: harness ✅ + model ✅ already there; add **yolo** (line-44
|
||||
conditional `MOSAIC_AGENT_YOLO`) + **command/channels** (`generateAgentEnv` emission).
|
||||
- webUI binds dropdowns/toggles to those four roster fields. One launcher, no new launch path.
|
||||
|
||||
## Results
|
||||
|
||||
- TDD: spec first (`comms-onboarding.spec.ts`, 6 new `resolveCommsBlock` cases) → red → implement → green.
|
||||
- `fleet.spec.ts` subcommand-list assertion extended with `comms-block`.
|
||||
- 177 fleet+comms tests green; typecheck clean; eslint clean; prettier clean.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- Pre-existing local-only failure `uninstall.spec.ts > removeFramework > handles missing mosaicHome
|
||||
gracefully` (EACCES on `/nonexistent` as non-root) — unrelated to #633, passes in CI as root.
|
||||
- Did NOT run `mosaic update` / anything auto-reseed: installed CLI still 0.0.40 (roster-wipe live
|
||||
until mos-claude-0 ships 0.0.41). All work is in-repo + vitest, never touches the live mosaic home.
|
||||
@@ -1,25 +0,0 @@
|
||||
# Scratchpad — fleet-personas spec timeout
|
||||
|
||||
## Objective
|
||||
|
||||
Raise the `@mosaicstack/mosaic` Vitest timeout to 30s at config level so filesystem-backed fleet drift-guard specs (`fleet-personas`, `fleet-profiles`, and siblings) stop false-reding under contended CI.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Move timeout policy into `packages/mosaic/vitest.config.ts` with `testTimeout: 30_000`.
|
||||
2. Remove the narrower `fleet-personas.spec.ts` local override so PR #677 fixes the suite class, not one file.
|
||||
3. Run targeted fleet specs plus typecheck/lint/format gates.
|
||||
4. Commit, queue guard, push, PR update.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet-personas.spec.ts` — pass (8 tests; initial narrow fix).
|
||||
- `pnpm typecheck` — pass (41 tasks; initial narrow fix).
|
||||
- `pnpm lint` — pass (23 tasks; initial narrow fix).
|
||||
- `pnpm format:check` — pass after formatting this scratchpad (initial narrow fix).
|
||||
- Package-wide timeout follow-up:
|
||||
- `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet-personas.spec.ts src/commands/fleet-profiles.spec.ts` — pass (24 tests).
|
||||
- `pnpm --filter @mosaicstack/mosaic test` — pass (44 files / 618 tests).
|
||||
- `pnpm typecheck` — pass (41 tasks).
|
||||
- `pnpm lint` — pass (23 tasks).
|
||||
- `pnpm format:check` — pass.
|
||||
@@ -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
|
||||
@@ -1,52 +0,0 @@
|
||||
# FED-M3-05 — Federation List Verb Scratchpad
|
||||
|
||||
## Objective
|
||||
|
||||
Implement `POST /api/federation/v1/list/:resource`.
|
||||
|
||||
## Scope
|
||||
|
||||
- Wire `FederationAuthGuard` → `FederationScopeService` → read-only list query layer.
|
||||
- Apply `max_rows_per_query` row cap and return pagination metadata when truncated.
|
||||
- Tag returned rows with `_source: "local"`.
|
||||
- Keep audit writes deferred to M4.
|
||||
- No request/response body persistence.
|
||||
|
||||
## Base / branch
|
||||
|
||||
- Branch: `feat/federation-m3-verb-list`
|
||||
- Base: `main` after M3-04 scope service merged via PR #672 (`c739256a`).
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- Added `ListController` under `apps/gateway/src/federation/server/verbs/`.
|
||||
- Added `FederationListQueryService` as the read-only query layer and native RBAC evaluator.
|
||||
- Query resources supported in M3 list path:
|
||||
- `tasks`: project/mission scoped tasks visible through personal/team project access.
|
||||
- `notes`: non-empty `mission_tasks.notes` rows visible through personal/team mission access.
|
||||
- `memory`: user-owned `insights` and `preferences` rows.
|
||||
- `credentials` / `api_keys`: denied by native RBAC in M3 even if present in scope; sensitive-resource implementation is not part of FED-M3-05.
|
||||
- Cursor pagination uses an opaque base64url keyset cursor over `(createdAt, id)`; DB reads fetch at most `limit + 1` rows per resource query.
|
||||
- Reviewer isolation fix: `mission_tasks.notes` rows are always constrained by `missionTasks.userId = subjectUserId` and accessible mission IDs; team scope narrows missions but never widens to other users' mission task notes.
|
||||
- Follow-up review fix: memory listing now uses deterministic table-block pagination (`insights` first, then `preferences`) with cursor source metadata, so one table's cursor is never applied to the other.
|
||||
- Follow-up hardening: missing auth-guard context returns a structured federation `unauthorized` envelope; unsupported resources and non-encodable truncated cursors throw instead of silently crashing/truncating.
|
||||
|
||||
## Tests
|
||||
|
||||
- `pnpm --filter @mosaicstack/gateway test -- list.controller.spec.ts list-query.service.spec.ts` — PASS (16 tests, including PGlite regression coverage for team-scoped notes isolation, unauthorized mission notes exclusion, `includePersonal: false`, deterministic memory pagination, missing context envelope, unsupported resource, and cursor encode failure).
|
||||
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
|
||||
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
|
||||
- `pnpm format:check` — PASS.
|
||||
- `pnpm typecheck` — PASS (41/41 turbo tasks).
|
||||
- `pnpm lint` — PASS (23/23 turbo tasks).
|
||||
- `pnpm --filter @mosaicstack/gateway test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup cannot connect to local PostgreSQL on `localhost:5433`. New list tests pass; failure is outside FED-M3-05.
|
||||
|
||||
## Review evidence
|
||||
|
||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS after follow-up remediation; approve, no findings.
|
||||
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS after follow-up remediation; risk level none, no findings.
|
||||
- Security-review note: read-path audit logging remains intentionally deferred to M4 per orchestrator clarification and FED-M3-05 scope.
|
||||
|
||||
## Risks / follow-up
|
||||
|
||||
- Read-path audit logging remains intentionally deferred to M4.
|
||||
@@ -1,65 +0,0 @@
|
||||
# FED-M3-07 — Capabilities Verb Scratchpad
|
||||
|
||||
## Objective
|
||||
|
||||
Implement `GET /api/federation/v1/capabilities` in `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`.
|
||||
|
||||
## Scope
|
||||
|
||||
- Add read-only capabilities controller under federation server verbs.
|
||||
- Use `FederationAuthGuard` only; active grant is sufficient and no native RBAC/scope-service eval runs.
|
||||
- Response shape: `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope.
|
||||
- Register controller in `FederationModule`.
|
||||
- Unit-test happy path, defaults, no-context guard seam, and invalid scope handling.
|
||||
|
||||
## Constraints / assumptions
|
||||
|
||||
- Issue: #462.
|
||||
- Branch: `feat/federation-m3-verb-capabilities` from `origin/main` (`3eeed04e`).
|
||||
- Depends on M3-03 auth guard; guard attaches `request.federationContext.scope` after active-grant validation.
|
||||
- ASSUMPTION: `supported_verbs` is the M3 verb set from `@mosaicstack/types` (`list`, `get`, `capabilities`).
|
||||
- ASSUMPTION: `filters`/`rate_limit` are intentionally omitted for FED-M3-07 because the card’s response shape lists only the four required fields.
|
||||
- Budget: no explicit hard cap from orchestrator; working cap ~4K-8K tokens for card implementation + tests + PR cycle.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Write controller unit tests first.
|
||||
2. Implement controller and module registration.
|
||||
3. Run scoped tests + typecheck/lint/format.
|
||||
4. Run Codex code/security review and remediate.
|
||||
5. Commit, queue guard, push, PR via wrapper.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-06-24: Intake complete; fresh worktree created from origin/main.
|
||||
- 2026-06-24: Added `CapabilitiesController`, registered it in `FederationModule`, and added 5 unit tests.
|
||||
- 2026-06-24: Code/security reviews passed with no findings.
|
||||
|
||||
## Tests run
|
||||
|
||||
- `pnpm --filter @mosaicstack/gateway test -- capabilities.controller.spec.ts` — PASS (5 tests).
|
||||
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
|
||||
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
|
||||
- `pnpm format:check` — PASS.
|
||||
- `pnpm typecheck` — PASS (41/41 turbo tasks).
|
||||
- `pnpm lint` — PASS (23/23 turbo tasks).
|
||||
- `pnpm test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup hit PostgreSQL connection/schema state for the `messages` table. Changed capabilities tests passed; failure is outside FED-M3-07 surface. No `fleet-personas.spec` flake encountered.
|
||||
|
||||
## Review evidence
|
||||
|
||||
- `~/.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.
|
||||
|
||||
## Risks / blockers
|
||||
|
||||
- Full repo `pnpm test` may hit known `fleet-personas.spec` flake per orchestrator; ignore that specific flake if encountered.
|
||||
- Previous card saw local DB schema issue in `cross-user-isolation.test.ts`; scoped capabilities tests should be authoritative for this surface.
|
||||
|
||||
## Acceptance evidence mapping
|
||||
|
||||
| Acceptance criterion | Evidence |
|
||||
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| GET `/api/federation/v1/capabilities` exists | Route metadata test in `capabilities.controller.spec.ts`; scoped test PASS |
|
||||
| Uses active-grant auth guard and no RBAC eval | Guard metadata test confirms only `FederationAuthGuard`; controller has no service injections/RBAC calls; scoped test PASS |
|
||||
| Response enumerates resources/excluded/max rows/supported verbs from scope | Happy-path/default scope tests + response schema parse; scoped test PASS |
|
||||
| Read-only/no persistence side effects | Controller only parses request `federationContext.scope` and returns a DTO; no DB/service dependency; code review PASS |
|
||||
@@ -1,67 +0,0 @@
|
||||
# FED-M3-09 — Query Source Service Scratchpad
|
||||
|
||||
## Objective
|
||||
|
||||
Implement `apps/gateway/src/federation/client/query-source.service.ts` for `source: "local" | "federated:<host>" | "all"` routing.
|
||||
|
||||
## Scope
|
||||
|
||||
- Add QuerySourceService in gateway federation client layer.
|
||||
- Unit-test local-only, single federated peer, all-source fan-out/merge, and per-peer partial failures.
|
||||
- Keep `docs/federation/TASKS.md` read-only per project agent guidance.
|
||||
|
||||
## Constraints / assumptions
|
||||
|
||||
- Issue: #462.
|
||||
- Branch: `feat/federation-m3-query-source` from `origin/main` (`e0e7be70`).
|
||||
- ASSUMPTION: `federated:<host>` should match active outbound peers by `commonName` first and by `endpointUrl` host/hostname as compatibility fallback; source tags use `peer.commonName` per `@mosaicstack/types` source-tag docs.
|
||||
- ASSUMPTION: QuerySourceService provides list/fan-out behavior; get/source routing can be layered later because card acceptance says merge rows.
|
||||
- ASSUMPTION: `source: "all"` cannot safely return a single continuation cursor for multiple sub-sources; any subquery cursor marks the merged response `_partial: true` + `_truncated: true` while omitting `nextCursor`.
|
||||
- Budget: no explicit hard cap from orchestrator; working cap ~8K-12K tokens for card 1 implementation + tests + PR cycle.
|
||||
- OpenBrain unavailable: credential loader failed with missing `/home/jarvis/.config/mosaic/credentials.json`; not blocking code delivery.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Review federation client/types/db patterns.
|
||||
2. Write unit tests for source behavior.
|
||||
3. Implement QuerySourceService and export/register it in FederationModule.
|
||||
4. Run scoped tests, typecheck, lint, format.
|
||||
5. Run codex uncommitted review and remediate.
|
||||
6. Commit, queue guard, push, PR via wrapper.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-06-24: Intake complete; using isolated worktree to avoid dirty orchestrator files in original checkout.
|
||||
- 2026-06-24: Added QuerySourceService, module export, barrel export, and 7 unit tests.
|
||||
- 2026-06-24: First Codex review found pagination and port-host matching issues; both remediated with tests.
|
||||
|
||||
## Tests run
|
||||
|
||||
- `pnpm --filter @mosaicstack/gateway test -- query-source.service.spec.ts` — PASS (7 tests).
|
||||
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
|
||||
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
|
||||
- `pnpm format:check` — PASS.
|
||||
- `pnpm typecheck` — PASS (41/41 turbo tasks).
|
||||
- `pnpm lint` — PASS (23/23 turbo tasks).
|
||||
- `pnpm test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup hit `relation "messages" does not exist` against local PostgreSQL. Changed QuerySource unit tests passed; failure is outside FED-M3-09 surface and appears tied to local DB schema state.
|
||||
|
||||
## Review evidence
|
||||
|
||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — first pass request-changes, 2 should-fix findings (all-source cursor handling; endpoint port host matching).
|
||||
- Remediation: `_partial` + `_truncated` when any all-source subquery has `nextCursor`; endpoint match accepts URL `host` and `hostname`; added tests for both.
|
||||
- `~/.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.
|
||||
|
||||
## Risks / blockers
|
||||
|
||||
- Federation query layer is not yet wired; service API needs to be stable and easy to compose.
|
||||
- Must avoid hard-failing `source: all` on remote peer failures.
|
||||
|
||||
## Acceptance evidence mapping
|
||||
|
||||
| Acceptance criterion | Evidence |
|
||||
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| local source returns local rows tagged `_source: local` | `query-source.service.spec.ts` local test; scoped test PASS |
|
||||
| `federated:<host>` queries selected peer and tags rows with peer source | `query-source.service.spec.ts` commonName/endpoint-host tests; scoped test PASS |
|
||||
| `all` fans out local + active outbound peers in parallel and merges tagged rows | `query-source.service.spec.ts` all-source call-order/merge test; scoped test PASS |
|
||||
| per-peer failure on `all` returns `_partial: true`, not throw | `query-source.service.spec.ts` peer failure test; scoped test PASS |
|
||||
@@ -1,29 +0,0 @@
|
||||
# F3-m3 — `mosaic update` re-seeds framework + relaunches agents (R13)
|
||||
|
||||
- **Issue:** #609 · **Branch:** `feat/f3-m3-update-reseed`
|
||||
|
||||
## Gap (found in 0.0.39 production validation)
|
||||
|
||||
`mosaic update` installs the new npm CLI but never re-seeds `~/.config/mosaic/` from the package's
|
||||
bundled `framework/`. So the shipped custom Pi harness (agent-name export + native HB, 0.0.39) stays
|
||||
DORMANT until a re-seed — operators get the new CLI on a stale framework.
|
||||
|
||||
## Implementation
|
||||
|
||||
- `update-checker.ts`: `resolveBundledFrameworkRoot()`, `buildReseedCommand()` (install.sh in
|
||||
`MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep` — the P4 data-safe reconcile), `runFrameworkReseed()`,
|
||||
`readRosterAgentNames()`, `buildRelaunchCommands()` (systemctl --user restart per agent).
|
||||
- `cli.ts` `update`: after a successful CLI install that includes `@mosaicstack/mosaic`, re-seed the
|
||||
framework (default-on; `--no-reseed` to skip). Then either `--relaunch` (restart rostered agents) or
|
||||
print clear guidance to run `mosaic update --relaunch` / `mosaic fleet restart`.
|
||||
|
||||
## Flow
|
||||
|
||||
`update CLI → re-seed framework (data-safe) → relaunch agents (opt-in)` — closes R13, activates the
|
||||
native harness for every operator.
|
||||
|
||||
## Verification
|
||||
|
||||
- 6 new unit tests (reseed command/env, relaunch commands, roster parse, missing-installer guard).
|
||||
- 19 runtime + 26 launch tests still green; tsc/eslint/prettier clean.
|
||||
- Data-safety of the sync is already proven (P4 5-fixture matrix + live dragon-lin validation).
|
||||
@@ -1,30 +0,0 @@
|
||||
# F4 — Orchestrator chat connector + Matrix (#616)
|
||||
|
||||
- **Issue:** #616 · **Branch:** `feat/f4-matrix-connector` (off main; independent of #615) · **Doctrine:** north-star #613.
|
||||
|
||||
## Phase 1 (this PR) — abstraction + scaffold
|
||||
|
||||
- `src/fleet/connectors/types.ts`: `OrchestratorConnector` (send/subscribe/health) + message/config types; thread-aware via optional `threadId`; `DEFAULT_CONNECTOR_KIND=tmux`.
|
||||
- `src/fleet/connectors/registry.ts`: extensible factory registry; `resolveConnectorKind` (defaults tmux, back-compat); `createConnector` throws `ConnectorNotImplementedError` until Phase 2 registers factories.
|
||||
- `roster.schema.json`: optional `connector` block (tmux|discord|matrix; matrix homeserver/user/room; secrets via env, never roster).
|
||||
- Design doc `docs/fleet/f4-matrix-connector.md`: interface, config, Matrix CS-API mapping, Conduit-default infra, phasing.
|
||||
- **No fleet.ts changes** → self-contained, zero conflict with stacked #615.
|
||||
|
||||
## Verification
|
||||
|
||||
- 7 connector tests green; tsc/eslint/prettier/sanitize clean; schema valid JSON.
|
||||
|
||||
## Phase 2+ (follow-ups, in the doc)
|
||||
|
||||
Matrix CS-API client (fetch send/sync/health) + factory; init/configure connector-selection UX + roster-parse wiring; systemd launch wiring; Conduit deploy guide; first-party Mosaic Discord (threads) as a connector.
|
||||
|
||||
## Phase 2a (feat/f4-matrix-client, stacked on #617) — Matrix CS-API client
|
||||
|
||||
- `src/fleet/connectors/matrix.ts`: `MatrixConnector implements OrchestratorConnector` over the Matrix
|
||||
client-server API (injectable fetch, no SDK). `send` → PUT m.room.message (thread-aware); `subscribe`
|
||||
→ /sync long-poll loop using the pure `parseSyncResponse`; `health` → /versions + /whoami.
|
||||
`registerMatrixConnector(env)` registers the factory (token from MATRIX_ACCESS_TOKEN, never roster).
|
||||
- Pure helpers `buildMessageBody` + `parseSyncResponse` make send/receive unit-testable.
|
||||
- 13 Matrix tests + 7 registry = 20 connector tests green; tsc/eslint/prettier clean.
|
||||
- Remaining Phase 2: init/configure connector-selection UX + roster-parse wiring (touches fleet.ts —
|
||||
after #615); systemd launch wiring; Conduit deploy guide.
|
||||
@@ -1,54 +0,0 @@
|
||||
# Fleet CLI Local Canary Review Fixes
|
||||
|
||||
## Objective
|
||||
|
||||
Fix only the two should-fix code review findings:
|
||||
|
||||
1. Ensure `@mosaicstack/mosaic` declares `yaml` and lockfile state is current.
|
||||
2. Validate `mosaic agent status [agent]` against the fleet roster before constructing/running the tmux target.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not modify `docs/TASKS.md`.
|
||||
- Leave changes uncommitted.
|
||||
- Run requested formatting and quality gates.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Inspect manifest/lockfile state for `yaml`.
|
||||
2. Add failing regression test for `mosaic agent status typo`.
|
||||
3. Patch `registerFleetAgentCommands` status validation.
|
||||
4. Format touched files.
|
||||
5. Run requested tests, typecheck, and lint.
|
||||
6. Review final diff.
|
||||
|
||||
## Progress
|
||||
|
||||
- Loaded required repo/global/runtime instructions.
|
||||
- Confirmed `packages/mosaic/package.json` already declares `yaml`.
|
||||
- Confirmed `pnpm-lock.yaml` already has `packages/mosaic` importer entry for `yaml`.
|
||||
- Found `registerFleetAgentCommands` status path does not validate agent before building tmux target.
|
||||
|
||||
## Verification
|
||||
|
||||
- TDD red check: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts`
|
||||
failed before the production fix because `mosaic agent status typo` resolved instead of
|
||||
rejecting.
|
||||
- Focused green check: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts`
|
||||
passed after adding roster validation.
|
||||
- Formatting: `pnpm exec prettier --write packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts docs/scratchpads/fleet-cli-local-canary-review-fixes.md`
|
||||
completed with all files unchanged.
|
||||
- Requested tests: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts src/cli-smoke.spec.ts`
|
||||
passed with 36 tests.
|
||||
- Baseline typecheck: `pnpm typecheck` passed.
|
||||
- Baseline lint: `pnpm lint` passed.
|
||||
- Independent review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`
|
||||
returned approve with 0 findings. Note: reviewer reported broader context inspection was limited
|
||||
by its read-only sandbox, so review was based on the supplied diff.
|
||||
- `docs/TASKS.md` has no diff.
|
||||
|
||||
## Risks
|
||||
|
||||
- `docs/TASKS.md` intentionally untouched per user instruction.
|
||||
- Review finding 1 required no file edit: `packages/mosaic/package.json` already declares
|
||||
`yaml`, and the `packages/mosaic` importer in `pnpm-lock.yaml` already includes `yaml`.
|
||||
@@ -1,31 +0,0 @@
|
||||
# Fleet onboarding-injection — comms cheat-sheet + peer roster (#620)
|
||||
|
||||
- **Issue:** #620 · **Branch:** `feat/fleet-comms-onboarding` (off main). Root cause of Mos's failed first send.
|
||||
|
||||
## What
|
||||
|
||||
Inject a `# Fleet Comms` block into each spawned fleet agent's system prompt (via composeContract — the
|
||||
runtime-agnostic path every `mosaic yolo <runtime>` agent hits), so it boots knowing how to reach peers.
|
||||
|
||||
- `src/fleet/comms-onboarding.ts` (standalone, no fleet.ts coupling):
|
||||
- `parseRosterAgents` (name/class/host/ssh, lenient), `renderPeerReach` (same-host `-s` vs cross-host
|
||||
`-H <ssh> -s`), `buildFleetCommsBlock` (self [host:session] identity + agent-send path + peer table +
|
||||
FLIP-to-reply + `agent send --verify`=ACCEPTED), `readFleetCommsBlock` (reads roster.yaml; '' if not a member).
|
||||
- `composeContract` appends it only when MOSAIC_AGENT_NAME is set + the agent is in the roster.
|
||||
- `roster.schema.json`: optional per-agent `host` + `ssh` (cross-host addresses; manual = pre-federation
|
||||
stopgap, federation/W1 auto-discovers later).
|
||||
|
||||
## Acceptance criteria (Mos) — all covered
|
||||
|
||||
1. own [host:session] + agent-send path + peer roster ✓
|
||||
2. cross-host correctness: local→`-s` (no -H); remote→`-H <ssh> -s` ✓ (concrete coder0-0@dragon-lin)
|
||||
3. FLIP-the-preamble reply rule ✓
|
||||
4. `agent send --verify` = ACCEPTED ✓
|
||||
5. no `-L` (default socket); matches live tooling ✓
|
||||
|
||||
## Verification
|
||||
|
||||
- 10 onboarding unit tests (parse, render local/remote/fallback/equal-host, build, situational read) +
|
||||
2 composeContract situational tests (injects for fleet agent w/ correct cross-host addr; no-op when
|
||||
MOSAIC_AGENT_NAME unset). tsc/eslint/prettier/sanitize clean.
|
||||
- Post-merge validation: Mos spawns a real w-jarvis agent → first-try reach to coder0-0@dragon-lin + a local peer.
|
||||
@@ -1,26 +0,0 @@
|
||||
# Fleet enhancer role + two-agent floor (#614)
|
||||
|
||||
- **Issue:** #614 · **Branch:** `feat/fleet-enhancer-floor` (stacked on #612 `feat/fleet-polish-bundle`)
|
||||
- **Doctrine:** `docs/fleet/north-star.md` (PR #613) — every fleet = orchestrator + enhancer minimum.
|
||||
|
||||
## Changes
|
||||
|
||||
- **Presets** (general, coding, research, hybrid): add `enhancer` (claude, `class: enhancer`,
|
||||
`persistent_persona: true`) as a core always-on agent alongside the orchestrator. minimal/local-canary
|
||||
unchanged.
|
||||
- **fleet.ts**: `countEnhancers` helper; init guarantee extended — non-minimal profiles must yield
|
||||
exactly 1 orchestrator AND >=1 enhancer (hard-fail otherwise); `removeAgentFromRoster` refuses to drop
|
||||
the sole enhancer (symmetric with the sole-orchestrator guard) so the floor holds at runtime, not just init.
|
||||
- **Role doc**: `framework/fleet/roles/enhancer.md` — the enhancer mandate (monitor → analyze → plan →
|
||||
upgrade tools/skills/harness WITH orchestrator → file Mosaic Stack bug reports) + boundaries (does NOT
|
||||
code or review).
|
||||
|
||||
## Verification
|
||||
|
||||
- 155 fleet tests green (new: countEnhancers; remove-sole-enhancer guard; remove-allows-when-another;
|
||||
init two-agent-floor; every-non-minimal-preset-has-enhancer; updated preset rosters). tsc/eslint/
|
||||
prettier/sanitize clean. TDD on the init guarantee + remove protection.
|
||||
|
||||
## Stacking
|
||||
|
||||
Built on #612's init-R5 code. PR shows #612 + enhancer until #612 merges; then rebase onto main → clean.
|
||||
@@ -1,100 +0,0 @@
|
||||
# Scratchpad — Fleet Phase 2: Observability (W-FLEET)
|
||||
|
||||
> Append-only. Mission `mvp-20260312` / workstream W-FLEET.
|
||||
> Lead: Jarvis (Claude) at `W-jarvis:mos-claude-18`. Coordinating with `jwoltje@dragon-lin:coder0-0`.
|
||||
|
||||
## Mission prompt (2026-06-20)
|
||||
|
||||
Establish the north star for the Mosaic Fleet feature and prepare Phase-2 observability
|
||||
for delivery. The USC tmux PoC is the proven base. Jason granted lead authority:
|
||||
"The fleet is a great way to actually build the MVP — we are building the system that
|
||||
builds the system." Dogfood actual agent construction + ad-hoc deployment; coordinate
|
||||
with a second agent on `dragon-lin`.
|
||||
|
||||
## Decisions of record (with Jason, 2026-06-20)
|
||||
|
||||
- Agent model: config defines, session runs (gateway = definition/identity/auth; tmux = runtime).
|
||||
- Tenancy: multi-tenant from the start; isolation = per-tenant Linux uid.
|
||||
- Health: heartbeat required; dogfood stub implements protocol now.
|
||||
- Lifecycle: hybrid (core always-on + ephemeral workers).
|
||||
- Observation: read-only default, opt-in takeover.
|
||||
- Multi-host: designed-for day one; control plane rides federation (W1), not a bespoke broker.
|
||||
- Delivery: CLI-first, dogfood on the live stub fleet; webUI deferred to Phase 5.
|
||||
- Fleet is dual-role: product AND means of production (bootstrapping the MVP).
|
||||
- Code review = **dual-engine**: Claude **and** gpt-5.5/Codex, run together (Jason: the
|
||||
combination produces the best results). Launch reviewers via `mosaic yolo pi` / `codex`
|
||||
(proven path) or `~/.config/mosaic/tools/codex/codex-code-review.sh`. Applies to all
|
||||
code-review gates incl. FLEET-OBS-008. Per Jason 2026-06-20.
|
||||
- Worktree discipline: do fleet work in `~/src/mosaicstack-stack-worktrees/<branch>`, NOT
|
||||
the shared main checkout — concurrent processes mutate `main` there (learned 2026-06-20).
|
||||
|
||||
## Environment facts (verified 2026-06-20)
|
||||
|
||||
- Fleet is live on `W-jarvis` (uid 1000, `jarvis`, `Linger=yes`) on tmux socket
|
||||
`mosaic-fleet`: `_holder`, `canary-pi`, `dogfood-coder`, `dogfood-orchestrator`,
|
||||
`dogfood-reviewer`. All panes run `~/.config/mosaic/fleet/dogfood-agent.py` (stub),
|
||||
including `canary-pi` (roster says runtime=pi → **drift**).
|
||||
- Holder + `mosaic-agent@*` units are `active (exited)` but `UnitFileState=disabled`
|
||||
(reboot loses fleet → boot-enable gap to surface).
|
||||
- Observation blocked by: isolated socket (hidden from default `tmux ls`), `capture-pane`
|
||||
blank for TUIs, `attach` being read-write + resizing.
|
||||
- Second agent: `jwoltje@dragon-lin`, session `coder0-0` (group `coder0`), running `node`,
|
||||
default socket. ssh forward reach confirmed.
|
||||
|
||||
## Governance / collision-safety
|
||||
|
||||
- `mosaicstack-stack` has active mission `mvp-20260312` with single-writer locks on
|
||||
`docs/MISSION-MANIFEST.md`, `docs/TASKS.md`, `docs/scratchpads/mvp-20260312.md`.
|
||||
- This workstream touches NONE of those. All Fleet docs scoped under `docs/fleet/` +
|
||||
this scratchpad. Rollup row proposed, not written.
|
||||
|
||||
## Session log
|
||||
|
||||
- 2026-06-20: Researched AI guide + fleet code + live state. Established north star with
|
||||
Jason (8 forks decided). Branched `feat/fleet-observability`. Persisted
|
||||
`docs/fleet/{north-star.md,PRD.md,TASKS.md}` + this scratchpad. Next: establish comms
|
||||
with dragon-lin coder, commit docs, begin Phase-2 delivery (heartbeat + `fleet ps`).
|
||||
- 2026-06-20 (session 2): Built Phase-2 CLI via worker (commit ab47831): `fleet ps`,
|
||||
`agent watch`, `agent send --verify`, 62 tests. LIVE-verified `fleet ps` on
|
||||
mosaic-fleet — correctly flagged canary-pi DRIFT + BOOT-ENABLE, tenant_id+host in JSON.
|
||||
Heartbeat responder added to dogfood-agent.py (FLEET-OBS-002) — `fleet ps` HB now
|
||||
`healthy` for all 4 agents.
|
||||
- Coordination: dual-engine-reviewed (Claude+Codex) and merged framework PRs #572
|
||||
(sanitization gate) + #575 (CONSTITUTION extraction) as Lead. Codex caught an Alpine
|
||||
blocker on #572 (refuted by CI); Claude caught a CI-breaking format failure on #575.
|
||||
- **FINDINGS (north-star / Phase-3 blockers):**
|
||||
1. Ad-hoc `mosaic yolo {codex,pi}` via `start-agent-session.sh` DIE immediately in a
|
||||
detached tmux pane (codex: "stdin is not a terminal"; pi: same). Only the python stub
|
||||
survives. => Real runtimes have NEVER run durably in the fleet. Launch path (PATH/TTY
|
||||
in the detached shell) must be fixed before Phase-3 real-runtime swap. `fleet ps`
|
||||
caught both dead panes instantly (tool validated).
|
||||
2. `MOSAIC_AGENT_NAME` (set in systemd EnvironmentFile) is NOT propagated into tmux's
|
||||
global env, so agents defaulted to `unknown`. Worked around in dogfood-agent.py via
|
||||
tmux session-name fallback; the systemd/tmux env handoff needs a real fix.
|
||||
- Next: rebase on merged main, open Phase-2 PR, dual-engine review, merge, close
|
||||
`fleet-observability-1`. Defer launch-path + env-propagation fixes to Phase 3.
|
||||
- 2026-06-21 (session 3): Phase-2 PR #579 merged (3 dual-engine rounds hardened
|
||||
verify+watch). Then closed the launch-path question with Jason's input — CORRECTING
|
||||
earlier findings:
|
||||
- The ad-hoc launch deaths were NOT a fundamental TTY blocker: (a) codex was a stale
|
||||
version (Jason updated it); (b) pi was misconfigured to Claude auth (Jason removed it;
|
||||
default is now Codex). The REAL durable-launch bug is **PATH**: the detached tmux
|
||||
launch shell is login+non-interactive, so it misses `~/.npm-global/bin` (added only in
|
||||
`~/.bashrc`) -> `mosaic: command not found` (127) -> pane dies. tmux panes inherit the
|
||||
tmux _server_ env, so PATH must be baked into the pane command.
|
||||
- **Durable real-agent recipe (validated live on gpt-5.5, Claude-free):**
|
||||
`mosaic yolo pi --model openai-codex/gpt-5.5:high` — pi tolerates detached tmux; a raw
|
||||
interactive TUI (codex CLI) exits without an attached client. Status line confirmed
|
||||
`(openai-codex) gpt-5.5 • high`.
|
||||
- PATH fix landed in `start-agent-session.sh` (commit 32efc13, branch
|
||||
feat/fleet-launch-path): derive runtime-bin prefix (MOSAIC_RUNTIME_BIN | npm prefix |
|
||||
~/.npm-global/bin | ~/.local/bin), bake `export PATH=...; exec <cmd>` into the pane;
|
||||
`exec` also fixes the drift false-positive. Live-tested under stripped PATH -> durable.
|
||||
- Boot-survival: Jason ran `systemctl --user enable` (+ linger). TODO: auto-enable in
|
||||
**fleet init** so operators never have to remember it (agentic-enhancement cycle).
|
||||
- Future custom Pi harness build: pi cannot self-report its model (track
|
||||
runtime/model/effort as fleet metadata); drift detection should recognize `node` as
|
||||
pi's pane command (a node-wrapped pane can currently read as drift).
|
||||
- Findings recorded in AI Guide playbooks/tmux-fleet.md (aiguide PR #7, merged).
|
||||
- Policy: avoid Claude outside Claude Code (API pricing for alt-harness use) — fleet
|
||||
runtimes default to Codex / pi-on-Codex; Claude stays in Claude Code only.
|
||||
@@ -1,20 +0,0 @@
|
||||
# Fleet-polish bundle — boot-survival symmetry (#611)
|
||||
|
||||
- **Issue:** #611 · **Branch:** `feat/fleet-polish-bundle` · From the Lead's Codex symmetry-gap finding.
|
||||
|
||||
## Three fixes
|
||||
|
||||
1. **disable-on-remove (BUG, TDD).** `fleet remove` stopped + deleted roster/env/heartbeat but never
|
||||
`systemctl --user disable mosaic-agent@NAME.service` → a removed-but-enabled unit could resurrect on
|
||||
reboot pointing at deleted config. Fix: `buildSystemdDisableCommand` + disable in `remove`
|
||||
(best-effort, gated on !--keep-files).
|
||||
2. **add-enable.** `fleet add` now enables the new agent's unit for boot-survival (best-effort,
|
||||
independent of --start) — symmetry with disable-on-remove.
|
||||
3. **init-R5 guarantee.** `fleet init --write` now FAILS HARD when a non-minimal profile doesn't yield
|
||||
exactly one orchestrator (was a soft warning). `minimal` (sanctioned no-orchestrator) still allowed.
|
||||
|
||||
## Verification
|
||||
|
||||
- 4 new tests (disable builder; remove-invokes-disable; add-invokes-enable; init general → exactly 1
|
||||
orchestrator) + 147 existing fleet tests green (151 total). tsc/eslint/prettier clean.
|
||||
- TDD on the disable bug per contract.
|
||||
@@ -1,28 +0,0 @@
|
||||
# Fleet stand-up fixes — model_hint→--model + socket-default trap (#626)
|
||||
|
||||
- **Issue:** #626 · **Branch:** `feat/fleet-standup-fixes` (off main). PoC-blocking, before doctrine doc.
|
||||
|
||||
## FIX 1 — model_hint consumed
|
||||
|
||||
- generateAgentEnv emits `MOSAIC_AGENT_MODEL=<modelHint>` (bare empty when unset).
|
||||
- start-agent-session.sh default command → `mosaic yolo $RUNTIME ${MOSAIC_AGENT_MODEL:+--model $MOSAIC_AGENT_MODEL}`.
|
||||
→ pi workers launch with `--model openai-codex/gpt-5.5:high`.
|
||||
|
||||
## FIX 2 — socket default trap (absent ⇒ literal default socket, no -L everywhere)
|
||||
|
||||
- THE TRAP (3 sites): parseRosterText fallback was DEFAULT_SOCKET_NAME; systemd unit had
|
||||
`Environment=MOSAIC_TMUX_SOCKET=mosaic-fleet` + `ExecStop ${…:-mosaic-fleet}`; start-agent-session
|
||||
defaulted `:-mosaic-fleet`. All fixed → absent socket = '' = default tmux socket (no -L).
|
||||
- `socketArgs(name)` helper → `name ? ['-L', name] : []`; replaced all ~15 -L render sites in fleet.ts.
|
||||
- shellEnvValue('') now emits a **bare** `VAR=` (not `''`) — unambiguous empty in systemd EnvironmentFile
|
||||
(a quoted '' could become a literal socket named "''").
|
||||
- start-agent-session.sh: `_tmux` wrapper passes -L only when socket set; mosaic-agent@.service: dropped the
|
||||
socket default + conditional ExecStop. So spawn == observe == onboarding cheat-sheet.
|
||||
- CONTAINMENT: all 6 shipped presets set socket_name: mosaic-fleet explicitly → unaffected; only
|
||||
socket-less rosters (the PoC) get default-socket behavior. DEFAULT_SOCKET_NAME exported for explicit use.
|
||||
|
||||
## Verification
|
||||
|
||||
- 158 fleet + 201 fleet-adjacent tests green; new: socketArgs none/named, model_hint→env, explicit-socket
|
||||
renders -L, socket-less env bare. tsc/eslint/prettier/sanitize clean. Shell bash -n + end-to-end sim
|
||||
(socket-less→no -L, model→--model).
|
||||
@@ -1,66 +0,0 @@
|
||||
# H1 — heartbeat readiness detection
|
||||
|
||||
## Objective
|
||||
|
||||
Add runtime-agnostic readiness classification to `mosaic fleet ps` so an agent can be reported as working/idle/stuck/stale/dead/unknown instead of treating pane liveness as progress.
|
||||
|
||||
## Scope
|
||||
|
||||
- `packages/mosaic/src/commands/fleet.ts`
|
||||
- exported readiness state/types/default thresholds/helpers/classifier
|
||||
- `AgentPsRow.readiness` additive JSON field
|
||||
- table HB column and IDLE/STUCK flags
|
||||
- `packages/mosaic/src/commands/fleet.spec.ts`
|
||||
- pure classifier branch/boundary coverage
|
||||
- threshold helper coverage
|
||||
- legitimate render/JSON assertion updates for new HB text
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Branches covered: dead, unknown, stale, busy working, null-idle working, stuck boundary, idle boundary, working below idle.
|
||||
- Threshold env helpers default to 300s/900s and honor positive integer env values.
|
||||
- `fleet ps` rows populate `readiness` for roster and unmanaged socket sessions.
|
||||
- Table HB text becomes `<age>s/<readiness>` when heartbeat age exists; remains `unknown` when absent.
|
||||
- Flags include `IDLE`/`STUCK` for matching readiness.
|
||||
- Local gates green: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, fleet vitest.
|
||||
- Pre-push queue guard passes; PR opened off `origin/main`; no merge by worker.
|
||||
|
||||
## Constraints / Assumptions
|
||||
|
||||
- Source branch: `origin/main` @ `e3adc6a`.
|
||||
- No scope creep beyond readiness detection.
|
||||
- `docs/TASKS.md` and `docs/fleet/TASKS.md` are orchestrator-owned; worker will not modify them.
|
||||
- PRD alignment source: `docs/fleet/PRD.md` Phase 2 observability; this is a refinement of heartbeat observability, preserving existing unknown/stale behavior.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Install dependencies with requested PNPM environment.
|
||||
2. Add readiness types/helpers/classifier near heartbeat constants.
|
||||
3. Add `readiness` to `AgentPsRow` and populate both row paths.
|
||||
4. Update table render and flags.
|
||||
5. Add unit tests and update affected ps render/JSON assertions.
|
||||
6. Run build precheck + required gates.
|
||||
7. Run automated independent review, remediate findings.
|
||||
8. Queue guard, push, open PR.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-06-24: Branch created from `origin/main` @ `e3adc6a`.
|
||||
- 2026-06-24: Implemented readiness thresholds/classifier, JSON row field, HB column label, and IDLE/STUCK flags.
|
||||
- 2026-06-24: Added classifier branch/boundary tests, threshold helper tests, JSON shape assertions, and readiness table rendering assertions.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
- `pnpm install --store-dir "$HOME/.pnpm-store"` — pass.
|
||||
- `npx turbo build --filter=@mosaicstack/mosaic^...` — pass, 12/12 tasks successful.
|
||||
- `pnpm typecheck` — pass, 41/41 tasks successful.
|
||||
- `pnpm lint` — pass, 23/23 tasks successful.
|
||||
- `pnpm format:check` — pass, all matched files use Prettier style.
|
||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts` — pass, 171 tests.
|
||||
- `pnpm --filter @mosaicstack/mosaic test` — pass, 39 files / 547 tests; `fleet.spec.ts` 171 tests.
|
||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings (reviewed supplied diff; sandbox file-inspection limitation noted by tool).
|
||||
|
||||
## Risks / Blockers
|
||||
|
||||
- No current blocker.
|
||||
- Review tool could not inspect repo files directly due sandbox wrapper limitation, but it reviewed the supplied diff and approved with no findings.
|
||||
@@ -1,53 +0,0 @@
|
||||
# H1b — tmux pane idle signal wiring
|
||||
|
||||
## Objective
|
||||
|
||||
Feed `classifyReadiness()` a real idle signal on tmux 3.4 by deriving `idleSeconds` from the first available tmux timestamp source: pane activity, then window activity, then session activity.
|
||||
|
||||
## Scope
|
||||
|
||||
- `packages/mosaic/src/commands/fleet.ts`
|
||||
- Extend `buildTmuxListPanesCommand()` format to include `#{window_activity}` and `#{session_activity}` after the existing fields.
|
||||
- Update `parseTmuxListPanes()` to choose the first non-empty finite positive timestamp and clamp future idle values to 0.
|
||||
- `packages/mosaic/src/commands/fleet.spec.ts`
|
||||
- Cover pane/window/session activity parsing behavior, empty-field index alignment, null idle, future clamping, math correctness, and exact tmux format.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- No changes to `classifyReadiness()`, thresholds, `AgentPsRow`, or `fleet ps` rendering.
|
||||
- No merge by worker; orchestrator routes review/merge.
|
||||
- Workers do not modify `docs/TASKS.md`.
|
||||
|
||||
## PRD Alignment
|
||||
|
||||
Aligned with `docs/fleet/PRD.md` FR-1 and acceptance criteria for truthful `mosaic fleet ps` pane/pid/idle observability.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Sync branch from latest `origin/main` and install dependencies with required pnpm env.
|
||||
2. Add/confirm reproducer tests for tmux 3.4 empty `pane_activity` and new fallback behavior.
|
||||
3. Implement the focused parser/format change only.
|
||||
4. Run required build, baseline gates, fleet vitest, and independent review.
|
||||
5. Run pre-push queue guard, push branch, and open PR to `main` with Mosaic wrapper.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-06-24: Branch `fix/fleet-pane-idle-activity` created from `origin/main` @ `ec8dd7c` after fetching.
|
||||
- 2026-06-24: Session-start generated local `.mosaic/orchestrator/*` changes on the previous release branch; stashed as `coder1 session-start state before H1b` to keep this branch clean.
|
||||
- 2026-06-24: Added TDD coverage for the tmux 3.4 production case (`pane_activity` empty, `window_activity` populated), exact new list-panes format, null/future/multiple-source behavior.
|
||||
- 2026-06-24: Implemented parser fallback without changing readiness classifier thresholds or render shape.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
- `pnpm install --store-dir "$HOME/.pnpm-store"` — pass.
|
||||
- Reproducer before implementation: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts` — failed as expected (old format, no fallback, negative future idle).
|
||||
- `npx turbo build --filter=@mosaicstack/mosaic^...` — pass, 12/12 tasks successful.
|
||||
- `pnpm typecheck` — pass, 41/41 tasks successful.
|
||||
- `pnpm lint` — pass, 23/23 tasks successful.
|
||||
- `pnpm format:check` — pass, all matched files use Prettier style.
|
||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts` — pass, 176 tests.
|
||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings (reviewed supplied diff; sandbox file-inspection limitation noted by tool).
|
||||
|
||||
## Risks / Blockers
|
||||
|
||||
- No current blocker.
|
||||
@@ -1,70 +0,0 @@
|
||||
# H2 — readiness semantics: available, not stuck
|
||||
|
||||
## Objective
|
||||
|
||||
Correct fleet readiness semantics so a healthy long-idle agent is reported as `available` (good/assignable) instead of `stuck` (fault). Reserve `stuck` in the type/JSON value space for future positive block evidence.
|
||||
|
||||
## Scope
|
||||
|
||||
- `packages/mosaic/src/commands/fleet.ts`
|
||||
- replace `idle` readiness state with `available`
|
||||
- keep `stuck` in the union but stop emitting it from idle-only heuristics
|
||||
- remove stuck threshold helper/env handling
|
||||
- remove IDLE/STUCK alarm flags from table rendering
|
||||
- `packages/mosaic/src/commands/fleet.spec.ts`
|
||||
- update classifier branch/boundary tests
|
||||
- assert very long idle maps to `available`, not `stuck`
|
||||
- update table/JSON assertions for available with no alarm flags
|
||||
- remove stuck threshold helper tests
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `classifyReadiness()` remains pure/total/never-throw and maps:
|
||||
- dead/stale/unknown unchanged
|
||||
- busy/null/undefined/non-finite idle to `working`
|
||||
- idle >= activity threshold to `available`
|
||||
- idle < activity threshold to `working`
|
||||
- No idle-derived path emits `stuck`.
|
||||
- `MOSAIC_HEARTBEAT_IDLE_THRESHOLD` remains backward compatible as the working→available activity threshold.
|
||||
- `MOSAIC_HEARTBEAT_STUCK_THRESHOLD` and helper/default are removed.
|
||||
- `fleet ps` keeps the idle-seconds column header `IDLE`, renders `available` in HB label, and does not add IDLE/STUCK warning flags.
|
||||
- Local gates green: build precheck, typecheck, lint, format:check, fleet vitest.
|
||||
- PR opened against `main`; no merge by worker.
|
||||
|
||||
## Constraints / Assumptions
|
||||
|
||||
- Source branch: `origin/main` @ `1020cfa`.
|
||||
- `docs/TASKS.md` is orchestrator-owned; worker will not modify it.
|
||||
- Documentation impact is captured in this scratchpad and PR description; no user/admin guide behavior beyond CLI readiness label semantics.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Install dependencies with requested PNPM environment.
|
||||
2. Inspect current H1/H1b readiness implementation and tests.
|
||||
3. Update classifier types/helpers/rendering.
|
||||
4. Update focused tests.
|
||||
5. Run build precheck + required gates.
|
||||
6. Run automated code review, remediate any findings.
|
||||
7. Queue guard, push, open PR.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-06-24: Branch created from `origin/main` @ `1020cfa`.
|
||||
- 2026-06-24: Replaced idle-derived `idle`/`stuck` outputs with `available`; retained `stuck` in type union for future positive block evidence.
|
||||
- 2026-06-24: Removed stuck threshold env/helper plumbing and IDLE/STUCK alarm flags.
|
||||
- 2026-06-24: Updated classifier and table-render tests for available semantics.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
- `pnpm install --store-dir "$HOME/.pnpm-store"` — pass.
|
||||
- `npx turbo build --filter=@mosaicstack/mosaic^...` — pass, 12/12 tasks successful.
|
||||
- `pnpm typecheck` — pass, 41/41 tasks successful.
|
||||
- `pnpm lint` — pass, 23/23 tasks successful.
|
||||
- `pnpm format:check` — pass, all matched files use Prettier style.
|
||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts` — pass, 177 tests.
|
||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings (reviewed supplied diff; sandbox file-inspection limitation noted by tool).
|
||||
|
||||
## Risks / Blockers
|
||||
|
||||
- No current blocker.
|
||||
- Review tool could not inspect repo files directly due sandbox wrapper limitation, but it reviewed the supplied diff and approved with no findings.
|
||||
@@ -1,19 +0,0 @@
|
||||
# north-star doctrine consolidation (#620-adjacent doc PR)
|
||||
|
||||
- **Branch:** `feat/north-star-doctrine` (off main). Source: Mos's consolidated handoff + 2 drafts (budgeting/200k/delegation + control-plane). ONE conflict-free PR per the merge-map.
|
||||
|
||||
## Applied (merge-map, in order)
|
||||
|
||||
1. Stack table: +2 rows (Central register, Budget/spend governance) after Control plane + PoC-socket-hygiene note.
|
||||
2. `## Budget & token governance` after Invariants (even-spread pacing [Jason override], hard-cap ladder, multi-sub auto-routing, historical learning, #558 CLI UX) + TTY OPS INVARIANT note.
|
||||
3. `## Control plane & central register` after Observation model (Postgres fleet schema, gateway-API access, dispatcher = forge pipeline engine + forge-exec adapter [NOT a daemon], register backs forge, board = forge BOD).
|
||||
4. Phased roadmap Phase 4/5 annotated (fleet schema migration + forge-exec; central register live).
|
||||
5. Decisions of record (2026-06-22): doctrine §1(c) bullets (200k cap, worker bound #8, delegation, budget, spend mandate, unified identity Fleet, role-based session naming) + control-plane 6c `### Control plane & central register` subgroup.
|
||||
6. Future enhancements: Matrix-future-transport (#10, F4 IS Matrix) + tmux security hardening (§5).
|
||||
7. Assumptions: doctrine §1(d) (3) + control-plane 6e (1) + release-procedure note + tracked-separately note.
|
||||
|
||||
## Conflict checklist: all ✓
|
||||
|
||||
1 Decisions-2026-06-22; order Invariants→Budget→Observation→Control plane→Roadmap; 2 stack rows; even-spread (no opportunistic/HOLD); control-plane UNHELD; forge-exec = tracked #628 post-PoC; §7 drift re-captures all present (#8/#10/#558/TTY/release).
|
||||
|
||||
## Out of scope (cited in doc + PR): #622 (spend template std), #623 (telemetry product), #625 (tenant_id schema), #628 (forge-exec adapter). Doctrine only — no implementation.
|
||||
@@ -1,43 +0,0 @@
|
||||
# P5 — Overlay composer + cross-harness (compose-contract)
|
||||
|
||||
- **Issue:** #604 · **Branch:** `feat/p5-overlay-composer` · **Lineage:** #542 → constitution alpha
|
||||
- **Requirements:** R7 (compose-contract) + R8 (cross-harness) + R9 (composer test)
|
||||
- **Design of record:** `docs/design/framework-constitution/{DESIGN.md §3.2, PRD.md §4}` (on `feat/framework-constitution-alpha`)
|
||||
|
||||
## Locked design (sequential-thinking)
|
||||
|
||||
Current `launch.ts` assembly (`buildComposedPrompt`) injects by value: mission + PRD + hard-gate +
|
||||
CONSTITUTION + AGENTS + USER + TOOLS + runtime. It does **not** inject SOUL or STANDARDS (those are
|
||||
read-on-demand per the gutted AGENTS dispatcher), and has no `.local` overlay support.
|
||||
|
||||
**Decision (ASSUMPTION — recorded for the PR):** overlays are injected as **deltas by value** under
|
||||
labeled sections; base files keep their existing residency.
|
||||
|
||||
- `USER.local.md` → appended directly under the `# User Profile` block (USER is injected).
|
||||
- `SOUL.local.md` + `STANDARDS.local.md` → a trailing `# Operator Overlays` section (their bases are
|
||||
load-on-demand, so only the small delta is injected — not the full base prose).
|
||||
- **Why:** honors DESIGN §3.2 ("model gets one pre-merged blob, no read-merge ritual") while preserving
|
||||
the P3 byte-budget tiering (don't re-inject large SOUL/STANDARDS prose). Precedence order kept: base
|
||||
layers first, operator overlays at recency.
|
||||
- Base-only is automatic when a `.local` file is absent (`readOptional`).
|
||||
|
||||
## Plan
|
||||
|
||||
| # | Task | File |
|
||||
| --- | ------------------------------------------------------------------------------------------------------ | --------------------------------------- |
|
||||
| 1 | Extract `composeContract({harness, mosaicHome})` pure fn; `buildComposedPrompt` delegates | `src/commands/launch.ts` |
|
||||
| 2 | Overlay logic (USER.local under profile; SOUL/STANDARDS.local in `# Operator Overlays`) | `src/commands/launch.ts` |
|
||||
| 3 | `mosaic compose-contract <harness>` command → prints blob to stdout | `src/commands/launch.ts` |
|
||||
| 4 | Bare-launch overlay nudge in self-load fallback | `framework/defaults/AGENTS.md` |
|
||||
| 5 | `compose-contract.spec.ts`: per-tier anchor, Tier-3 byte-equality, overlay present/absent, per-harness | `src/commands/compose-contract.spec.ts` |
|
||||
|
||||
## Deferred to P6
|
||||
|
||||
CONTRIBUTING.md + harness×gate compliance matrix; resident line-count CI ceiling; `aiguide` reconcile;
|
||||
alpha tag `mosaic-vX.Y.Z-alpha`.
|
||||
|
||||
## Status
|
||||
|
||||
- [x] Phase scaffold (branch, issue #604, scratchpad, TASKS)
|
||||
- [ ] Implementation (tasks 1–5)
|
||||
- [ ] prettier + vitest green; PR via wrapper → Lead (rides 0.0.39; 0.0.38 mid-cut)
|
||||
@@ -1,29 +0,0 @@
|
||||
# P6 — Docs, compliance matrix, alpha tag (constitution capstone)
|
||||
|
||||
- **Issue:** #606 · **Branch:** `feat/p6-docs-compliance-alpha` · **Lineage:** #542
|
||||
- **Requirements:** R9 (resident line-count ceiling) + R10 (CONTRIBUTING + compliance matrix + aiguide) + alpha tag
|
||||
|
||||
## Delivered (in-repo)
|
||||
|
||||
- `framework/CONTRIBUTING.md` — layer model, operator-hygiene/PII prohibition, dedup rule, resident
|
||||
budget, **dual-installer parity rule**, adding-a-harness, re-contamination rule, **harness×gate
|
||||
compliance matrix** (hook-parity gap marked ⚠️ tracked-v2), known-limitations (§9 residuals), PR checklist.
|
||||
- `framework/tools/quality/scripts/check-resident-budget.sh` — line-count ceiling over framework-owned
|
||||
resident files (CONSTITUTION + AGENTS + each runtime/\*/RUNTIME.md); `--self-test`; replaces the crude
|
||||
inline ci.yml loop. Wired blocking in `.woodpecker/ci.yml`.
|
||||
- Composer unit test (R9) already runs via `pnpm test`; `verify-sanitized.sh` (P1) already wired.
|
||||
|
||||
## Verification
|
||||
|
||||
- Sanitization gate green (CONTRIBUTING is operator-neutral). Resident-budget self-test + real run green.
|
||||
- prettier clean. Current resident counts: CONSTITUTION 96, AGENTS 83, RUNTIME max 75 — all < ceiling.
|
||||
|
||||
## Remaining
|
||||
|
||||
- [ ] `aiguide` reconcile (separate repo `~/src/aiguide` / mosaicstack/aiguide) — consistency pass vs Constitution.
|
||||
- [ ] Alpha tag `mosaic-vX.Y.Z-alpha` — propose version; Lead cuts after full DoD §8 green + all phases merged.
|
||||
|
||||
## Notes
|
||||
|
||||
- Alpha DoD (DESIGN §8): all phases P0–P6 merged + CI green. P5 (#605) pending merge after 0.0.38 publish.
|
||||
- Hook parity (codex/opencode/pi) = tracked v2 gap, documented in the matrix, not closed here.
|
||||
@@ -51,48 +51,3 @@ This repository currently has no root `CHANGELOG.md`; the scratchpad and `docs/T
|
||||
- PR #1908: `Dry run: would merge PR #1908 on git.uscllc.com with authenticated Gitea API fallback (base=main, method=squash).`
|
||||
- PR: `https://git.mosaicstack.dev/mosaicstack/stack/pulls/518`, branch `fix/t-a292e96f-gitea-pr-metadata`.
|
||||
- CI: Recent PR/push pipelines failed before clone/test execution due Woodpecker/Kubernetes PVC API timeout: `dial tcp 10.43.0.1:443: i/o timeout`. No repository test step executed in CI; local targeted verification above remains clean.
|
||||
|
||||
## 2026-06-18 — PR #549 functional blocker remediation
|
||||
|
||||
### Assignment
|
||||
|
||||
Coordinator `mos-claude` assigned remediation for PR #549: fix `packages/mosaic/framework/tools/git/pr-metadata.sh` tmpfile cleanup where an `EXIT` trap references function-local `body_file` after the function returns inside `RAW=$(...)`, producing `body_file: unbound variable` on the authenticated success path and failing to clean up safely on early `set -e` exits.
|
||||
|
||||
### Plan
|
||||
|
||||
1. Add a non-vacuous Gitea test that exercises `curl_gitea_pull` with stubbed `curl` and `GITEA_TOKEN` instead of `MOSAIC_GITEA_PR_METADATA_RAW_FILE`.
|
||||
2. Prove the new test is RED against the current PR head.
|
||||
3. Replace the function-local `EXIT` cleanup with robust function-scoped tmpfile cleanup.
|
||||
4. Re-run targeted tests, `bash -n`, and review gates; commit and push branch only. Do not merge.
|
||||
|
||||
### Constraints / assumptions
|
||||
|
||||
- Do not modify prior injection/JSON fixes in `issue-edit`, `issue-assign`, or `milestone-create`.
|
||||
- Worker role: do not modify `docs/TASKS.md`; orchestrator remains the single writer.
|
||||
- Budget: no explicit token cap provided; keep scope to shell wrapper + targeted regression harness.
|
||||
|
||||
### Remediation results
|
||||
|
||||
- Rebased `fix/tooling-eval-injection-jq-json` onto `origin/main`; branch was already current.
|
||||
- Added a curl-stub regression path that does not use `MOSAIC_GITEA_PR_METADATA_RAW_FILE`, so it exercises `curl_gitea_pull` and its temp body file.
|
||||
- RED evidence: copied the new harness next to the pre-fix `HEAD` version of `pr-metadata.sh`; `MOSAIC_TEST_WORK_DIR=$PWD/.mosaic-test-work/pr-metadata-red-work .../test-pr-metadata-gitea.sh` failed with `body_file: unbound variable` on the curl success path.
|
||||
- Fix: replaced `EXIT` temp-file cleanup with a `RETURN`-scoped cleanup function that removes the body file while the function-local variable is still in scope, preserves the original return status, and clears the `RETURN` trap.
|
||||
- GREEN evidence:
|
||||
- `MOSAIC_TEST_WORK_DIR=$PWD/.mosaic-test-work/pr-metadata-gitea-current packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
|
||||
- `bash -n packages/mosaic/framework/tools/git/pr-metadata.sh packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
|
||||
- `shellcheck -x -P . -e SC1090 packages/mosaic/framework/tools/git/pr-metadata.sh packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
|
||||
|
||||
### Review remediation
|
||||
|
||||
- Codex review returned one should-fix: the early-exit test used `chmod 000`, which is not root-safe in container CI.
|
||||
- Remediation: changed the stubbed 2xx/cat-failure mode to replace the curl output with a broken symlink, which fails deterministically even as root and still validates cleanup via `rm -f -- "$body_file"`.
|
||||
|
||||
### Second review remediation
|
||||
|
||||
- Codex review found the 2xx `cat "$body_file"` read could be masked under command substitution semantics because the branch returned 0 unconditionally.
|
||||
- Remediation: both authenticated 2xx branches now use `cat "$body_file" || return $?` before returning success.
|
||||
- Strengthened the broken-symlink test to require the body-read failure and reject the later `Gitea API returned non-JSON` parse-failure path, so the test verifies the helper-level failure propagation rather than eventual downstream failure.
|
||||
|
||||
### Final review gate
|
||||
|
||||
- Codex review after remediation: approved (`0 blockers, 0 should-fix, 0 suggestions`).
|
||||
|
||||
@@ -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,67 +0,0 @@
|
||||
# 544: Agent Reflection Loop — durable kernel
|
||||
|
||||
**Issue:** [#544](http://git.mosaicstack.dev/mosaicstack/stack/issues/544)
|
||||
**PRD:** [`docs/plans/agent-reflection-loop-PRD.md`](../plans/agent-reflection-loop-PRD.md)
|
||||
**Branch:** `feat/agent-reflection-loop`
|
||||
|
||||
## Context
|
||||
|
||||
Build the **durable kernel** of the agent reflection loop: passive end-of-run
|
||||
capture of the doer's end-state as structured `reflection.v1` data, plus a
|
||||
deterministic diff **review risk-floor**. The closed calibration / skill-synthesis
|
||||
loop (design §7–§8) stays **gated** behind Phase-0 experiments P1/P2/P3 and is
|
||||
explicitly out of scope here. Source design: jarvis-brain
|
||||
`docs/planning/AGENT-REFLECTION-LOOP.md` (debate-hardened v2).
|
||||
|
||||
Scope rule, non-goals, the full `reflection.v1` field list, and acceptance
|
||||
criteria live in the PRD. This file is the task breakdown + status.
|
||||
|
||||
## Work items
|
||||
|
||||
| # | Item | Path | Status |
|
||||
| --- | ----------------------------------------------------- | --------------------------------------------------------- | ------ |
|
||||
| 1 | Diff risk-floor (pure, deterministic) + unit tests | `packages/macp/src/risk-floor.ts`, `risk-floor.spec.ts` | done |
|
||||
| 2 | `reflection.v1` JSON Schema (documented contract) | `packages/macp/src/schemas/reflection.v1.schema.json` | done |
|
||||
| 3 | `reflection.v1` zod schemas + self-report DTO + tests | `packages/types/src/reflection/*` | done |
|
||||
| 4 | Stop hook (fail-closed capture) | `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh` | done |
|
||||
| 5 | Hook registration (`hooks.Stop`) | `packages/mosaic/framework/runtime/claude/settings.json` | done |
|
||||
| 6 | Phase-0 experiment harnesses (P1/P2/P3) | `scripts/analysis/reflect-*.sh` | done |
|
||||
|
||||
## Design decisions (this implementation)
|
||||
|
||||
- **Mechanical vs self-reported split.** A bash Stop hook cannot author the
|
||||
agent's self-assessment, so it writes the mechanical fields (risk-floor verdict,
|
||||
`files_changed`, ids, provenance) and merges an optional agent-supplied
|
||||
`$REFLECTION_INPUT` self-report; absent/unreadable ⇒ those fields `null` and
|
||||
`provenance.degraded = true`.
|
||||
- **Risk-floor authority.** `evaluateRiskFloor` (TS, tested) is the source of
|
||||
truth. The hook ports the same surface table inline to avoid a node/build
|
||||
dependency on the hook path; the two are documented as kept in sync.
|
||||
- **Hook registration deviation.** `settings-overlays/` has no merge mechanism
|
||||
(docs-only), so a hooks overlay there would be inert. The Stop hook is
|
||||
registered in the canonical `runtime/claude/settings.json` — the same file the
|
||||
`mosaic` launcher reflects into `~/.claude/settings.json`. Still vendored in-repo.
|
||||
- **DTO without class-transformer.** `reflection.dto.ts` uses class-validator only
|
||||
(no `@Type`), matching `chat.dto.ts`, so the module imports without a
|
||||
`reflect-metadata` shim in the types-package test env. Deep nested validation is
|
||||
owned by the zod `ReflectionSelfReportSchema` (the runtime authority the hook uses).
|
||||
- **`.mosaic/` excluded** from the change surface — it is agent scratch
|
||||
(reflections, locks, self-report input), not part of the diff under review.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm --filter @mosaicstack/macp test` → 88 passed (15 new risk-floor).
|
||||
- `pnpm --filter @mosaicstack/types test` → 64 passed (10 new reflection).
|
||||
- Root `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` → green.
|
||||
- Stop hook smoke: fail-closed no-op (mode unset), solo capture (degraded),
|
||||
self-report merge (degraded=false), re-fire lock guard — all pass.
|
||||
- All bash (hook + 3 Phase-0 scripts) shellcheck-clean; Phase-0 scripts emit
|
||||
structured JSON/markdown and print their pre-registered kill conditions.
|
||||
|
||||
## Activation (post-merge, deployment concern — not a blocker)
|
||||
|
||||
The Stop hook only activates when a launcher/profile sets
|
||||
`REFLECTION_MODE=solo|orchestrated`; unset/`off` is a strict no-op, so global
|
||||
registration is safe. `framework/install.sh` rsyncs the hook into
|
||||
`~/.config/mosaic/tools/qa/`, and the `mosaic` launcher reflects the updated
|
||||
`settings.json` (`hooks.Stop`) into `~/.claude/settings.json`.
|
||||
@@ -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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user