Compare commits

..

2 Commits

Author SHA1 Message Date
Jarvis
4dd9806af1 fix(installer): clearer pnpm/corepack errors + dev-aware --check
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/pr/ci Pipeline was successful
Review follow-ups on the --dev feature:
- Pin corepack to pnpm@10.6.2 (the repo packageManager) and surface
  corepack enable/prepare failures, with an actionable hard-fail when pnpm
  is still absent — the fresh-machine path no longer dies on a bare
  "command not found".
- `--check --dev` now reports the installed version instead of a
  misleading "Could not reach registry" warning (dev mode skips the
  registry lookup).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMoEx7hfdFGjUiCHuN1RRi
2026-06-24 17:58:21 -05:00
Jarvis
745b9c4967 feat(installer): --dev flag builds CLI + gateway from source
Adds `install.sh --dev` (and MOSAIC_DEV=1): instead of installing
@mosaicstack/mosaic from the registry @latest, download the monorepo archive
at --ref, `pnpm install` + build, `pnpm pack` both @mosaicstack/mosaic and
@mosaicstack/gateway, and `npm install -g` both local tarballs. ZERO registry
writes — no dev dist-tags. Workspace deps resolve from the registry at the
versions `pnpm pack` rewrites `workspace:*` into.

Exports MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 so the auto-launched wizard keeps the
source-built global gateway instead of overwriting it with the registry build.

Refactors the archive download into a shared ensure_monorepo helper reused by
both the framework install (Part 1) and the dev build (Part 2).

Lets a branch be tested end-to-end on a clean machine before cutting a release,
reducing semver churn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMoEx7hfdFGjUiCHuN1RRi
2026-06-24 17:58:21 -05:00
39 changed files with 25 additions and 3299 deletions

View File

@@ -7,12 +7,10 @@ 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 { FederationAuthGuard } from './server/index.js';
@Module({
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
controllers: [EnrollmentController, FederationController, CapabilitiesController],
providers: [
AdminGuard,
CaService,
@@ -21,8 +19,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationClientService,
QuerySourceService,
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
],
exports: [
CaService,
@@ -31,8 +27,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationClientService,
QuerySourceService,
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
],
})
export class FederationModule {}

View File

@@ -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' },
},
});
});
});

View File

@@ -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';

View File

@@ -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,
},
};
}
}

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -69,9 +69,8 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c
| # | 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 |
| 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)

View File

@@ -79,102 +79,6 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
---
## Tess Interaction Agent Workstream (TESS)
### Problem and Objective
Jason needs one durable, operator-facing Mosaic agent outside Hermes that is reachable through a dedicated Discord channel and CLI, can attach to and operate the Mosaic fleet and transitional Hermes agents, and preserves context across restarts and compaction. Mos remains the coding/general fleet orchestrator; Tess is the complementary human interaction, visibility, control, and migration agent.
The objective is to ship **Tess** (from *tessera*, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency.
### Scope
#### In Scope
1. `TESS-ARP-001`: A runtime-neutral `AgentRuntimeProvider` contract supporting `listSessions`, `streamSession`, `sendMessage`, `terminate`, `getSessionTree`, `attach`, health, capability discovery, and normalized events/errors.
2. `TESS-PI-001`: A long-running Pi-native Tess agent profile/service pinned to GPT-5.6 Sol with high reasoning, explicit tool policy, lifecycle hooks, durable checkpoints, and restart recovery.
3. `TESS-DSC-001`: Dedicated Discord channel binding to Tess through the Mosaic gateway, with allowlists/RBAC, thread/reply policy, streaming, attachments, approvals, and correlation IDs.
4. `TESS-CLI-001`: `mosaic tess` CLI commands for chat, status, session listing, attach/detach, send/steer/stop, provider health, and recovery.
5. `TESS-FLT-001`: Fleet plugin capabilities for roster/status/heartbeat inspection, message delivery, session hierarchy, safe attach, and controlled restart/recovery.
6. `TESS-MOS-001`: Explicit Mos coordination boundary and tools: hand off orchestration requests, observe mission/task state, receive results, and never silently compete for orchestration authority.
7. `TESS-HRM-001`: Transitional Hermes adapter for profiles/agents, sessions, streaming/messages, Kanban, skills, memory, tools, cron, and health, using capability negotiation and fail-closed unsupported operations.
8. `TESS-MEM-001`: Unified memory/retrieval plugin with scoped search/recent/capture/stats, startup context injection, provenance, redaction, namespace isolation, and flat-file/project truth precedence.
9. `TESS-STA-001`: Durable agent state, inbox, handoff, compaction-recovery, and resume reconstruction.
10. `TESS-PLG-001`: Plugin/tool catalog covering runtime bootstrap, repository/PR workflow, fleet diagnostics, incident-safe read operations, Discord interaction, and extensible MCP/skill discovery.
11. `TESS-TRN-001`: Replaceable transport providers: tmux/fleet now, Matrix/native Mosaic transport later, with no Discord/CLI business logic coupled to transport details.
12. `TESS-SEC-001`: RBAC, per-operation authorization, explicit approval for destructive/privileged/customer-visible actions, audit events, secret/PII redaction, tenant isolation, and bounded command execution.
13. `TESS-SEC-002`: Command execution SHALL enforce declared scope/role server-side; admin/system and destructive operations SHALL require policy-bound durable approval.
14. `TESS-SEC-003`: Every session list/read/attach/send/terminate operation SHALL enforce server-derived owner and tenant scope; guessed or client-supplied IDs SHALL grant no authority.
15. `TESS-SEC-004`: MCP tools SHALL derive actor/tenant from authenticated context and SHALL NOT accept caller-controlled identity fields.
16. `TESS-SEC-005`: Discord plugin ingress SHALL authenticate service identity, enforce guild/channel/user allowlists, propagate correlation/message IDs, and reject replay.
17. `TESS-SEC-006`: Secret/PII classification and redaction SHALL occur before persistence and before channel egress, including tool metadata and authentication flows.
18. `TESS-SEC-007`: Approvals SHALL be one-time, expiring, actor/tenant-bound, and cryptographically bound to the exact structured action digest.
19. `TESS-SEC-008`: Ingress, provider sends, tool side effects, and responses SHALL use durable inbox/outbox/checkpoints and idempotency records for restart-safe replay.
20. `TESS-SEC-009`: Garbage collection and retention SHALL be session/tenant scoped unless executed as a separately authorized and audited system-wide job.
21. `TESS-OBS-001`: Structured logs, traces, health/readiness, provider latency/errors, session lifecycle, tool audit, and actionable recovery diagnostics.
22. `TESS-MIG-001`: Capability inventory and staged Hermes-to-Mosaic migration matrix with coexistence, cutover, rollback, and deprecation gates.
#### Out of Scope
1. Replacing Mos as coding/general fleet orchestrator.
2. Making Hermes the Mosaic core or coupling Mosaic domain logic to Hermes schemas.
3. Migrating every historical chat verbatim; only policy-compliant indexed summaries and user-selected sessions are migrated.
4. Unrestricted shell execution from Discord.
5. Full web UI parity in the first Tess operational milestone; gateway contracts must remain web-consumable.
6. Replacing tmux before Matrix/native transport reaches operational parity.
### Stakeholder and User Requirements
- Jason must be able to converse with the same Tess session from Discord and CLI.
- Jason must be able to see what is running, stale, blocked, or unhealthy without attaching manually to every session.
- Jason must be able to attach to Tess and authorized fleet sessions through supported CLI controls.
- Tess must collaborate with Mos and the fleet while preserving a single clear orchestration authority.
- The system must migrate useful Hermes/OpenClaw capabilities intentionally, with evidence, instead of copying implementations wholesale.
### Non-Functional Requirements
1. **Security:** default-deny provider/tool capabilities, least privilege, no secrets in logs/prompts/commits, Discord user/channel authorization, and auditable approvals.
2. **Reliability:** durable inbox/checkpoints; idempotent message handling; reconnect with bounded backoff; no message loss or duplicate execution across gateway restart.
3. **Performance:** first acknowledgement within 2 seconds when connected; streamed agent output begins within 5 seconds excluding model/provider delay; status reads return within 2 seconds under nominal local conditions.
4. **Observability:** every ingress message and resulting provider/tool operation carries a correlation ID across Discord, gateway, Tess, provider, and audit events.
5. **Maintainability:** channel, runtime, transport, memory, and external-agent integrations remain adapter-based with contract tests.
6. **Privacy:** only scoped context enters external runtimes; persisted messages/memories follow retention and redaction policy.
7. **Portability:** Tess runs through Pi/Mosaic contracts and does not require Hermes to start or serve native Mosaic operations.
### Acceptance Criteria
1. `AC-TESS-01`: A dedicated Discord channel and `mosaic tess chat` connect to one durable Tess session and stream responses bidirectionally.
2. `AC-TESS-02`: `mosaic tess status|sessions|tree|attach|send|stop` operate against authorized provider capabilities with stable typed outputs and actionable errors.
3. `AC-TESS-03`: Tess runs GPT-5.6 Sol at high reasoning and its effective runtime/model/tool policy is visible through status without exposing credentials.
4. `AC-TESS-04`: Tess can inspect and message the Mosaic fleet, hand orchestration work to Mos, and demonstrate that Tess does not independently claim Mos-owned orchestration work.
5. `AC-TESS-05`: Hermes adapter demonstrates session listing, streaming/message delivery, hierarchy mapping, and at least one approved capability in each of Kanban, skills, memory, tools, and cron—or reports unsupported capabilities fail-closed.
6. `AC-TESS-06`: Restart/compaction test preserves session identity, pending inbox, last durable checkpoint, and a resumable handoff without duplicate side effects.
7. `AC-TESS-07`: Unauthorized Discord users/channels, cross-tenant access, unsafe tool calls, forged approvals, and sensitive-output cases are denied and audited.
8. `AC-TESS-08`: tmux/fleet and Matrix/native transport implementations pass the same provider contract suite; Matrix may remain non-default until readiness gates pass.
9. `AC-TESS-09`: Baseline quality gates, unit/integration/contract tests, Discord+CLI E2E, restart/recovery tests, independent code review, and security review are green.
10. `AC-TESS-10`: Migration matrix documents every audited Hermes/OpenClaw capability as native, adapted, deferred, or rejected, with cutover and rollback evidence.
11. `AC-TESS-11`: User, admin, developer, API/OpenAPI, operations/recovery, and plugin-authoring documentation is current and linked from the sitemap.
### Constraints, Dependencies, Risks, and Assumptions
- Dependency: Mosaic gateway remains the single API surface; Pi is the native runtime; Valkey/PostgreSQL provide canonical durable state where required.
- Dependency: Discord bot credentials and dedicated channel ID are deployment secrets provisioned outside source control.
- Risk: Tess could drift into a second orchestrator. Mitigation: explicit role policy, Mos handoff contract, authority checks, and E2E boundary tests.
- Risk: broad Hermes compatibility can freeze legacy semantics into Mosaic. Mitigation: Mosaic-owned normalized contracts and capability negotiation.
- Risk: Discord creates a privileged remote-control surface. Mitigation: pairing/allowlists, RBAC, approvals, rate limits, audit, and safe tool classes.
- Risk: transcript ingestion can violate privacy or overload memory. Mitigation: scoped opt-in import, redacted summaries, provenance, retention, and deduplication.
- Risk: current root filesystem has limited headroom. Mitigation: isolated worktrees, no duplicated dependency installation unless required, and cleanup only after active-lane verification.
- `ASSUMPTION:` The public name is **Tess**, because the user requested a name and the tessera/Mosaic relationship is distinctive; config must permit later display-name changes without renaming APIs or storage keys.
- `ASSUMPTION:` The dedicated Discord channel ID and final guild policy will be supplied/provisioned during deployment, so implementation uses explicit configuration and fail-fast startup validation.
- `ASSUMPTION:` tmux/fleet is the production transport for the first operational milestone; Matrix/native transport is implemented behind the same contract and promoted only after parity/reliability verification.
- `ASSUMPTION:` Project/task truth remains in canonical Mosaic/project stores; semantic memory systems are retrieval/mirror layers, not hidden authorities.
### Testing and Delivery Intent
Delivery uses five gated milestones: runtime contracts/security; Pi service/state; Discord/CLI; fleet/Hermes/plugin suite; migration/Matrix/recovery/qualification. Every source-code task requires tests, independent review, a PR to `main`, terminal-green CI, and issue/task closure. Production activation additionally requires a clean-host Pi launch, dedicated Discord channel smoke test, CLI attach test, restart/recovery drill, and rollback procedure.
---
## Architecture
### High-Level System Diagram

View File

@@ -16,8 +16,7 @@
| 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; M2M7 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 |
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2M7 deferred to mission planning |
## Cross-Cutting Tracking

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -1,62 +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.
## Ops config (Mos, 2026-07-12)
- **Tess dedicated Discord channel ID:** `1525947814033494126` (Jason configuring bot in Discord portal). Target for TESS-M3-001 (#709) binding. Treat as config, not a hardcoded secret. Live go-live = owner gate T1 @ M5.

View File

@@ -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.

View File

@@ -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.

View File

@@ -1,47 +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.
6. **The HMI agent's name/identity is configuration, never a hardcoded literal.** The agent name (display + roster key) is supplied at provisioning — user-chosen or system-generated during config — and carried as data in the roster/profile. "Tess" is only the default/example instance name and this mission's build codename; no source, schema key, service identifier, or config default may hardcode it. (M2 exit gate must prove a distinct name provisions cleanly with zero code change.)
## 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. |

View File

@@ -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 |

View File

@@ -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.

View File

@@ -1,30 +0,0 @@
# Tess Verification Matrix
| Acceptance criterion | Requirements | Planned evidence | Gate |
| --- | --- | --- | --- |
| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V |
| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V |
| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V |
| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | Authority E2E: coding request creates Mos handoff; safe status runs in Tess; no competing worker claim | M4-V |
| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V |
| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V |
| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V |
| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V |
| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone |
| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V |
| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V |
## Security Abuse Suite Minimum
- Role/scope matrix for every command and provider capability.
- Cross-tenant and cross-user session ID matrix across REST, WS, Discord, CLI, MCP, and providers.
- Discord service identity, guild/channel/user allowlist, replay, attachment, and mention/DM policy cases.
- Prompt/tool injection corpus and structured-proposal enforcement.
- Approval action-digest mutation, replay, expiry, tenant, and actor mismatch cases.
- Secret/PII canaries through message, attachment, tool args/output, logs, memory, audit, and error paths.
- Restart fault injection before/after enqueue, provider send, side effect, response persistence, and acknowledgement.
- Wrong tmux socket/target and Matrix identity/room/replay cases.
## Evidence Rules
Evidence must include command/test name, terminal result, CI run URL, PR/merge reference, environment, and artifact/log location. A worker self-report is not evidence until independently verified.

View File

@@ -15,22 +15,6 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
## Agent Host Prerequisites
Agent hosts must provide the Python runtime shape that runtime agents and
Mosaic automation assume is present.
For Debian/Ubuntu hosts:
```bash
sudo apt-get update
# #561: bare python invocations from agents must resolve.
sudo apt-get install -y python3 python-is-python3
```
For non-Debian hosts, install the equivalent Python 3 runtime and ensure
`/usr/bin/python` resolves to `python3` (for example, via a managed symlink).
## Quick Start
```bash

View File

@@ -15,22 +15,6 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
## Agent Host Prerequisites
Agent hosts must provide the Python runtime shape that runtime agents and
Mosaic automation assume is present.
For Debian/Ubuntu hosts:
```bash
sudo apt-get update
# #561: bare python invocations from agents must resolve.
sudo apt-get install -y python3 python-is-python3
```
For non-Debian hosts, install the equivalent Python 3 runtime and ensure
`/usr/bin/python` resolves to `python3` (for example, via a managed symlink).
## Quick Start
```bash

View File

@@ -15,23 +15,13 @@
#
# After loading, service-specific env vars are exported.
# Run `load_credentials --help` for details.
#
# Resolution order (first match wins):
# 1. $MOSAIC_CREDENTIALS_FILE (explicit override — never second-guessed)
# 2. $HOME/.config/mosaic/credentials.json
# 3. /etc/mosaic/credentials.json (host-level fallback)
# The /etc fallback exists for HOME-redirected profile environments, where
# $HOME points at a per-profile directory that has no credentials file.
# Operators symlink /etc/mosaic/credentials.json to the host's canonical
# file once, instead of exporting MOSAIC_CREDENTIALS_FILE per invocation.
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do
for _cand in "$HOME/.config/mosaic/credentials.json"; do
if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi
done
: "${MOSAIC_CREDENTIALS_FILE:=$HOME/.config/mosaic/credentials.json}"
fi
export MOSAIC_CREDENTIALS_FILE
_mosaic_require_jq() {
if ! command -v jq &>/dev/null; then

View File

@@ -86,16 +86,7 @@ gitea_url_matches_host() {
get_gitea_service_for_host() {
local host="$1"
local cred_file="${MOSAIC_CREDENTIALS_FILE:-}"
if [[ -z "$cred_file" ]]; then
# Same resolution chain as _lib/credentials.sh: profile HOME, then
# host-level /etc only if it exists; neither existing keeps the
# $HOME default (matches the lib's final := fallback).
cred_file="$HOME/.config/mosaic/credentials.json"
if [[ ! -f "$cred_file" && -f /etc/mosaic/credentials.json ]]; then
cred_file="/etc/mosaic/credentials.json"
fi
fi
local cred_file="${MOSAIC_CREDENTIALS_FILE:-$HOME/.config/mosaic/credentials.json}"
case "$host" in
git.mosaicstack.dev)

View File

@@ -33,7 +33,7 @@ Examples:
$(basename "$0") -i 42 -l "in-progress" -m "0.2.0"
$(basename "$0") -i 42 -a @me
EOF
exit "${1:-1}"
exit 1
}
# Parse arguments
@@ -60,7 +60,7 @@ while [[ $# -gt 0 ]]; do
shift
;;
-h|--help)
usage 0
usage
;;
*)
echo "Unknown option: $1" >&2

View File

@@ -72,7 +72,7 @@ Examples:
$(basename "$0") -t "Fix login bug" -l "bug,priority-high"
$(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0"
EOF
exit "${1:-1}"
exit 1
}
# Parse arguments
@@ -95,7 +95,7 @@ while [[ $# -gt 0 ]]; do
shift 2
;;
-h|--help)
usage 0
usage
;;
*)
echo "Unknown option: $1" >&2

View File

@@ -36,7 +36,7 @@ Examples:
$(basename "$0") -m "0.2.0" # Issues in milestone 0.2.0
$(basename "$0") --repo ddk/ai-bma # List issues from anywhere
EOF
exit "${1:-1}"
exit 1
}
# Parse arguments
@@ -67,7 +67,7 @@ while [[ $# -gt 0 ]]; do
shift 2
;;
-h|--help)
usage 0
usage
;;
*)
echo "Unknown option: $1" >&2

View File

@@ -37,7 +37,7 @@ Examples:
$(basename "$0") -t "0.0.1" -d "Pre-MVP Foundation Sprint"
$(basename "$0") -t "0.1.0" -d "MVP Release" --due "2025-03-01"
EOF
exit "${1:-1}"
exit 1
}
# Parse arguments
@@ -60,7 +60,7 @@ while [[ $# -gt 0 ]]; do
shift
;;
-h|--help)
usage 0
usage
;;
*)
echo "Unknown option: $1" >&2

View File

@@ -86,7 +86,7 @@ Examples:
$(basename "$0") -i 42 -b "Implements the feature described in #42"
$(basename "$0") -t "WIP: New feature" --draft
EOF
exit "${1:-1}"
exit 1
}
# Parse arguments
@@ -125,7 +125,7 @@ while [[ $# -gt 0 ]]; do
shift
;;
-h|--help)
usage 0
usage
;;
*)
echo "Unknown option: $1" >&2

View File

@@ -34,7 +34,7 @@ Examples:
$(basename "$0") -s merged -a username # Merged PRs by user
$(basename "$0") --repo ddk/ai-bma # List PRs from anywhere
EOF
exit "${1:-1}"
exit 1
}
# Parse arguments
@@ -61,7 +61,7 @@ while [[ $# -gt 0 ]]; do
shift 2
;;
-h|--help)
usage 0
usage
;;
*)
echo "Unknown option: $1" >&2

View File

@@ -35,7 +35,7 @@ Examples:
$(basename "$0") -n 42 -d # Squash merge and delete branch
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
EOF
exit "${1:-1}"
exit 1
}
# Parse arguments
@@ -63,7 +63,7 @@ while [[ $# -gt 0 ]]; do
shift
;;
-h|--help)
usage 0
usage
;;
*)
echo "Unknown option: $1" >&2

View File

@@ -1,53 +0,0 @@
#!/usr/bin/env bash
# Regression harness for #701: -h/--help must exit 0, bad args must still exit nonzero.
#
# Covers the 7 wrappers whose usage() previously hard-coded `exit 1`, so every
# --help invocation exited nonzero and logged a phantom isError across fleet lanes.
# Asserts, per wrapper:
# 1. `--help` exits 0 and prints usage.
# 2. `-h` exits 0 and prints usage.
# 3. A genuine unknown flag still exits nonzero (usage() default path untouched).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WRAPPERS=(
issue-assign.sh
issue-create.sh
issue-list.sh
milestone-create.sh
pr-create.sh
pr-list.sh
pr-merge.sh
)
fail=0
for wrapper in "${WRAPPERS[@]}"; do
path="$SCRIPT_DIR/$wrapper"
if ! output=$(bash "$path" --help 2>&1); then
echo "FAIL: $wrapper --help exited nonzero" >&2
fail=1
elif [[ "$output" != Usage:* ]]; then
echo "FAIL: $wrapper --help did not print usage" >&2
fail=1
fi
if ! bash "$path" -h >/dev/null 2>&1; then
echo "FAIL: $wrapper -h exited nonzero" >&2
fail=1
fi
if bash "$path" --this-is-not-a-real-flag >/dev/null 2>&1; then
echo "FAIL: $wrapper accepted an unknown flag (should have exited nonzero)" >&2
fail=1
fi
done
if [[ "$fail" -eq 0 ]]; then
echo "help-exit-code regression passed (7/7 wrappers)"
fi
exit "$fail"

View File

@@ -87,10 +87,6 @@ message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards.
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
- `auto-submit-drafts.sh` — watchdog that flushes stable unsubmitted prompt
drafts on a coordinator pane (default target `mos-claude`); run it as a
long-lived process alongside the coordinator session.
- `agent-send.test.sh` — regression + grammar lock for `agent-send.sh`.
- `test-send-message-socket.sh` — smoke test for named-socket isolation.
## Distribution

View File

@@ -1,80 +0,0 @@
#!/usr/bin/env bash
# auto-submit-drafts.sh — watchdog for Claude Code panes that receive channel
# messages but leave them as unsubmitted prompt drafts. Intended for Mos only.
set -uo pipefail
TARGET="${1:-mos-claude}"
INTERVAL="${INTERVAL:-2}"
STABLE_SECONDS="${STABLE_SECONDS:-4}"
LOG_PREFIX="[auto-submit-drafts:$TARGET]"
last_prompt=""
first_seen=0
prompt_text() {
tmux capture-pane -t "$TARGET" -p 2>/dev/null | python3 -c '
import sys, re
lines = sys.stdin.read().splitlines()
idx = None
for i in range(len(lines)-1, -1, -1):
if "" in lines[i]:
idx = i
break
if idx is None:
raise SystemExit
parts = []
after = lines[idx].split("", 1)[1]
parts.append(after)
for line in lines[idx+1:]:
# Stop at Claude Code separator/border lines.
if "─" in line or "╰" in line or "╭" in line:
break
s = line.replace("\u00a0", " ")
s = re.sub(r"[\x00-\x1f\x7f]", "", s).strip()
if s:
parts.append(s)
text = " ".join(parts).replace("\u00a0", " ")
text = re.sub(r"[\x00-\x1f\x7f]", "", text).strip()
print(text)
'
}
while true; do
if ! tmux has-session -t "$TARGET" 2>/dev/null; then
echo "$LOG_PREFIX target missing; waiting" >&2
sleep "$INTERVAL"
last_prompt=""
first_seen=0
continue
fi
current="$(prompt_text || true)"
now="$(date +%s)"
if [[ -z "$current" ]]; then
last_prompt=""
first_seen=0
sleep "$INTERVAL"
continue
fi
if [[ "$current" != "$last_prompt" ]]; then
last_prompt="$current"
first_seen="$now"
sleep "$INTERVAL"
continue
fi
age=$(( now - first_seen ))
if (( age >= STABLE_SECONDS )); then
echo "$LOG_PREFIX submitting stable draft after ${age}s: ${current:0:120}" >&2
tmux send-keys -t "$TARGET" C-j
sleep 0.8
tmux send-keys -t "$TARGET" C-m
sleep 2
last_prompt=""
first_seen=0
else
sleep "$INTERVAL"
fi
done

View File

@@ -77,20 +77,10 @@ snippet=$(printf '%s' "$MSG" | tr '\n' ' ' | tr -s ' ' | sed 's/[^[:print:]]//g'
# 1) Paste the body as a bracketed paste so multi-line content does not submit
# line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`.
# Buffer name MUST be unique per invocation: concurrent senders on the shared
# tmux server race a fixed name (load overwrites load, -d deletes underneath),
# cross-delivering or dropping messages — bit the fleet on the 2026-07-09
# simultaneous restart (briefs swapped between sessions).
BUF="__mosaic_send_$$_$(date +%s%N)"
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b __mosaic_send -
# -p = bracketed paste when the client supports it; fall back if not.
"${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \
|| "${tmux_cmd[@]}" paste-buffer -d -b "$BUF" -t "$EFFECTIVE_TARGET" \
|| "${tmux_cmd[@]}" delete-buffer -b "$BUF" 2>/dev/null
# ^ -d deletes the buffer only on a SUCCESSFUL paste; if both attempts fail
# (e.g. the target vanished since the liveness check), delete explicitly —
# named buffers are exempt from tmux's buffer-limit eviction, so orphans
# would otherwise accumulate forever.
"${tmux_cmd[@]}" paste-buffer -d -p -b __mosaic_send -t "$EFFECTIVE_TARGET" 2>/dev/null \
|| "${tmux_cmd[@]}" paste-buffer -d -b __mosaic_send -t "$EFFECTIVE_TARGET"
sleep 0.5
# 2) Submit, then verify; flush with another Enter if it is still a draft.

View File

@@ -47,32 +47,4 @@ if capture_default | grep -qF "agent socket hello"; then
fail "agent-send.sh leaked named-socket message to default tmux server"
fi
# Concurrency: parallel senders on one server must not cross-deliver or drop.
# Locks the unique-per-invocation paste buffer (a fixed buffer name raced:
# load overwrote load, -d deleted underneath — messages swapped between panes).
CONC_N=5
for i in $(seq 1 "$CONC_N"); do
tmux -L "$SOCKET" new-session -d -s "conc-$i" -c "$TMPDIR" 'bash --noprofile --norc -i'
done
pids=()
for i in $(seq 1 "$CONC_N"); do
"$SEND_MESSAGE" -L "$SOCKET" -t "=conc-$i" -m "CONCPAYLOAD-${i}-END" >/dev/null &
pids+=($!)
done
for pid in "${pids[@]}"; do
wait "$pid" || fail "concurrent send-message.sh invocation exited non-zero"
done
sleep 0.2
for i in $(seq 1 "$CONC_N"); do
pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p)
printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${i}-END" \
|| fail "concurrent send dropped payload for pane conc-$i"
for j in $(seq 1 "$CONC_N"); do
[ "$j" = "$i" ] && continue
if printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${j}-END"; then
fail "concurrent send cross-delivered payload $j to pane conc-$i"
fi
done
done
echo "ok - named tmux socket send tools"

View File

@@ -4,7 +4,6 @@ import { dirname, join, resolve } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
acquireRestartLock,
addAgentToRoster,
buildAgentSendCommand,
buildAgentWatchAttachCommand,
@@ -46,8 +45,6 @@ import {
removeAgentFromRoster,
resolveFleetPaths,
resolvePresetFilename,
restartLockPath,
RESTART_LOCK_STALE_MS,
RUNTIME_ACCEPTABLE_COMMANDS,
serializeRosterToYaml,
VERIFY_DEFAULT_TIMEOUT_MS,
@@ -681,364 +678,6 @@ describe('fleet command construction', () => {
}
});
it('waits for an in-flight restart to clear before relaunching (re-entry guard)', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
// Simulate another `mosaic fleet restart` process mid-teardown: a fresh lock
// (recent timestamp, so it is NOT treated as stale) already held.
const lockPath = restartLockPath(home);
await mkdir(dirname(lockPath), { recursive: true });
await writeFile(lockPath, `4242\n${Date.now()}\n`);
const events: string[] = [];
const runner: CommandRunner = async (command, args) => {
events.push(`run:${args[args.length - 1]}`);
return { stdout: '', stderr: '', exitCode: 0 };
};
// The injected sleep stands in for time passing while we wait; the in-flight
// restart "finishes" (releases its lock) after the first poll.
let sleeps = 0;
const sleepFn: SleepFn = async () => {
sleeps += 1;
events.push(`sleep:${sleeps}`);
await rm(lockPath, { force: true });
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
// It must have waited at least once before issuing any systemctl restart.
expect(sleeps).toBeGreaterThan(0);
const firstSleep = events.findIndex((e) => e.startsWith('sleep:'));
const firstRun = events.findIndex((e) => e.startsWith('run:'));
expect(firstSleep).toBeGreaterThanOrEqual(0);
expect(firstRun).toBeGreaterThan(firstSleep);
// And it still performs the full restart once the lock clears.
expect(events).toContain('run:mosaic-tmux-holder.service');
expect(events).toContain('run:mosaic-agent@coder0.service');
// The lock is released after the restart completes.
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('breaks a stale restart lock and proceeds without waiting', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
// A lock left behind by a crashed owner: timestamp older than the stale window.
const lockPath = restartLockPath(home);
await mkdir(dirname(lockPath), { recursive: true });
await writeFile(lockPath, `4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\n`);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const sleepFn = vi.fn<SleepFn>(async () => {});
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
// Stale lock is broken immediately — no waiting.
expect(sleepFn).not.toHaveBeenCalled();
expect(calls).toEqual([
['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
]);
// The stale lock is gone once the restart completes.
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('releases the restart lock so a subsequent restart is not blocked', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const sleepFn = vi.fn<SleepFn>(async () => {});
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
// Two sequential restarts both run fully and neither has to wait.
expect(sleepFn).not.toHaveBeenCalled();
expect(calls).toEqual([
['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('guards the single-agent restart path behind the in-flight restart lock', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
// A full restart is mid-flight (lock held); a single-agent restart re-enters.
const lockPath = restartLockPath(home);
await mkdir(dirname(lockPath), { recursive: true });
await writeFile(lockPath, `4242\n${Date.now()}\n`);
const events: string[] = [];
const runner: CommandRunner = async (command, args) => {
events.push(`run:${args[args.length - 1]}`);
return { stdout: '', stderr: '', exitCode: 0 };
};
let sleeps = 0;
const sleepFn: SleepFn = async () => {
sleeps += 1;
events.push(`sleep:${sleeps}`);
await rm(lockPath, { force: true });
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart', 'coder0']);
// The single-agent restart waits for the in-flight restart before acting.
expect(sleeps).toBeGreaterThan(0);
const firstSleep = events.findIndex((e) => e.startsWith('sleep:'));
const firstRun = events.findIndex((e) => e.startsWith('run:'));
expect(firstSleep).toBeGreaterThanOrEqual(0);
expect(firstRun).toBeGreaterThan(firstSleep);
// Only the named agent is restarted; the holder is untouched.
expect(events).toContain('run:mosaic-agent@coder0.service');
expect(events).not.toContain('run:mosaic-tmux-holder.service');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('does not let a timed-out owner drop a lock another restart broke and re-owned', async () => {
const home = await tempDir();
const runDir = join(home, 'fleet', 'run');
await mkdir(runDir, { recursive: true });
const lockPath = restartLockPath(home);
const tokenOf = async (): Promise<string> => {
const raw = await readFile(lockPath, 'utf8');
return raw.split('\n')[2]?.trim() ?? '';
};
const sleepFn = vi.fn<SleepFn>(async () => {});
// R1 acquires the lock and begins a restart that then hangs.
const r1 = await acquireRestartLock(home, sleepFn);
const tokenR1 = await tokenOf();
expect(tokenR1).not.toBe('');
// The hung R1 leaves a stale lock: rewrite its timestamp into the past while
// preserving R1's token — exactly the on-disk state a stuck owner leaves.
await writeFile(lockPath, `4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\n${tokenR1}\n`);
// R2 re-enters, sees the stale lock, and atomically takes ownership.
const r2 = await acquireRestartLock(home, sleepFn);
const tokenR2 = await tokenOf();
expect(tokenR2).not.toBe(tokenR1);
expect(sleepFn).not.toHaveBeenCalled();
// R1 finally finishes and releases. It must NOT delete R2's lock — otherwise
// a third restart (R3) could acquire and interleave with R2 still running.
await r1.release();
expect(await tokenOf()).toBe(tokenR2);
// R2 releases cleanly and the lock is gone.
await r2.release();
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
await rm(home, { recursive: true, force: true });
});
it('lets only one of several concurrent breakers proceed past a stale lock', async () => {
const home = await tempDir();
const lockPath = restartLockPath(home);
await mkdir(dirname(lockPath), { recursive: true });
// A stale lock left by a crashed owner: every concurrent re-entrant restart
// will judge it stale and try to break it at the same instant. Breaking must
// NOT grant ownership — only the atomic re-create may — so exactly one
// contender can ever hold the lock at a time. (The v2 fix wrote our own token
// during the break and read it back, so two breakers each saw their own token
// and BOTH proceeded; this guards that regression.)
await writeFile(
lockPath,
`4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\nstale-owner-token\n`,
);
// Yielding sleep so a waiting contender lets the current owner finish and
// release before it re-contends, instead of spinning the microtask queue.
const sleepFn: SleepFn = async () => {
await new Promise((res) => setTimeout(res, 0));
};
let active = 0;
let maxActive = 0;
const tokens: string[] = [];
const tokenOf = async (): Promise<string> => {
const raw = await readFile(lockPath, 'utf8');
return raw.split('\n')[2]?.trim() ?? '';
};
// One "restart" = acquire the lock, do work in the critical section, release.
const restartOnce = async (): Promise<void> => {
const guard = await acquireRestartLock(home, sleepFn);
active += 1;
maxActive = Math.max(maxActive, active);
// Record the token we own while we hold it, then yield to interleave with
// any other contender that might (wrongly) believe it owns the lock too.
tokens.push(await tokenOf());
await new Promise((res) => setTimeout(res, 0));
active -= 1;
await guard.release();
};
try {
// Three breakers race the single stale lock simultaneously.
await Promise.all([restartOnce(), restartOnce(), restartOnce()]);
// Mutual exclusion held: never two owners at once despite concurrent breaks.
expect(maxActive).toBe(1);
// Each acquire owned with its own distinct token — no two ever shared it.
expect(new Set(tokens).size).toBe(3);
// The lock is fully released at the end.
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('lets exactly one of two breakers take over a stale lock while the other waits', async () => {
const home = await tempDir();
const lockPath = restartLockPath(home);
await mkdir(dirname(lockPath), { recursive: true });
// A single stale lock both contenders will judge stale at the same instant.
// Every transition runs under the registry mutex, so only one may take the
// lock over; the other must observe a now-fresh owner and WAIT/re-evaluate
// rather than also taking over. (A content-blind clobber let both believe
// they owned it — this asserts the mutex-gated CAS takeover instead.)
await writeFile(
lockPath,
`4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\nstale-owner-token\n`,
);
// Barrier the winner holds against until the loser has observed the lock
// fresh and waited at least once — forcing the exact interleaving where one
// proceeds while the other waits, deterministically rather than by timing.
let resolveLoserWaited: () => void = () => {};
const loserWaited = new Promise<void>((res) => {
resolveLoserWaited = res;
});
let sleeps = 0;
const sleepFn: SleepFn = async () => {
sleeps += 1;
resolveLoserWaited();
await new Promise((res) => setTimeout(res, 0));
};
let active = 0;
let maxActive = 0;
const tokens: string[] = [];
const tokenOf = async (): Promise<string> => {
const raw = await readFile(lockPath, 'utf8');
return raw.split('\n')[2]?.trim() ?? '';
};
let firstOwner = true;
const restartOnce = async (): Promise<void> => {
const guard = await acquireRestartLock(home, sleepFn);
active += 1;
maxActive = Math.max(maxActive, active);
tokens.push(await tokenOf());
if (firstOwner) {
// Winner: keep holding the lock until the loser has waited once, so the
// loser is guaranteed to see a FRESH owner (not the stale one) and back
// off — proving it could not also take over.
firstOwner = false;
await loserWaited;
} else {
await new Promise((res) => setTimeout(res, 0));
}
active -= 1;
await guard.release();
};
try {
// Exactly two breakers race the single stale lock.
await Promise.all([restartOnce(), restartOnce()]);
// Mutual exclusion: never two owners at once (if both took over the stale
// lock, this would be 2).
expect(maxActive).toBe(1);
// Both eventually owned, each with its own distinct token.
expect(new Set(tokens).size).toBe(2);
// The loser observed the winner's fresh lock and waited — it did NOT also
// take over the stale lock.
expect(sleeps).toBeGreaterThanOrEqual(1);
// The lock is fully released at the end.
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('attempts every agent and the holder during fleet stop even when an agent stop fails', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');

View File

@@ -1,16 +1,5 @@
import { constants } from 'node:fs';
import {
access,
chmod,
copyFile,
mkdir,
open,
readFile,
stat,
unlink,
writeFile,
} from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { access, chmod, copyFile, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import { homedir, hostname, userInfo } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -544,295 +533,6 @@ export function buildFleetServiceCommand(action: FleetServiceAction, agentName?:
return ['systemctl', '--user', action, service];
}
/** Poll interval (ms) while waiting for an in-flight restart's lock to clear. */
export const RESTART_LOCK_POLL_INTERVAL_MS = 250;
/**
* Maximum time (ms) a re-entrant restart waits for the in-flight restart to
* finish before it breaks the lock and proceeds anyway. A bound is required so
* a crashed holder of the lock can never deadlock the fleet permanently.
*/
export const RESTART_LOCK_MAX_WAIT_MS = 30_000;
/**
* Age (ms) past which a restart lock is treated as stale (its owner died
* without releasing it) and is broken immediately rather than waited on.
*/
export const RESTART_LOCK_STALE_MS = 60_000;
/**
* Resolves the path of the cross-process restart lock for a given Mosaic home.
* Kept strictly under `<mosaicHome>/fleet/run` (not the heartbeat env override)
* so the lock is scoped to the same fleet the restart acts on.
*/
export function restartLockPath(mosaicHome: string): string {
return join(mosaicHome, 'fleet', 'run', 'restart.lock');
}
/** A held restart lock; `release()` removes the lock file iff we still own it. */
interface RestartGuard {
release(): Promise<void>;
}
/** Lock-file contents: pid (informational), timestamp, and a unique owner token. */
function formatRestartLockContent(token: string): string {
return `${process.pid}\n${Date.now()}\n${token}\n`;
}
/**
* Reads the owner token (line 3) from a lock file, or null if the file is
* missing/unreadable/tokenless. The token is what makes release and break
* ownership-safe: a process only ever acts on a lock whose token matches its own.
*/
async function readRestartLockToken(lockPath: string): Promise<string | null> {
let raw: string;
try {
raw = await readFile(lockPath, 'utf8');
} catch {
return null;
}
const token = raw.split('\n')[2]?.trim();
return token ? token : null;
}
/**
* Returns true when a lock's contents are stale: older than RESTART_LOCK_STALE_MS,
* or unparseable (a corrupt or partially written lock left by a crashed owner).
*/
function isRestartLockContentStale(raw: string, now: number): boolean {
const stampLine = raw.split('\n')[1] ?? '';
const stamp = Number.parseInt(stampLine.trim(), 10);
if (!Number.isFinite(stamp)) {
return true;
}
return now - stamp >= RESTART_LOCK_STALE_MS;
}
/**
* Path of the short-lived registry mutex that guards EVERY transition of the
* restart lock (acquire, release, takeover). Held only across a few filesystem
* ops — never across the restart itself — so contention clears in microseconds.
*/
function restartMutexPath(lockPath: string): string {
return `${lockPath}.mutex`;
}
/** Brief back-off between registry-mutex acquisition attempts (held microseconds). */
const RESTART_MUTEX_RETRY_MS = 20;
/**
* Staleness for the internal mutex / reclaim locks, judged by the file's mtime
* rather than its CONTENT. `open(path, 'wx')` creates the inode (with a fresh
* mtime) before any token/timestamp is written into it, so a content-based check
* would momentarily see that empty file as corrupt-and-stale and could reap a
* lock another contender is still acquiring. mtime is set atomically at creation,
* so a just-created lock always reads as live; only a lock whose holder died and
* stopped touching it ages past the threshold. These locks are never held across
* the restart itself (only a couple of filesystem ops), so any mtime this old can
* belong only to a dead holder.
*/
async function isRestartLockPathStale(path: string, now: number): Promise<boolean> {
try {
const info = await stat(path);
return now - info.mtimeMs >= RESTART_LOCK_STALE_MS;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return false; // Gone, not stale — the caller will re-contend.
}
return false; // Can't stat — treat as live and back off rather than reap.
}
}
/** Path of the reclaim lock that serializes reaping of a crashed-holder mutex. */
function restartReclaimPath(mutexPath: string): string {
return `${mutexPath}.reclaim`;
}
/**
* Reap a registry mutex left behind by a process that CRASHED mid-transition —
* one whose file has aged past RESTART_LOCK_STALE_MS. Because the mutex is held
* only for a couple of filesystem ops (no sleeps, never across the restart), a
* mutex this old can only belong to a dead holder.
*
* The reap removes the dead mutex but never CREATES/holds it — acquisition stays
* the single `open('wx')` create in {@link acquireRestartMutex}, so exactly one
* contender wins ownership no matter how the reap and acquires interleave. The
* removal is made conditional by a dedicated reclaim lock: while it is held the
* dead mutex is stable (its dead holder will never touch it, and no other
* reclaimer can race), so re-reading it and removing it only if it is STILL stale
* is a true compare — a live holder's fresh mutex is never removed. This closes
* the reclaim race a content-blind rename-and-restore left open (a third
* contender slipping into the gap while a fresh mutex was moved aside).
*/
async function reclaimStaleRestartMutex(mutexPath: string): Promise<void> {
const reclaimPath = restartReclaimPath(mutexPath);
let handle: Awaited<ReturnType<typeof open>>;
try {
handle = await open(reclaimPath, 'wx');
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
throw err;
}
// Someone is already reclaiming. If their reclaim lock is itself stale by
// mtime, its holder crashed mid-reap (the lock spans only a stat + unlink,
// microseconds) — clear it so a later pass can retry. Otherwise a live
// reclaimer has it; back off. Either way we do not reap the mutex this pass.
if (await isRestartLockPathStale(reclaimPath, Date.now())) {
await unlink(reclaimPath).catch(() => {});
}
return;
}
try {
// Re-check the mutex UNDER the reclaim lock and remove it only if it is STILL
// stale by mtime. A live holder's mutex is fresh and is left untouched; a dead
// holder's mutex is stable here (its holder is gone and no other reclaimer can
// race us), so this re-check is authoritative.
if (await isRestartLockPathStale(mutexPath, Date.now())) {
await unlink(mutexPath).catch(() => {});
}
} finally {
await handle.close();
await unlink(reclaimPath).catch(() => {});
}
}
/**
* Acquire the registry mutex, BLOCKING (with brief back-offs) until held, and
* return a token-gated release. This is the single point of mutual exclusion for
* the restart lock: acquire, release, and stale/timeout takeover all run under it,
* so "read the lock, then mutate it" is atomic — no acquirer, releaser, or breaker
* can ever interleave with another. A mutex left by a crashed holder is reclaimed
* once it ages past the stale threshold.
*/
async function acquireRestartMutex(
mutexPath: string,
token: string,
): Promise<RestartGuard['release']> {
for (;;) {
let handle: Awaited<ReturnType<typeof open>>;
try {
handle = await open(mutexPath, 'wx');
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
throw err;
}
// Staleness is judged by mtime, not content, so a mutex that exists but has
// not yet had its token written (the open-before-write window) reads as live
// and is never wrongly reaped.
if (!(await isRestartLockPathStale(mutexPath, Date.now()))) {
// A live holder has it — it will be gone in microseconds. Back off briefly.
await new Promise((resolve) => setTimeout(resolve, RESTART_MUTEX_RETRY_MS));
continue;
}
await reclaimStaleRestartMutex(mutexPath);
continue;
}
// We created the mutex. Populate it with our token; if writing fails, clean up
// our own file so we never leak an empty mutex that a peer would have to reap.
try {
await handle.writeFile(formatRestartLockContent(token));
await handle.close();
} catch (err) {
await handle.close().catch(() => {});
await unlink(mutexPath).catch(() => {});
throw err;
}
return async (): Promise<void> => {
if ((await readRestartLockToken(mutexPath)) !== token) return;
await unlink(mutexPath).catch(() => {});
};
}
}
/**
* Acquire the fleet restart lock, serializing concurrent `mosaic fleet restart`
* invocations across processes. Each restart tears the tmux holder (and the
* agent sessions inside it) down and back up; without this guard a re-entrant
* restart relaunches agents against a half-torn-down holder, which fails and
* tight-loops. A re-entrant caller waits for the in-flight restart to release
* the lock (clean shutdown settled) before proceeding, breaks a stale lock left
* by a crashed owner, and after RESTART_LOCK_MAX_WAIT_MS breaks the lock to
* avoid a permanent deadlock.
*
* Correctness rests on a single invariant: EVERY transition of the lock — taking
* a free lock, taking over a stale/timed-out one, and releasing — happens under
* the registry mutex. Because the check ("is the lock free / stale / fresh?") and
* the mutation that follows it both run while the mutex is held, they are atomic:
* no other acquirer, releaser, or breaker can slip in between. That is what makes
* takeover a true compare-and-swap rather than a content-blind clobber — a normal
* `open('wx')` acquirer cannot create a fresh lock in a gap, and the original
* owner's `release()` (also mutex-gated and token-checked) cannot drop a lock a
* breaker already took over. So no interleaving lets two restarts both own the
* lock and run concurrently.
*/
export async function acquireRestartLock(
mosaicHome: string,
sleepFn: SleepFn,
): Promise<RestartGuard> {
const token = randomUUID();
const lockPath = restartLockPath(mosaicHome);
const mutexPath = restartMutexPath(lockPath);
await mkdir(dirname(lockPath), { recursive: true });
const release = async (): Promise<void> => {
// Mutex-gated and token-gated: only remove the lock if it is still ours. If
// another caller took it over (after a stale/timeout break) the token no
// longer matches and we leave their lock intact.
const releaseMutex = await acquireRestartMutex(mutexPath, token);
try {
if ((await readRestartLockToken(lockPath)) === token) {
await unlink(lockPath).catch(() => {});
}
} finally {
await releaseMutex();
}
};
const deadline = Date.now() + RESTART_LOCK_MAX_WAIT_MS;
for (;;) {
let owned = false;
const releaseMutex = await acquireRestartMutex(mutexPath, token);
try {
// Read and (if appropriate) mutate the lock atomically under the mutex.
let current: string | null = null;
let absent = false;
try {
current = await readFile(lockPath, 'utf8');
} catch (readErr) {
if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') {
absent = true;
} else {
current = null; // Unreadable/corrupt: treat as stale.
}
}
const now = Date.now();
if (absent) {
// Lock is free — take it.
await writeFile(lockPath, formatRestartLockContent(token));
owned = true;
} else {
const stale = current === null || isRestartLockContentStale(current, now);
const timedOut = now >= deadline;
if (stale || timedOut) {
process.stderr.write(
stale
? 'Breaking stale fleet restart lock.\n'
: `Timed out after ${RESTART_LOCK_MAX_WAIT_MS}ms waiting for the in-flight fleet ` +
'restart; breaking the lock.\n',
);
// Takeover is just an overwrite — safe because we hold the mutex, so no
// acquirer or releaser can touch the lock between our read and this write.
await writeFile(lockPath, formatRestartLockContent(token));
owned = true;
}
// else: a fresh restart owns it — wait below and re-evaluate.
}
} finally {
await releaseMutex();
}
if (owned) {
return { release };
}
await sleepFn(RESTART_LOCK_POLL_INTERVAL_MS);
}
}
/**
* Returns the systemctl --user enable command for a given unit.
* Used by the install auto-enable step to persist units across reboots.
@@ -1472,7 +1172,6 @@ export function isSendAccepted(capturedOutput: string): SendVerifyResult {
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
const runner = deps.runner ?? runCommand;
const sleepFn = deps.sleepFn ?? defaultSleep;
const paths = resolveFleetPaths(deps.mosaicHome);
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
@@ -1586,22 +1285,9 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.command(`${action} [agent]`)
.description(`${action} the fleet holder or one agent`)
.action(async (agent?: string) => {
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const roster = await loadRosterForCommand(cmd);
if (agent) {
getRosterAgent(roster, agent);
// Single-agent restart is guarded too: it can race a full restart that
// is tearing the shared holder down.
if (action === 'restart') {
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
try {
await runChecked(runner, buildFleetServiceCommand(action, agent));
} finally {
await guard.release();
}
return;
}
await runChecked(runner, buildFleetServiceCommand(action, agent));
return;
}
@@ -1612,21 +1298,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
);
return;
}
if (action === 'restart') {
// Serialize the holder+agents teardown/relaunch behind the restart lock
// so a re-entrant restart waits for clean shutdown before relaunching,
// instead of racing a half-torn-down holder into a tight loop.
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
try {
await runChecked(runner, buildFleetServiceCommand(action));
for (const rosterAgent of roster.agents) {
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
}
} finally {
await guard.release();
}
return;
}
await runChecked(runner, buildFleetServiceCommand(action));
for (const rosterAgent of roster.agents) {
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));